enhance(#3418): report real codebase drift instead of the whole repository (#4124)

* fix(#3418): write the codebase-drift baseline from code instead of agent prose

writeMappedCommit shipped correct and callerless, so no full map-codebase run ever wrote last_mapped_commit. The gate then read null and diffed HEAD against the empty tree, reporting every tracked file as newly added on every run.

Adds the stamp-codebase-map leaf verb and calls it from the map-codebase workflow and the execute-phase auto-remap path, replacing the prose instruction that asked the mapper agent to stamp its own output. An agent that concludes its work is already done skips a prose step silently, which is the failure the stamp exists to detect.

The gate now reports an absent or unresolvable baseline as skipped, with reason no-mapped-commit or unresolvable-mapped-commit, rather than as whole-repo drift. Files under .planning/ are excluded from the diff so the map's own commit does not read as seven new directories on the next run.

Emitted-Drift-Ack-Growth: map-codebase.md — adds the stamp_codebase_map step and its rationale, new workflow content this change requires

* test(#3418): cover the stamp writer and the absent-baseline gate

* docs(#3418): document how the drift baseline is written and skipped

* docs(#3418): note that a manual stamp reflows the map's whitespace

writeMappedCommit writes through platformWriteSync, which normalizes markdown whitespace on .md targets. Run in its workflow position the stamp lands on documents the mapper just wrote, so the normalization is folded into the same commit, but a hand-run stamp over an already-committed map reflows that map as a side effect. Reported on the issue thread.

* chore(#3418): add changeset fragment

Typed Changed to match the enhancement route the linked issue's label sets. The docs-required lint is satisfied by the ARCHITECTURE.md update already in this branch.

* fix(#3418): anchor the planning-artifact filter to the repo root

git diff --name-status prints repo-root-relative paths whatever the cwd, so computing the exclusion prefix against cwd yielded ".planning/" while git printed "sub/.planning/" and the filter silently matched nothing from a subdirectory.

* fix(#3418): derive the planning prefix from git, not from path arithmetic

Anchoring the exclusion prefix with path.relative() against `rev-parse --show-toplevel` broke on Windows, where os.tmpdir() hands back the 8.3 short form and git resolves the long one, so relative() produced a "../.." chain that matched nothing. `rev-parse --show-prefix` gives the cwd's root-relative prefix from the same producer as the diff paths, so the two sides cannot disagree.

* fix(#3418): take the planning lock around the codebase-map stamp

Stamping seven documents is seven frontmatter read-modify-writes, and two stampers can run at once: the full map-codebase run and the execute-phase auto-remap. Wrap the write loop in withPlanningLock, the same lock the other .planning/ writers take, so a concurrent pair cannot lose an update.

Also corrects the path-arithmetic comment, which read as if the Windows short-path hazard applied to the .planning half of the prefix. It applies to the rejected --show-toplevel alternative; both sides of the surviving relative() call are the same cwd string.

* fix(#3418): read HEAD and the map file list under the planning lock

The stamp resolved HEAD and listed the present codebase-map documents before it acquired the planning lock, so a stamper that then waited on the lock could write its now-stale sha over a newer one, or recreate a document deleted while it waited as a frontmatter-only stub. Both reads now happen inside the lock, matching the read-and-write-in-one-lock pattern config.cts and phase.cts already use. An empty --files value is refused as well instead of silently widening the stamp to all seven documents.

* fix(#3418): narrow the map stamp to the documents an update run refreshed

An "Update - only update specific documents" run reached the new stamp step with no --files narrowing, so the six documents the user did not select were stamped at HEAD and read as freshly mapped. The selection now threads through to --files, the same way the auto-remap path already does.

A bare --files (an unquoted empty shell variable drops the token) parsed to null, indistinguishable from an absent flag, so it skipped the empty-filter refusal and stamped all seven. Presence is now read off argv.

* fix(#3418): require the drift baseline to resolve to a commit, not any object

`git cat-file -t` exits 0 for a tree or blob sha and for a ref name, and `git diff <tree> HEAD` is valid, so an exit-code-only probe accepted a baseline that is not a commit and reported the resulting diff as real drift. Check the reported type instead of the exit code alone, which routes every non-commit stamp to the same `unresolvable-mapped-commit` skip.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Norman Yee
2026-09-08 19:08:20 -10:00
committed by GitHub
parent 3ad75a6d59
commit 42c02a00c0
7 changed files with 594 additions and 15 deletions

View File

@@ -41,8 +41,9 @@ operates in **incremental-remap mode**:
- Reject path values that contain `..`, start with `/`, or include shell
metacharacters (`;`, `` ` ``, `$`, `&`, `|`, `<`, `>`). If all provided
paths are invalid, fall back to a normal whole-repo run.
- On write, each mapper stamps `last_mapped_commit: <HEAD sha>` into the YAML
frontmatter of every document it produces (see `bin/lib/drift.cjs:writeMappedCommit`).
- The `last_mapped_commit` baseline is NOT the mapper's job. It is stamped
deterministically by the `stamp_codebase_map` step below, on every run,
incremental or full. See that step for why.
**Explicit contract — propagate `--paths` through a single normalized
variable.** Downstream steps (`spawn_agents`, `sequential_mapping`, and any
@@ -103,9 +104,21 @@ What's next?
Wait for user response.
If "Refresh": Delete .planning/codebase/, continue to create_structure
If "Update": Ask which documents to update, continue to spawn_agents (filtered)
If "Update": Ask which documents to update, then record the selection for the
stamp step below and continue to spawn_agents (filtered):
```bash
# Comma-separated filenames the user selected, e.g. "STACK.md,CONCERNS.md":
UPDATED_DOCS="<selected documents>"
```
If "Skip": Exit workflow
`UPDATED_DOCS` narrows `stamp_codebase_map`. Leave it empty on every other
path (Refresh, first run, `--paths`), which regenerate all seven documents.
An Update run does not touch the documents the user did not select, so
stamping those at HEAD would claim a freshness they do not have.
**If doesn't exist:**
Continue to create_structure.
</step>
@@ -347,6 +360,40 @@ wc -l .planning/codebase/*.md
If any documents missing or empty, note which agents may have failed.
Continue to stamp_codebase_map.
</step>
<step name="stamp_codebase_map">
Stamp the drift baseline into every document that was just written:
```bash
gsd_run stamp-codebase-map ${UPDATED_DOCS:+--files "$UPDATED_DOCS"}
```
This writes `last_mapped_commit: <HEAD sha>` and `last_mapped_at: <date>` into
the YAML frontmatter of each `.planning/codebase/*.md` that exists. It runs on
every mapping run, incremental (`--paths`) and full alike. `--files` narrows it
to the documents an Update run actually refreshed; `--paths` needs no narrowing
because all seven are regenerated, just scoped in content.
**Why this is a shell step and not an instruction to the mapper.** The stamp is
the only machine-readable freshness marker: the `verify codebase-drift` gate
reads it to decide what to diff HEAD against. The human-readable markers the
mapper writes (`**Analysis Date:**`, `<!-- refreshed: ... -->`) are restamped
unconditionally on an Update run, so a mapper that decides its work is already
done and rewrites only the dates still looks fresh to a human. Leaving the
machine-readable stamp to the same agent reproduces exactly the failure the
stamp exists to detect. A shell step cannot be skipped by a confident agent.
The command is non-blocking: it emits `skipped` with a `reason` outside a git
repo or when no documents exist. Report `stamped` and `commit` in the summary
if any entry in `failed` is non-empty; otherwise continue silently.
Run in this position, before `commit_codebase_map`, the stamp lands on
documents the mapper just wrote, so its markdown whitespace normalization is
folded into the same commit. Running `stamp-codebase-map` by hand against an
already-committed map reflows that map's whitespace as a side effect.
Continue to scan_for_secrets.
</step>