Skip to content

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-integrity verb 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/.

T-C9RD-consolidate-augmented-into-solutions

LocationRole today
apps/sdlc/lib/services/gate/ops/worktree-scope.tsReference 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.tsSecond reference gate op — the byte-comparison precedent (formats fixtures, byte-compares against *.expected.md, non-zero on drift).
apps/sdlc/lib/registry.ts#defineOpOp descriptor factory every gate verb is declared through.
apps/sdlc/lib/services/git/easy.ts#showAtRevTyped 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.tsGenerated 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.tsRegenerates that barrel from the ops discovery walk.
apps/sdlc/lib/services/gate/tests/worktree_scope.test.tsReference 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.

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.

  1. Add two methods to the typed git client apps/sdlc/lib/services/git/easy.ts, both built on the existing rawChecked seam (never a raw child_process spawn, per apps/sdlc/lib/CLAUDE.md):

    • lsTreeBlobs(rev: string, pathspec: string): Array<{ path: string; sha: string; mode: string }> — parses git ls-tree -r <rev> -- <pathspec>, keeping blob rows only and skipping commit rows (submodules).
    • hashObject(relPath: string): string | nullgit hash-object -- <path>, returning null when the path does not exist.
  2. Create apps/sdlc/lib/services/gate/ops/move-integrity.ts declaring defineOp({ path: ["gate", "move-integrity"], hidden: true, ... }), modelled on worktree-scope.ts. Input zod schema: { projectRoot: string, oldRef: string, oldRoot: string, newRoot: string, allow?: string[] }. oldRoot / newRoot are repo-relative directory prefixes; oldRef defaults to HEAD at the CLI layer.

  3. Handler body:

    • git.lsTreeBlobs(oldRef, oldRoot) to enumerate the origin tree. Empty result is an INVALID_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 oldRoot prefix to get the tree-relative suffix and join it onto newRoot to get the expected new path.
    • Skip the pair when the tree-relative suffix matches any allow glob (match with Bun.Glob, consistent with existing glob use in the repo).
    • git.hashObject(newPath); null → finding kind: "missing". SHA mismatch → finding kind: "modified". Equal → counted as verified.
    • Also enumerate the new tree on disk under newRoot and report paths with no old counterpart as kind: "extra", so a move that gained files is visible too.
  4. Output zod schema

    { old_ref, old_root, new_root, checked: number, allowed: string[],
    findings: Array<{ path, new_path, kind }> }

    where kind is a z.enum(["missing", "modified", "extra"]). Findings sort by path so output is stable across runs.

  5. Render hook renderMoveIntegrity(out, io): return 0 with a one-line move-integrity: <checked> files byte-identical summary when findings is empty; otherwise write one <kind> <path> -> <new_path> citation per finding to stderr plus a trailing hint naming --allow, and return 1.

  6. Regenerate the barrel by running apps/sdlc/scripts/gen-op-manifest.ts under bun, and commit the resulting apps/sdlc/lib/_generated/op-manifest.ts diff.

  7. Add apps/sdlc/lib/services/gate/tests/move_integrity.test.ts following the worktree_scope.test.ts shape — build an ephemeral git repo in a temp dir, commit an old/ tree, git mv it to new/, 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 as modified; a deleted file is cited as missing; an added file is cited as extra; the same mutated file under --allow exits 0.

  8. 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.

LocationKindChange
apps/sdlc/lib/services/gate/ops/move-integrity.tsnewThe gate op: input/output schemas, handler, render hook, module docstring.
apps/sdlc/lib/services/gate/tests/move_integrity.test.tsnewEphemeral-repo suite driving the op through the real CLI across clean/modified/missing/extra/allowlisted cases.
apps/sdlc/lib/services/git/easy.tsmodifyAdd lsTreeBlobs and hashObject to the Git class, built on rawChecked.
apps/sdlc/lib/_generated/op-manifest.tsmodifyRegenerated barrel gaining the gate/ops/move-integrity.ts import.
  • AC-1: apps/sdlc/lib/services/gate/ops/move-integrity.ts exists and default-exports a defineOp descriptor whose path is ["gate", "move-integrity"].
  • AC-2: bun apps/sdlc/cli/sdlc.ts gate move-integrity --help exits 0 and documents --old-ref, --old-root, --new-root, and --allow.
  • AC-3: Running the verb against a tree moved with git mv and 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 kind extra.
  • 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-root naming no blobs) fails with a non-zero exit and a message naming the empty tree, rather than exiting 0.
  • AC-8: --output json emits an object with keys old_ref, old_root, new_root, checked, allowed, and findings.
  • AC-9: apps/sdlc/lib/_generated/op-manifest.ts contains the line import "@lib/services/gate/ops/move-integrity.ts"; and re-running the generator apps/sdlc/scripts/gen-op-manifest.ts produces no further diff.
  • AC-10: bun test apps/sdlc/lib/services/gate/tests/move_integrity.test.ts passes.
  • 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.ts returns no matches.
  • Wiring the verb into lefthook.yml as 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 (rumdl rules, lefthook.yml markdown-fmt) implicated in the originating incident.
  • none

Spawned by /sdlc:spawn-task-pr on 2026-07-19 UTC from T-C9RD-consolidate-augmented-into-solutions in https://github.com/sksizer/dev.


← Back to Tasks