T-3C5K-move-integrity-gate
Status: open/ready · Impact: medium · Complexity: small
AUTO-DEFINED: this spec was best-effort machine-authored by /sdlc:task-auto-define on 2026-07-19 because the task is autonomy: autonomous/pr. Review the Goal, Approach, Today, Files-to-touch, and Acceptance-criteria carefully before trusting it.
Verifying a large directory move today depends on the parent agent re-deriving
truth after the implementer reports success, with no deterministic gate to lean
on. This task closes that gap on SDLC by adding a sdlc gate move-integrity
verb that mechanically asserts a moved tree is byte-identical to its origin,
turning a manual tree-level diff into a checkable exit code. Originating from
T-C9RD-consolidate-augmented-into-solutions on
sksizer/dev.
Verifying a large directory move today depends on the parent agent re-deriving truth after the implementer reports success. During T-C9RD a lefthook markdown hook semantically rewrote 107 vendored book-content files mid-move (MD034 rewrote bare URLs as autolinks, MD025 demoted a book’s ’# Video Courses’ to ’## Video Courses’); the wave-1 implementing agent reported ‘zero content-changed files’ and had restored only 21 of the 107. The damage was caught only by a manual tree-level diff. Add a
sdlc gate move-integrityverb that takes an old-tree ref/path and a new-tree path and asserts every moved file is byte-identical modulo an explicit allowlist, exiting non-zero with per-file citations otherwise. Implement it alongside the existing gate ops in apps/sdlc/lib/services/gate/ops/ (see worktree-scope.ts and markdown-fixtures.ts for the op shape) and register the verb in the sdlc CLI at apps/sdlc/cli/.
| Location | Role today |
|---|---|
apps/sdlc/lib/services/gate/ops/worktree-scope.ts | Reference gate-op shape: defineOp({ path: ["gate", ...], hidden: true, input, output, cli: { render } }) with a render hook returning 1 to paint the exit red. |
apps/sdlc/lib/services/gate/ops/markdown-fixtures.ts | Second reference gate op — the byte-comparison precedent (formats fixtures, byte-compares against *.expected.md, non-zero on drift). |
apps/sdlc/lib/registry.ts#defineOp | Op descriptor factory every gate verb is declared through. |
apps/sdlc/lib/services/git/easy.ts#showAtRev | Typed git client. Reads one path’s content at a rev; has no tree-listing or blob-hashing method, so a tree-wide byte comparison cannot be expressed through it today. |
apps/sdlc/lib/_generated/op-manifest.ts | Generated static barrel; a new op module is invisible to the CLI and the compiled binary until it appears here. |
apps/sdlc/scripts/gen-op-manifest.ts | Regenerates that barrel from the ops discovery walk. |
apps/sdlc/lib/services/gate/tests/worktree_scope.test.ts | Reference gate-op suite: builds an ephemeral git repo in a temp dir and drives the op through the real CLI. |
No verb asserts that a moved directory tree is byte-identical to its origin.
Verifying a move is a manual git diff / diff -r by whoever reviews the
result, which is how the T-C9RD hook-rewrite damage reached review undetected.
Proposed
Section titled “Proposed”A hidden gate op sdlc gate move-integrity that takes an old tree (a git rev
plus a path prefix) and a new tree (a working-tree path prefix), enumerates
every blob under the old prefix at that rev, maps each to its counterpart under
the new prefix, and compares git blob SHAs rather than text — binary-safe
and exactly byte-level. Any file whose counterpart is missing or whose SHA
differs is a finding. An explicit --allow <glob> list (repeatable) suppresses
intended edits, so the gate states the allowlist rather than assuming none.
Findings print one line per file on stderr and the op exits 1.
Blob-SHA comparison is the load-bearing choice: git ls-tree -r <rev> already
carries the old SHAs, so the old side needs no file reads, and git hash-object produces the same identity function for the new side.
Approach
Section titled “Approach”-
Add two methods to the typed git client
apps/sdlc/lib/services/git/easy.ts, both built on the existingrawCheckedseam (never a rawchild_processspawn, perapps/sdlc/lib/CLAUDE.md):lsTreeBlobs(rev: string, pathspec: string): Array<{ path: string; sha: string; mode: string }>— parsesgit ls-tree -r <rev> -- <pathspec>, keepingblobrows only and skippingcommitrows (submodules).hashObject(relPath: string): string | null—git hash-object -- <path>, returningnullwhen the path does not exist.
-
Create
apps/sdlc/lib/services/gate/ops/move-integrity.tsdeclaringdefineOp({ path: ["gate", "move-integrity"], hidden: true, ... }), modelled onworktree-scope.ts. Input zod schema:{ projectRoot: string, oldRef: string, oldRoot: string, newRoot: string, allow?: string[] }.oldRoot/newRootare repo-relative directory prefixes;oldRefdefaults toHEADat the CLI layer. -
Handler body:
git.lsTreeBlobs(oldRef, oldRoot)to enumerate the origin tree. Empty result is anINVALID_INPUT-shaped failure (old tree is empty at <ref>:<root>), not a silent pass — an empty old tree would otherwise make the gate vacuously green.- For each old blob, strip the
oldRootprefix to get the tree-relative suffix and join it ontonewRootto get the expected new path. - Skip the pair when the tree-relative suffix matches any
allowglob (match withBun.Glob, consistent with existing glob use in the repo). git.hashObject(newPath);null→ findingkind: "missing". SHA mismatch → findingkind: "modified". Equal → counted as verified.- Also enumerate the new tree on disk under
newRootand report paths with no old counterpart askind: "extra", so a move that gained files is visible too.
-
Output zod schema
{ old_ref, old_root, new_root, checked: number, allowed: string[],findings: Array<{ path, new_path, kind }> }where
kindis az.enum(["missing", "modified", "extra"]). Findings sort bypathso output is stable across runs. -
Render hook
renderMoveIntegrity(out, io): return 0 with a one-linemove-integrity: <checked> files byte-identicalsummary whenfindingsis empty; otherwise write one<kind> <path> -> <new_path>citation per finding to stderr plus a trailing hint naming--allow, and return 1. -
Regenerate the barrel by running
apps/sdlc/scripts/gen-op-manifest.tsunderbun, and commit the resultingapps/sdlc/lib/_generated/op-manifest.tsdiff. -
Add
apps/sdlc/lib/services/gate/tests/move_integrity.test.tsfollowing theworktree_scope.test.tsshape — build an ephemeral git repo in a temp dir, commit anold/tree,git mvit tonew/, and drive the op through the real CLI (apps/sdlc/cli/sdlc.ts). Cases: clean move exits 0; a one-byte-mutated file exits 1 and cites that path asmodified; a deleted file is cited asmissing; an added file is cited asextra; the same mutated file under--allowexits 0. -
Document the verb in the op’s module docstring (the house pattern — the docstring is the authoritative contract for gate ops), including the T-C9RD incident as the motivating case.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
apps/sdlc/lib/services/gate/ops/move-integrity.ts | new | The gate op: input/output schemas, handler, render hook, module docstring. |
apps/sdlc/lib/services/gate/tests/move_integrity.test.ts | new | Ephemeral-repo suite driving the op through the real CLI across clean/modified/missing/extra/allowlisted cases. |
apps/sdlc/lib/services/git/easy.ts | modify | Add lsTreeBlobs and hashObject to the Git class, built on rawChecked. |
apps/sdlc/lib/_generated/op-manifest.ts | modify | Regenerated barrel gaining the gate/ops/move-integrity.ts import. |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
apps/sdlc/lib/services/gate/ops/move-integrity.tsexists and default-exports adefineOpdescriptor whosepathis["gate", "move-integrity"]. - AC-2:
bun apps/sdlc/cli/sdlc.ts gate move-integrity --helpexits 0 and documents--old-ref,--old-root,--new-root, and--allow. - AC-3: Running the verb against a tree moved with
git mvand nothing else exits 0. - AC-4: Mutating one byte of one moved file makes the same invocation exit
1 and print that file’s path on stderr with kind
modified. - AC-5: Deleting one moved file makes the invocation exit 1 and cite that
path with kind
missing; adding an unmatched file under the new root cites it with kindextra. - AC-6: Passing
--allow <glob>matching the mutated file returns the same invocation to exit 0. - AC-7: An empty old tree (
--old-ref/--old-rootnaming no blobs) fails with a non-zero exit and a message naming the empty tree, rather than exiting 0. - AC-8:
--output jsonemits an object with keysold_ref,old_root,new_root,checked,allowed, andfindings. - AC-9:
apps/sdlc/lib/_generated/op-manifest.tscontains the lineimport "@lib/services/gate/ops/move-integrity.ts";and re-running the generatorapps/sdlc/scripts/gen-op-manifest.tsproduces no further diff. - AC-10:
bun test apps/sdlc/lib/services/gate/tests/move_integrity.test.tspasses. - AC-11: The two new methods added in this task are reachable only through
the typed git client —
grep -n "child_process" apps/sdlc/lib/services/gate/ops/move-integrity.tsreturns no matches.
Out of scope
Section titled “Out of scope”- Wiring the verb into
lefthook.ymlas an automatic pre-commit gate. It is an on-demand verb an agent or reviewer invokes around a move; the hook wiring is a separate decision. - Auto-repairing drift (restoring the mutated files). The gate reports; the caller fixes.
- Comparing file modes, symlink targets, or
.gitattributes-driven normalization beyond what the blob SHA already encodes. - Detecting which hook or process caused the drift. The T-C9RD case pointed at lefthook markdown rules, but attribution stays a human read of the citations.
- Any change to the markdown hooks (
rumdlrules,lefthook.ymlmarkdown-fmt) implicated in the originating incident.
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”Spawned by /sdlc:spawn-task-pr on 2026-07-19 UTC from T-C9RD-consolidate-augmented-into-solutions in https://github.com/sksizer/dev.