From 9cab1fc0fa293258062e61cad4b393c6b3cfbcfc Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 14 Sep 2026 16:11:28 +0200 Subject: [PATCH] fix(guard): the roadmap guard counted agy lane scratch as a second copy of the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make gate` went red on a clean branch with 1107 "NEW unprovable claims", every one of them pointing at an innocent roadmap row. The claims were real rows — read twice. `.claude/worktrees/` is agy's throwaway lane workspace: a full checkout, roadmap.yaml included, created per review lane and left behind when a lane dies. It is in `.git/info/exclude`, so nothing under it can ever reach the tree CI gates. But this guard's universe deliberately includes UNTRACKED files — that is the hole it was written to close (#2512, an untracked file is exactly how a new uncited claim arrives) — so it walked straight into the lane checkouts and counted every entry twice. The arithmetic says it plainly: completed claims 2266 before, 1133 after. Exactly half. Universe 46 roadmap files before, 23 after. NEW 1107 -> 0. check_complexity_ratchet.sh:149 already prunes `*/.claude/worktrees/*`. That lesson was paid for once and never propagated; this guard is the second place it was needed. The prune is applied in `is_candidate` rather than only in the walk, so the `git ls-files` half of the universe is covered by the same rule, and the walk skips descending into what was measured as 66 registered lane worktrees. This does not widen any hole: a path under `.claude/worktrees/` is in `.git/info/exclude` and cannot be committed, so it can never be part of the tree this guard gates. Case-table row ships with the fix: a fixture roadmap planted at `.claude/worktrees/lane-deadbeef/docs/roadmaps/roadmap.yaml` with a completed-and-uncited entry must contribute 0 records. It reads `want 0 got 1` without the prune. Pmat-Ticket: PMAT-3226 Co-Authored-By: Claude Opus 5 (1M context) --- docs/roadmaps/roadmap.yaml | 17 ++++++++ scripts/check_roadmap_completion_is_cited.sh | 42 +++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 47b04c766e..8158d76411 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -17704,3 +17704,20 @@ roadmap: labels: - kind:code notes: null +- id: PMAT-3226 + github_issue: null + item_type: task + title: 'check_roadmap_completion_is_cited: lane scratch under .claude/worktrees is scanned as if it were the tree' + status: planned + priority: medium + assigned_to: null + created: 2026-09-14T14:10:24Z + updated: 2026-09-14T14:10:24Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null diff --git a/scripts/check_roadmap_completion_is_cited.sh b/scripts/check_roadmap_completion_is_cited.sh index 674c39183d..1db8819df2 100755 --- a/scripts/check_roadmap_completion_is_cited.sh +++ b/scripts/check_roadmap_completion_is_cited.sh @@ -224,7 +224,24 @@ def tokens(*texts): # where a work contract lives. The second arm is not redundant: the first, # alone, gave a free pass to any pmat-schema file under docs/roadmaps/ that was # not called roadmap*.yaml, which the case table caught as two red rows. +# `.claude/worktrees/` is agy's throwaway lane workspace: a full checkout, +# roadmap.yaml and all, created per lane and left behind when one dies. It is in +# `.git/info/exclude`, so nothing under it can ever reach the tree CI gates -- +# but this guard's universe deliberately includes UNTRACKED files (that is the +# hole it was written to close), so it walked straight into them and counted a +# second copy of every entry. Measured on a clean branch after one review +# quorum: 1107 phantom "NEW unprovable claims" pointing at innocent rows. +# check_complexity_ratchet.sh:149 already prunes `*/.claude/worktrees/*`. +# Checked here rather than only in the walk so the `git ls-files` half of the +# universe is covered by the same rule. +SCRATCH_PREFIX = ".claude/worktrees/" + +def is_scratch(rel): + return rel.replace(os.sep, "/").startswith(SCRATCH_PREFIX) + def is_candidate(rel): + if is_scratch(rel): + return False d, fn = os.path.split(rel) if not fn.lower().endswith(".yaml"): return False @@ -234,7 +251,13 @@ def is_candidate(rel): cand = set() for dirpath, dirnames, filenames in os.walk(root): - dirnames[:] = [d for d in dirnames if d not in (".git", "target", "node_modules")] + reldir = os.path.relpath(dirpath, root).replace(os.sep, "/") + dirnames[:] = [ + d for d in dirnames + if d not in (".git", "target", "node_modules") + # do not descend into lane scratch: 66 worktrees were registered here + and (reldir + "/" + d).lstrip("./") != SCRATCH_PREFIX.rstrip("/") + ] for fn in filenames: rel = os.path.relpath(os.path.join(dirpath, fn), root) if is_candidate(rel): @@ -580,6 +603,23 @@ YAML row 'completed PHASE is gated' uncited "$(short "$(verdict_of 'phase:R-PHASE-PARENT/Phase one')")" row 'duplicate id gets a positional key' uncited "$(short "$(verdict_of 'R-DUP [#2]')")" + # LANE SCRATCH. `.claude/worktrees/` is agy's throwaway lane workspace and is + # in `.git/info/exclude`, so nothing under it can ever reach the tree CI sees. + # It holds a full checkout, roadmap.yaml included. Walking into it counted a + # second copy of every entry: measured 1107 phantom "NEW unprovable claims" on + # a clean branch, a false RED that sends the reader to 1107 innocent rows. + # check_complexity_ratchet.sh already prunes `*/.claude/worktrees/*`; this + # guard never got the same fix. + mk .claude/worktrees/lane-deadbeef/docs/roadmaps/roadmap.yaml <<'YAML' +roadmap: +- id: R-LANE-SCRATCH + status: completed + notes: null +YAML + scan_roadmaps "$TD" >"$TD/scan3.out" 2>"$TD/scan3.err" || true + n_lane=$(grep -c 'lane-deadbeef' "$TD/scan3.out" "$TD/scan3.err" 2>/dev/null | awk -F: '{t+=$2} END {print t+0}') + row 'lane scratch contributes no records' 0 "$n_lane" + n_out=$(grep -c '^SCOPE out crates/other/roadmap.yaml' "$TD/scan.err" || true) row 'foreign schema is reported out of scope, not skipped' 1 "$n_out" n_rec=$(grep -c 'crates/other/roadmap.yaml' <<< "$SCAN" || true)