e6d047decc23dfc802a0ffcc032d4a23b863bf7f
3061 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e6d047decc |
fix(#4129): derive completed_phases from the ROADMAP authority; honor the progress-ratchet on every state write (#4359)
* test(#4129): failing-first regressions — completed_phases clobber on resyncing writes and phase-complete failure to increment * fix(#4129): completed_phases derives from the ROADMAP authority and the write path honors the progress-ratchet Three coordinated prongs (diagnosis in .gsd/bug/fix-4129-completed-phases-recompute/): P1 — buildStateFrontmatter's disk scan floors the completed-phases numerator at the milestone-scoped ROADMAP Complete-row count (deriveProgressFromRoadmap, the one owner), gated inside the same safeToUseRoadmapCount / not-withheld branch that owns the denominator. A completed phase whose verification routes stale (#2348 clean-commit-time drift) or is missing no longer under-counts forever. P2 — applyPreserveAlways's resync arm merges instead of wholesale-replacing on a measured scan: totals derived both directions (#2440), completed counters up-only (#2969 — the schema-declared progress-ratchet, now enforced on the write path like the read path always has), percent recomputed from the merged counters. The #3756 unmeasured guard and the #3242 explicit-progress contract are unchanged. P3 — phase complete's atomic 3-file commit passes the post-completion ROADMAP-derived counters through the #2736 authoritativeFm seam (new object direction for the progress key; completedOnlyRaise at the post-preservation re-assert), because the transaction's disk scan reads the pre-completion ROADMAP and failed to increment on the completing phase's own write. * fix(#4129): adversarial-review hardening — intent is a floor at BOTH authoritativeFm sites The pre-preservation merge could lower a correctly-higher disk-derived counter (a verification-passed phase whose ROADMAP table row drifted behind the disk signal). completedOnlyRaise now governs both application sites: the intent and the derivation agree on direction (up), never on subtraction. * fix(#4129): the ratchet merge keeps derived values verbatim when numerically equal The re-parsed derived block carries string scalars ("2") while the curated snapshot carries numbers (2); substituting the curated spelling over an equal derived one was a no-op in substance but a shape churn the ADR-3473 §8.7 reporting loop surfaced as a phantom preserved-over-disagreeing-derived warning on phase complete (ADR-3408 §8.5 Matrix B). Only a strictly-greater curated counter replaces the derived value now; percent gets the same verbatim rule. * changeset(#4129): backfill PR 4359 --------- Co-authored-by: sim <sim@local> |
||
|
|
06eba5fdb0 |
fix(#4130): parse phase-prefixed decision IDs (D4-01) (#4357)
* test(#4130): failing-first regression for phase-prefixed decision IDs Add the #4130 matrix: D4-01/D12-01 across all three bullet forms, tags, discretion, wrapped lead-ins, gate-level plan/verify end-to-end rows, and parity properties (well-formed digit-prefixed ids parse to their exact id; a non-digit injected into the prefix fails loud). Update the #2347 non-D-prefix fixture from D5-NN (now a legal grammar) to DEC-NN, and graduate the representative d5-prefix corpus fixture from could-not-parse to parsed-but-uncovered. All new rows are RED against origin/next; they go green with the parser fix in the next commit. * fix(#4130): parse phase-prefixed decision IDs (D4-01) The three declaration grammars, the parse-miss guard, the #3939 join regexes, and the token evidence all anchored on the literal 'D-' (or '**D-'), so an ID carrying a digit-run phase prefix between the leading letter and the hyphen matched nothing — while the #2347 shape detector correctly called those bullets decision-shaped, collapsing the whole CONTEXT.md to could-not-parse with 0 extracted instead of a coverage verdict. Derive the extractor ID grammar from one shared DECISION_ID_SOURCE ('D[0-9]*-' + the existing alnum tail, full id captured), widen the guard/join anchors to ID_ATTEMPT_SOURCE (bare 'D-' or a digit-initial prefix run, so a typo'd 'D4x-01' fails loud while letter-initial prose like 'Deferred-until' stays none-present), and align the bare-token evidence. Both gates and the gap-checker share the parser, so all three surfaces read phase-prefixed decisions now; the gate messages name the accepted forms including the phase-prefixed one. * docs(#4130): document the phase-prefixed decision identifier form The canonical CONTEXT.md reference said decisions carry 'a sequential D-NN identifier' with no mention of the optional phase-number prefix the parser now accepts (D4-01) or the alphanumeric tail it always accepted (D-INFRA-01). Name both in the Decision identifier format section, EN and ja-JP. * chore(#4130): changeset * chore(#4130): backfill PR number in changeset --------- Co-authored-by: sim <sim@local> |
||
|
|
48789fe9a9 |
fix(#4355): add --merge-async to test:coverage:unit:raw (#4356)
* test(#4355): assert test:coverage:unit:raw carries --merge-async tests/c8-merge-async-flag.test.cjs asserted the opposite based on a disproven assumption that `--reporter none` skips c8's merge phase -- it does not (Report.run() computes the merge unconditionally before consulting the reporter list). This is the failing-first assertion for the fix in the next commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4355): add --merge-async to test:coverage:unit:raw c8's Report.run() computes the full coverage merge unconditionally, even with --reporter none -- it only skips the final text/json output, not the merge dispatch (verified against node_modules/c8/lib/report.js). Without --merge-async this used the synchronous _getMergedProcessCov(), loading every raw per-process V8 coverage dump into memory at once and OOM-crashing release.yml's finalize-test job (run 33997057100) with a silent exit 1 and no diagnostic ~21s after the test suite itself finished cleanly ("# fail 0"). Same class already fixed on the other three coverage scripts via #4068/#4172. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(changeset): add changeset for #4355 coverage:unit:raw fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(changeset): backfill PR number for #4355 fix pr:0 -> pr:4356 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
a65cb291e8 |
fix(#4105): park the #3889 hang fixture on a settling timer (#4349)
* test(#4105): guard the #3889 hang fixture — must genuinely hang by itself and self-terminate RED at this sha: against the current never-settling-promise body the guard fails on the matrix line (Node 24: the unheld promise never self-terminates, ceiling expires) and off it (v22-class runtimes: the child exits rc=1 after ~60ms, never reaching the still-hanging checkpoint). Same shape as the #4104 self-exit regression: spawn the exact served body, observe liveness past the chunk bound and natural exit — no elapsed-value assertions. * fix(#4105): park the #3889 hang fixture on a settling timer The never-settling promise held no libuv handle, so the hang T1/T4 rely on was a property of the runtime's test-runner shutdown behavior, not of the fixture: v24/v26 happen to hold the loop open; v22-class runtimes exit rc=1 after ~60ms (# cancelled 1), so the chunk never reaches the timeout path and the two timeout assertions assert nothing. Park on a settling 10s timer (the #4104 idiom): an explicit handle makes the hang the fixture's on every Node line, 10s >> the 2000ms chunk bound (margin asserted structurally in the #4105 guard), ~0% CPU while parked, and guaranteed self-termination if a kill orphans it. Behavior on the Node 24 matrix line is unchanged — the chunk is still killed by the harness timeout (~2006ms) with the identical diagnostic. * test(#4105): drive the fixture guard off the child's exit event + runner timeout Review-driven restructure (Memtrace flaky_test_fixed_sleep on the 200ms poll interval): the guard now waits on the child's natural 'exit' event — no polling interval, no hand-rolled watchdog setTimeout. The immortal-body bound is the node:test per-test { timeout: 2 * HANG_PARK_MS } backstop, the health-validation #663 house pattern and the no-elapsed-assertion-compliant form. t.after still reaps the child on every path. Same failing-first arms: still-hanging checkpoint, natural-exit (no signal), exit code 0. * changeset(#4105) * changeset(#4105): backfill PR number --------- Co-authored-by: sim <sim@local> |
||
|
|
0be5bf865a |
enhance(#3783): audit-uat summary segments current-milestone vs archived debt (#4336)
* test(#3783): add failing coverage for audit-uat summary segmentation Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3783): segment audit-uat summary into current_milestone and archived buckets Additive: current_milestone/archived are new; total_items, total_files, parse_gap_files, by_phase, and by_category are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3783): add changeset fragment for audit-uat summary segmentation Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#3783): allowlist the new audit-uat-summary-segmentation test file lint-test-file-count.cjs baselines the "audit" module (keyed off bin/lib/audit.cjs) at 6 pre-existing files; this adds the new dedicated suite as a 7th, matching the module's existing one-file-per-feature-slice precedent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(#3783): fix phase/file number mismatch in the mixed-milestone fixture The active phase fixture used dir "02-current" with file "01-UAT.md" — a cross-phase stray per phase-id.cts's isPhaseArtifact/scopeToPhase (#3511), so the file was silently excluded from the scan and current_milestone read {files:0, items:0} instead of {files:1, items:1}. Confirmed by direct CLI run against a hand-built fixture before recommitting. Renamed the file to 02-UAT.md to match its directory's phase number, matching every other fixture in this suite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3783): backfill changeset PR number to 4336 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
f9f72cb54c |
enhance(#3777): opt-in concurrent per-plan planners in chunked mode (#4346)
* test(#3777): add failing-first coverage for concurrent per-plan planner dispatch Extracts and executes the real bash blocks this PR is about to add to plan-phase.md and chunked-planning-mode.md (CHUNKED_PARALLEL resolution and the BATCH_PLAN_IDS dedup guard), plus config-set/config-get coverage for the new planning.chunked_parallel key. Expected RED against the current shipped workflow text — the extraction anchors do not exist yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(#3777): dispatch chunked mode's per-plan planners concurrently within a Wave Adds opt-in planning.chunked_parallel (default false, byte-identical to the existing serial loop). When true and the runtime's negotiated dispatch capacity (dispatch-capacity, #3673) is greater than 1, chunked planning's per-plan Tasks that share one outline Wave are issued together instead of one at a time; a later Wave still waits for the current one to be verified on disk and committed. A host with no declared maxConcurrency (most non-Claude runtimes today) stays serial regardless of the setting. Resolution and the Plan-ID dedup guard live in chunked-planning-mode.md itself (gated on the section's own CHUNKED_MODE skip-check) rather than in plan-phase.md, so a non-chunked run pays no extra gsd_run calls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(#3777): repoint extraction at chunked-planning-mode.md after the move CHUNKED_PARALLEL resolution moved out of plan-phase.md into chunked-planning-mode.md itself (see the preceding commit); update the test's extraction path and header comment to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3777): relocate the canonical runtime-launcher preamble before its first use The CHUNKED_PARALLEL resolution block's two gsd_run calls landed earlier in the file than the sole existing preamble (in the commit step), which tests/runtime-launcher-parity.test.cjs's (B) check requires to precede every gsd_run call in the file. Move the preamble (not duplicate it) to the top of the resolution block; the commit step's fenced block now just calls gsd_run directly. Caught by the GREEN checkpoint gsd-test run before push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3777): strip the canonical preamble from the extracted resolution block The CHUNKED_PARALLEL resolution fence now carries the relocated runtime-launcher preamble as its first line (previous commit). Extracting the whole fence and running it after the test's own gsd_run stub let the embedded preamble's own resolver logic `unset -f gsd_run` and exit 1 before reaching the resolution logic, since no real gsd-tools.cjs exists in the temp script dir — every test calling runChunkedParallelResolution() failed. Strip the preamble (sourced from gsd-core/workflows/_runtime-launcher.snippet.sh, the same file scripts/sync-runtime-launcher.cjs treats as canonical) before splicing in the stub, so this suite tests only the resolution logic it is actually about. Caught by the post-rebase gsd-test run before push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3777): add the How-To page the phase gate requires Enablement is 2 commands (config-set, then --chunked), which this repo's own doc-quadrant gate flags as how-to-owed: a reference table cannot carry a sequence. Covers enablement, the dispatch-capacity gate's honest "most runtimes today: no effect" case, and the two accepted trade-offs. An earlier reasoning pass (recorded in .gsd/phase/.../70-docs.json before this commit) had incorrectly claimed #3034 shipped with no equivalent how-to page, as precedent for skipping one here. That claim was false — docs/how-to/enable-parallel-reviewer-lanes.md exists and is indexed. The phase gate caught the omission before merge; corrected here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3777): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
1db726ebbf |
feat(#3806): canonize the Review Dispositions Ledger contract (#4345)
* test(#3806): add parity tests for the Review Dispositions Ledger contract Failing-first: asserts references/planner-reviews.md, workflows/plan-phase.md, and agents/gsd-plan-checker.md agree on a single canonical "Review Dispositions Ledger" heading, its round-scoping, L##@{sha} anchor format, and append-only supersession rule. These fail until the canon and its two references are added. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(#3806): canonize the Review Dispositions Ledger contract Promote the existing planner-reviews.md Step 4 return-payload tables (Review Feedback Addressed/Deferred) into a canonical `## Review Dispositions Ledger` PLAN.md section, stated once in planner-reviews.md and referenced (not restated) from plan-phase.md's <review_incorporation_contract> and gsd-plan-checker.md's Review Incorporation dimension. Adds round-scoping (`### Round {N} — {REVIEWS_sha}`), a `L##@{sha}` line-anchor format so a REVIEWS.md reference survives the file being rewritten each round, and an append-only supersession rule. Scoped to part 1 only per the maintainer's approved-feature verdict — the deterministic lint/check verb (part 2) is explicitly deferred to a follow-up. Also: ADR-3806 recording the decision, a docs/features/ fragment (FEATURES.md is generated), and a changeset fragment. Closes #3806 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3806): fenced-example count bug and lint findings from review - tests/plan-review-convergence.test.cjs: the "heading exactly once" test counted the canonical heading text globally, so it also matched the illustrative fenced-code example in planner-reviews.md that shows the same heading as sample content, always failing 2 !== 1. Rewritten as a bounded line scanner that skips fenced blocks (found by an isolated adversarial review pass). Also bounded an unbounded regex quantifier over readFileSync content flagged by local/no-unbounded-quantifier. - docs/features/review-dispositions-ledger.md: match house fragment style (bold-lead paragraphs, not #### headings) per the Standards-axis review; regenerated docs/FEATURES.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3806): fit reference-cite fix within size hard caps; ack growth Trims the plan-phase.md / gsd-plan-checker.md reference-cite text to a single short clause pointing at gsd-core/references/planner-reviews.md (also fixes the bare `references/planner-reviews.md` cite the #3576 shipped-reference-cites gate rejects), bringing both files back under their SIZE hard caps and the plan-phase.md phase6 shrink-only baseline. Both files still grow slightly versus origin/next, acknowledged below per ADR-2719's emitted-drift-ack contract. Emitted-Drift-Ack-Growth: gsd-plan-checker.md — adds a short pointer (in the existing Review Incorporation bullet) to the canonical Review Dispositions Ledger location (#3806); stays within the LARGE hard cap. Emitted-Drift-Ack-Growth: plan-phase.md — adds a short pointer (in the existing review_incorporation_contract bullet) to the canonical Review Dispositions Ledger location (#3806); stays under the XL hard cap and the phase6 shrink-only baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3806): correct malformed Emitted-Drift-Ack-Growth trailer block The previous commit's two Emitted-Drift-Ack-Growth trailers were separated from the Co-Authored-By trailer by a blank line, so git's own trailer parser (which tests/helpers/emitted-runtime.cjs reads via `%(trailers:key=...)`) only recognized the last contiguous block (Co-Authored-By) and treated the Ack-Growth lines as ordinary body text — invisible to the emitted-attribution gate, not malformed data. Restating them here as one contiguous trailer block, git log over the PR range aggregates trailers from every commit, so this is additive. Emitted-Drift-Ack-Growth: gsd-plan-checker.md — adds a short pointer (in the existing Review Incorporation bullet) to the canonical Review Dispositions Ledger location (#3806); stays within the LARGE hard cap. Emitted-Drift-Ack-Growth: plan-phase.md — adds a short pointer (in the existing review_incorporation_contract bullet) to the canonical Review Dispositions Ledger location (#3806); stays under the XL hard cap and the phase6 shrink-only baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3806): isolate the ack-trailer paragraph as its own trailer block Git's trailer parser requires the trailer paragraph to be the message's final paragraph, preceded by a blank line, and to contain nothing but trailer-shaped lines. The prior commit's blank line before the trailer lines was missing, which folded the leading Emitted-Drift-Ack-Growth lines into an ordinary prose paragraph. Emitted-Drift-Ack-Growth: gsd-plan-checker.md — adds a short pointer (in the existing Review Incorporation bullet) to the canonical Review Dispositions Ledger location (#3806); stays within the LARGE hard cap. Emitted-Drift-Ack-Growth: plan-phase.md — adds a short pointer (in the existing review_incorporation_contract bullet) to the canonical Review Dispositions Ledger location (#3806); stays under the XL hard cap and the phase6 shrink-only baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3806): backfill PR #4345 into changeset and ADR Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
ea91268d02 |
ci(#4335): shard release.yml rc/finalize unit-suite tests (#4338)
* ci(#4335): shard release.yml rc/finalize unit-suite tests The finalize job's unsharded unit-coverage step outgrew the 30-minute job timeout that was already raised once for this exact symptom (#2280): run 33988966357 finished all tests with 0 failures at 28m26s, then got cancelled ~80s into the post-test coverage merge — a phase that historically completes in 54-101s. The suite's wall-clock time, not a hang, ate the budget. test.yml already fixed the identical cliff for its own full-scope lane (#2952, #3057) by sharding the unit suite 3 ways with a separate merged coverage-gate job. Apply the same pattern to rc and finalize (rc has the byte-identical unsharded shape and would hit the same wall next): each gains a `*-test` matrix job (raw coverage only, no report/gate) and a `*-coverage-gate` job that merges the shards' raw V8 dumps before enforcing the existing gsd-core/bin/lib coverage floor. rc/finalize now depend on their gate job instead of running the suite inline. Updates release-coverage-scope.test.cjs's exact-count assertion for the new command surface and adds release-shard-lane-sharding.test.cjs to pin shard-set completeness and gate wiring, mirroring ci-full-lane-sharding.test.cjs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Potential fix for pull request finding 'CodeQL / Cache Poisoning via execution of untrusted code' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Cache Poisoning via execution of untrusted code' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(#4335): close CodeQL cache-poisoning and missing-permissions findings CodeQL flagged the PR (10 actions/cache-poisoning/poisonable-step errors, 4 actions/missing-workflow-permissions warnings) on release.yml. Remove `cache: 'npm'` from every actions/setup-node step in the file (7 occurrences, not just the 4 newly-added jobs the alerts pointed at) — restoring an npm cache before running install/build code in a write-permissioned job is exactly the shape this query targets, and the same pattern was already present unchanged in create/rc/finalize. These are short CI/release jobs; losing npm's install cache costs a few seconds per job, closing the finding everywhere it appears in this file rather than only where the alert happened to land on a changed line. Add explicit `permissions: contents: read` to rc-test, rc-coverage-gate, finalize-test, finalize-coverage-gate — the four new jobs had no permissions block at all and inherited the ambient default. Matches validate-version's existing least-privilege pattern; create/rc/finalize keep their own broader write/publish scopes unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> |
||
|
|
c20675cc4d |
fix(#3819): widen executor's pre-commit guard beyond worktree mode (#4343)
* fix(#3819): widen executor's pre-commit guard beyond worktree mode The pre-commit protected-branch assertion in the executor agent (#2924) only fired inside a Claude Code worktree and matched a hardcoded five-name branch list. It never ran in an ordinary checkout and never covered this repo's own default branch ("next"), so gsd-executor could commit planning-repo documents directly onto a shared checkout's default branch with no PR ever created. Widen the guard to run in every isolation mode, and resolve the protected branch via the repository's actual default branch (with the existing five-name list retained as a fallback when the resolver itself cannot be invoked) plus any configured git.protected_branches. Add a git.allow_default_branch_commits escape hatch for projects that intentionally execute on their default branch. Also point the separate <final_commit> commit helper back at the same guard, so it cannot be sidestepped by that path. Emitted-Drift-Ack-Growth: gsd-executor.md — widened pre-commit protected-branch guard (#3819); tightened comments to stay under the size cap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3819): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
4e1c449281 |
enh(#3811): add hooks.commit_types config surface to gsd-validate-commit (#4340)
* enh(#3811): add hooks.commit_types config surface to gsd-validate-commit Extends the opt-in Conventional Commits hook with a hooks.commit_types config array that adds project-specific types to the 10 built-ins without replacing them. Configured values pass a safe-token filter before reaching the compiled regex, so a config entry can never alter the pattern's structure. The regex alternation, the human-readable error text, and a new typed valid_types JSON field all derive from one list instead of the two hand-synced copies this replaces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#3811): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
7533cb4454 |
fix(#4104): park the prohibition-enforcement hang fixture on a settling timer (#4331)
* test(#4104): add self-exit regression for the hang fixture (RED at this sha) * fix(#4104): park the hang fixture on a settling timer instead of a busy loop The prohibition-enforcement hang fixture busy-looped `while (true) {}`, so a worker orphaned by a killed runner burned a core forever. It now parks on a 10s settling setTimeout — still hung for any enforcement bound, ~0% CPU if leaked, and guaranteed to self-terminate. A regression test spawns the exact served body and asserts it self-exits with no signal. * test(#4104): harden the self-exit regression (signal/spawn-error paths) * changeset(#4104) * changeset(#4104): backfill PR number --------- Co-authored-by: sim <sim@local> |
||
|
|
2f920c5af3 |
fix(#4097): evidence preservation no longer sweeps the run's own input copies into .review-diagnostics/ (#4329)
* test(#4097): failing-first regression — run input copies swept into .review-diagnostics The present_results preserve+cleanup glob `_DIAG_MD=( "$RUN_DIR"/gsd-review-*.md )` matches both lane outputs and the run's own assembled input copies (prompt, instructions, roadmap, per-plan copies, project/context/research/requirements, per-lane trimmed prompts) because both share the gsd-review- prefix. Seed the full input-copy set into the existing runWriteReviewsFlow fixture and assert (a) the diagnostics dir holds exactly the lane report + .err sidecar and no input basenames, and (b) an inputs-only run creates no diagnostics dir at all and still cleans up. Both RED against the shipped block. * fix(#4097): preserve lane output only — exclude the run's input copies from evidence The preserve+cleanup glob treated every gsd-review-*.md in RUN_DIR as lane evidence, but the workflow itself writes the run's assembled INPUTS there under the same prefix (prompt, instructions, roadmap, per-plan copies, project/ context/research/requirements, per-lane trimmed prompts). Filter _DIAG_MD by basename against that closed input set instead. Direct glob iteration + case filter — identical under bash and zsh (#4099/ #4109), nullglob-safe (#2962), and the exclusion list is closed and owned in this step: a future input basename cannot silently rejoin the evidence set. Lane reports, diagnostic stubs and non-empty .err sidecars are unchanged. Emitted-Drift-Ack-Growth: review.md — #4097 narrows the present_results evidence-preservation glob: the closed input-basename exclusion list (case filter) plus its rationale note are deliberate additions so a future input basename cannot silently rejoin the evidence set. * changeset(#4097): fixed — review diagnostics no longer sweep run input copies * changeset(#4097): backfill PR number 4329 --------- Co-authored-by: sim <sim@local> |
||
|
|
4c60879b5d |
fix(#4132): verify durable runtime surface sources (#4182)
* fix(#4132): verify durable runtime surface sources * chore(#4132): record PR number in changeset * test(#4132): cover rejected commands source alias * fix(#4132): reject aliased package fallback * test(#4132): cover rejected agents source alias * test(#4132): cover partially aliased marker provider * fix(#4132): reject partially aliased source providers * test(#4132): cover routed source identity probes * fix(#4132): route installed source identity probes * refactor(#4132): tighten installer source metadata * test(#4132): cover corpus trust boundary attacks * fix(#4132): close installed corpus trust gaps * refactor(#4132): keep installer authority private * fix(#4132): preserve private installer fallback * test(#4132): preserve fixture source authority * fix(#4132): reject overlapping source fallback * fix(#4132): avoid redundant installed corpus reads * refactor(#4132): simplify provider resolution * test(#4132): sync install tree fixtures after rebase --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
1017898cb9 |
fix(#3771): make remediation examples non-binding and surface revision conflicts (#3916)
* fix(#3771): separate the binding property from the advisory remediation
Checker findings fused "what property failed" with "how to fix it" into a
single `fix_hint` and never marked which half binds. The checker rendered
every hint under a "must fix" heading, the orchestrators injected the issues
verbatim and ordered targeted updates, and the shared revision references
mapped each hint to a prescriptive strategy — so a contract-following planner
applied a hint literally even when a smaller mechanism satisfied the same
property, or when the hint contradicted a locked decision. There was no
channel to report that conflict, and every attempt burned a revision
iteration.
Checker side: every issue now carries a binding `required_property` (the
invariant that failed) plus its evidence and severity, and `fix_hint` is
labelled non-binding wherever it appears — including the human-facing blocker
rendering, so "must fix" unambiguously names the property and never the
example.
Planner side: revision re-checks locked decisions, capability guidance and
existing plan constraints before editing; satisfying a blocker through a
smaller valid alternative counts as addressing it; and a hint that conflicts
with any of those returns `REVISION_CONFLICT` carrying the conflict and the
alternatives considered. Orchestrators route that to user choice or the
configured plan-review convergence loop without consuming retry budget.
Also applied to the UI-spec revision loop and the gap-plan hint, and the
generic pattern's stray `suggested_fix` field name is reconciled to the
plan-checker's `fix_hint`.
Nothing legitimately binding is weakened: blockers still block, severity
still gates, iteration caps and stall escalation still fire, and required
task fields and decision coverage still hold.
Refs #3771
* test(#3771): pin the binding/advisory split across the revision chain
Locks the separation at every link that carries it: the checker's issue
schema and blocker rendering, the planner's constraint re-check and
REVISION_CONFLICT return, the generic pattern's reconciled field names, and
each orchestrator's conflict routing without retry-budget consumption. Also
pins what must not have been weakened — blockers, severity gating, iteration
caps and stall escalation.
Red against the pre-fix prose: 32 of 34 assertions fail (the 2 that pass are
the preservation checks, correctly).
Refs #3771
* chore(#3771): add changeset fragment for the remediation-binding fix
* chore(#3771): acknowledge the remediation-binding growth
Five runtime-loaded files grow: the two checkers carry the binding/advisory
split where the model reads it (a `required_property` on every dimension
example, since a schema the examples contradict teaches the examples), and
the three orchestrators carry the REVISION_CONFLICT route, which has to live
with the `iteration_count`/`revision_count` state it declines to spend.
Deletes tests/emitted-drift-acks/3172-stated-failing-direction.json: it is
fully spent on next and still owned plan-phase.md, so it walls off a key it
can no longer clear (#3078). Its removal is the documented remedy for the
duplicate-key collision, not drive-by cleanup.
* fix(#3771): close the review gaps in the conflict contract
Adversarial review (Codex, Antigravity) found four real defects in the first
pass, each confirmed against the source before acting:
- The UI checker's structured return still ordered `Fix: {exact fix required}`
and "list each BLOCK dimension with exact fix required". The dimension
examples had been marked non-binding but the rendering the researcher
actually reads had not — the same omission this issue is about.
- `ui-phase` and the canonical `revision-loop` flow incremented their counter
BEFORE dispatching the reviser, so "do NOT increment on REVISION_CONFLICT"
was unreachable prose: the iteration was already spent. The increment now
sits on the return path in both.
- The conflict gate offered "accept as-is", which is an early exit from a
still-failing blocker — a weakening the brief explicitly forbids. The three
options are now adopt an alternative / override the constraint / amend the
constraint; every one resolves the conflict. Accepting an unaddressed blocker
remains available only at the unchanged iteration-cap escalation.
- The convergence route was declarative: nothing in
plan-review-convergence.md could receive a conflict. plan-phase now records
it in REVIEWS.md — the channel that loop already consumes — convergence
refuses to declare convergence over an open entry, and routing back into a
run convergence itself started is explicitly excluded as a cycle. `quick` has
no REVIEWS.md and no phase, so its convergence branch was dead prose and is
deleted in favour of asking the user.
Also reconciles the last two drifted field names (`finding`, `affected_field`)
to the plan-checker schema, and repairs a silent no-op: the few-shot
`required_property` insertion never applied because those lines are
blockquoted, and the test's own block filter was anchored on indentation only,
so a vacuous loop passed over zero blocks. Both are fixed and the filter now
asserts it found blocks.
Refs #3771
* chore(#3771): extend the growth acknowledgment for the review round
plan-review-convergence.md joins the list: the conflict route needed a
receiving end, and it lands on the seam that loop already reads (REVIEWS.md)
rather than a new mechanism. The plan-phase, ui-phase and gsd-ui-checker
entries gain the second-pass reasoning — an executable convergence branch, the
increment moved onto the return path, and the structured return that still
ordered an exact fix.
* fix(#3771): make the conflict route bounded, ordered, and owned
Round-2 adversarial review found five more defects, each confirmed in the
source before acting:
- The convergence gate sat AFTER `gsd_run state planned-phase` and the success
banner, so a run could write and announce convergence over an unresolved
conflict. OPEN_CONFLICTS is now read from REVIEWS.md and is part of the
converged CONDITION, evaluated before any write.
- plan-phase's cycle-exclusion ("unless this run was invoked by convergence")
was not a question the orchestrator can answer at runtime. plan-phase now
never invokes convergence at all — it records the conflict when a phase
REVIEWS.md exists and resolves it with the user in-place, which removes the
cycle instead of describing it.
- Closure had no owner. plan-phase writes the row, so plan-phase strikes it
resolved; convergence only reads. An open row is a live blocker, never a
stale artifact.
- Declining to increment the counter removed the only bound on the conflict
path: an agent returning the same conflict forever would loop unattended. A
conflict naming the same `required_property` twice in a row is now a stall
and escalates through the existing gate.
- verify-work's gap-plan revision loop hands `<revision_context>` to
gsd-planner and so inherits the whole contract, but stated none of it and
could not handle the conflict return. It is now covered like the others, and
is in the test's orchestrator table.
Refs #3771
* chore(#3771): acknowledge the round-2 growth
verify-work.md joins the list — the flow the second review found missed — and
the plan-phase, ui-phase and plan-review-convergence entries gain the
round-2 reasoning: the gate moved ahead of the state write, the convergence
hand-off replaced with a runtime-checkable record-and-resolve, and the
recurrence bound that replaces the counter the conflict path stopped spending.
* fix(#3771): make the convergence gate countable and stop the conflict fall-through
Third adversarial round (Antigravity) found three defects:
- The OPEN_CONFLICTS pipeline had no `grep -v '~~'` despite its own comment
claiming one, and `grep -c '^| '` also counts a markdown table's header and
separator rows — every resolved conflict would have read as open and
convergence would have deadlocked instead of converging. plan-phase now
records each conflict as a `- [ ]` checklist line and flips it to `- [x]`, so
the gate is an exact fixed-string match with no table parsing.
- "then continue below" fell through to the checker spawn, so a SECOND
REVISION_CONFLICT would have been handed to the checker as though it were a
revised plan. plan-phase, quick and verify-work now re-evaluate the return
from the top of the conflict handler; ui-phase already looped back.
- revision-loop.md still described plan-phase routing a conflict to the
convergence loop instead of asking — the behaviour round 2 removed. Recording
is now stated as being in addition to asking, never instead of it.
Refs #3771
* chore(#3771): bring the changeset in line with what shipped
Two review rounds widened the change after the fragment was written:
verify-work's gap-plan revision and the convergence loop are covered, two
more drifted field names are reconciled, and the conflict path carries an
explicit recurrence bound.
* fix(#3771): declare and emit the REVISION_CONFLICT marker
check:contract-drift on CI caught what local lint never reached: four
workflows dispatch on `## REVISION_CONFLICT`, but no agent declared or emitted
it — an orphan consumer, matching a marker nothing produces. The shared
reference (planner-revision.md Step 7b) described the return; the agent
definitions did not carry it.
gsd-planner and gsd-ui-researcher now emit the marker in-fence alongside their
other return markers, and both registry rows in agent-contracts.md declare it.
gsd-planner's Consumed by gains the two workflows that dispatch on it and were
missing from the row.
The gate is right: a return contract belongs where the agent is defined, not
only in a reference the agent happens to load.
Refs #3771
* chore(#3771): acknowledge the return-marker growth
gsd-planner.md and gsd-ui-researcher.md each gain the REVISION_CONFLICT
marker that check:contract-drift requires them to emit.
* fix(#3771): hoist the shared conflict protocol out of the workflows
Two CI failures, both correct gates:
- tests/few-shot-calibration.test.cjs pins the plan-checker calibration file
at exactly 4 examples (2 positive, 2 negative). The example added in the
first pass broke that balance — and described PLANNER behaviour in the
CHECKER's calibration set, which is the wrong surface for it. Removed; the
smaller-alternative rule is already normative in gsd-plan-checker.md and
planner-revision.md, and pinned by the regression suite.
- tests/phase6-capstone-conformance.test.cjs (ADR-857 phase 6, #1168) requires
plan-phase.md to stay BELOW its pre-phase-6 baseline of 94519 bytes. The
inline conflict block pushed it to 94988.
The fix for the second is the one that should have been made first: the
record/resolve/close protocol and the recurrence bound were identical in four
workflows, and revision-loop.md — which plan-phase already @-imports — is what
a shared contract is for. The protocol now lives there once; plan-phase states
only its bindings (which counter, which artifact, which next step) and points
at it. plan-phase.md: 94988 -> 92739, under the ratchet with headroom, and the
four-way duplication is gone.
quick, ui-phase and verify-work do not import the reference, so they keep their
inline statements. The suite asserts each rule against what the runtime
actually loads for that orchestrator, not against the file in isolation.
Refs #3771
* docs(#3771): state the shared-protocol relationship accurately
Three of the four revision-bearing workflows do not @-import revision-loop.md,
so 'follows it verbatim' overstated the coupling. Only plan-phase defers; the
others restate the rules inline and this section is the authority they must
agree with.
* refactor(#3771): name the authority instead of restating it four times
Self-review finding: the same ~700-byte reviser paragraph was inlined in four
prompts while gsd-planner already loads planner-revision.md whenever
<revision_context> is present (agents/gsd-planner.md:555, :588) — a fifth copy
of the same contract. Four places to edit in lockstep is precisely the drift
class this PR exists to fix; the generic pattern calling the field
suggested_fix while the checker emitted fix_hint is what that looks like after
a year.
Each prompt now carries only the load-bearing clauses and names the authority
it summarises. Deliberately NOT reduced to a bare pointer: these are LLM
prompts, and a contract stated only in a file the reader is supposed to fetch
is the failure mode of this very bug. The saving is modest (~50 bytes each) —
the point is the named source of truth, not the bytes.
Suite: dropped seven assertions that pinned heading text and bold-lead
phrasing a reword would break without changing what the runtime is told; their
neighbours already pin the same contract by content. 519 -> 499 lines, 61
tests. Red gate against origin/next: 56 of 61 fail.
Refs #3771
* fix(#3771): sanitize agent-authored conflict text and bound total conflicts
Cross-AI review (agy/Gemini) raised two security findings. I confirmed the
first by running the gate's own awk against a crafted file and reproduced it
exactly; the second is a real hole in my bound.
1. FAIL-OPEN in the convergence gate. The conflict text is agent-authored and
is appended to REVIEWS.md, which the gate scans with an awk that stops at
the next '## ' heading. One line of agent text beginning '## ' ends that
scan early, so conflicts below it are never counted and convergence declares
success over a live blocker. Measured: 3 open conflicts, awk returned 2.
Fixed at the write boundary, which is the trust boundary: every field has
newlines and tabs collapsed to spaces and a leading '#', '-', '|' or fence
stripped, so one conflict is exactly one line. Both producing agents now
declare their fields single-line plain text, and the reader states the
invariant it depends on so a later edit cannot silently break it. Verified:
3 open + 1 resolved now counts 3; missing file and absent section count 0.
2. The recurrence bound was 'same required_property twice in a row', which an
agent alternating property names never trips, leaving the un-incremented
conflict path unbounded. Now bounded twice: the repeat rule catches the
common case, and the THIRD conflict return of a loop escalates whatever
property it names. A conflict still never consumes a revision iteration;
this cap is separate from and additional to the revision cap.
Rejected from the same review: deleting 'a planner that reaches
required_property by a smaller or different mechanism has addressed the issue
in full' from the CHECKER prompt as misplaced. It is load-bearing exactly
there. A checker that does not know a different mechanism counts will re-flag
the issue on re-check, which is the revision loop that never terminates. The
argument offered for deleting it, that the checker evaluates the new state
independently, describes the failure mode.
Refs #3771
* fix(#3771): fail closed on an unverifiable convergence gate
Second cross-AI pass (agy, this time with the full files rather than the diff)
found two more, both real:
1. The gate read REVIEWS_FILE with `2>/dev/null || echo 0`, so an unreadable or
empty path counted as ZERO open conflicts and converged. That path is
resolved a few lines earlier by a pre-existing unquoted
`ls ${phase_dir}/${padded_phase}-REVIEWS.md` (line 346, not touched by this
PR), which yields an empty string rather than an error when the path
contains a space. Unverifiable is not clean: the gate now tests -z and -r
first and BLOCKS. Verified both branches.
The unquoted ls itself is left alone deliberately — it predates this change
and belongs to the reviews lookup, not the conflict gate. Fixing it at my
own boundary removes its effect on this gate without widening scope.
2. REVIEWS.md is writable by the review agent, which could flip a `- [ ]` to
`- [x]` or delete the section and forge the state of a blocking gate. The
section now declares a single writer: /gsd:plan-phase appends and closes,
every other agent leaves it byte-for-byte alone, readers read.
Also trimmed a clause that explained the increment ordering by reference to
what the file said before this PR. Commit history is not instruction, and
these files are prompts.
Rejected: the claim that quick's conflict gate deadlocks autonomous pipelines
by asking the user. Its existing max-iteration escalation in the same file
already asks the user the same way; this adds no new interaction class.
Noted but out of scope: the per-dimension YAML example blocks and the shim
boilerplate duplicated across agent prompts both predate this change.
Refs #3771
* fix(#3771): count conflicts by line shape, not by section
CodeRabbit review on the rehearsal PR. Five findings, all valid, all applied.
The best one is a deletion. The convergence gate scanned between
'## Plan-Revision Conflicts' and the next '## ' heading, and that scan stops at
the FIRST heading it meets — so one stray '## ' line hid every conflict beneath
it and returned 0, converging over a live blocker. Reproduced: section-scan 0,
shape-scan 1. Sanitizing at the write boundary does not cover a hand-edited,
legacy, or foreign-written REVIEWS.md, so the reader needed its own guarantee.
It now matches the conflict line SHAPE anywhere in the file:
grep -c '^- \[ \] .*required_property:'
No section bookkeeping, nothing a heading can truncate, and it composes with the
writer's sanitization (which strips a leading '-' from agent text, so agent prose
cannot forge the shape). Verified: injected heading -> 1, all resolved -> 0.
The other four:
- Both checkers told the author never to emit a contradictory fix_hint, then
offered an escape hatch that put the forbidden route in the hint anyway. They
now name NO route in that case and state only that the property conflicts with
the constraint. A hint carrying a forbidden route is applied by anyone who
trusts hints.
- The REVISION_CONFLICT marker description in gsd-planner.md was narrower than
planner-revision.md: it covered a contradictory hint but not an unreachable
required_property. A planner reading only the agent file would have burned
retry budget on the case the reference routes to a conflict.
- The few-shot calibration examples used uppercase BLOCKER/INFO while the schema
defines blocker/warning/info. Pre-existing, but it is the same schema-vs-example
disagreement this PR exists to end, and the file was already being edited.
- verify-work's re-entry instruction existed but sat after the Bounded clause, so
the paragraph read "re-spawn ... stop re-spawning ... after re-spawning". The
re-entry now immediately follows the re-spawn, and states that only a
non-conflict return may reach the checker or increment iteration_count.
Refs #3771
* fix(#3771): resolve the contradictory scope_sanity severity examples
Sixth CodeRabbit finding, posted outside the diff range and missed on my first
read — I had claimed all findings were addressed after reading only the five
inline comments. This one was in the review body.
agents/gsd-plan-checker.md carried TWO scope_sanity examples with identical
metrics (5 tasks, 12 files) and OPPOSITE severities: warning in Dimension 5,
blocker in <examples>. Line 872 states "2-3 tasks/plan good, 4 warning, 5+
blocker" and the severity table lists warning as "Scope 4 tasks (borderline)",
so the warning example contradicted both.
ADR-2629 Decision 5's "over budget is a WARNING, never a blocker" does not
excuse it: that rule governs the smart-zone TOKEN estimate (the estimate-check
verb, lines 299-306), which is a different axis from task count. Verified in
source before touching it.
The contradiction is pre-existing but this PR made it binding and visible:
severity is now declared part of the binding payload, and both examples were
given the same required_property, so they now disagree on the severity of an
identical finding about an identical property.
Deviating from the proposed correction, which was warning -> blocker: that
would duplicate the <examples> entry outright (same tasks, files, severity).
The Dimension 5 example is instead made a genuine 4-task borderline warning, so
the file keeps one worked example per severity and the thresholds, the severity
table and both examples finally agree.
Refs #3771
* fix(#3771): stop laundering a grep error into zero open conflicts
Seventh CodeRabbit finding — from a SECOND review round my own CR-4 push
triggered, which I had not looked for. This one is a regression I introduced
while fixing the previous fail-open.
CR-4 replaced the truncatable section scan with:
OPEN_CONFLICTS=$(grep -c '^- \[ \] .*required_property:' "$REVIEWS_FILE" || true)
`|| true` masks every grep failure. grep exits 1 for "no matches" (a legitimate
zero) but 2 for a read error, and `|| true` turns both into an empty capture
that `${OPEN_CONFLICTS:-0}` renders as 0. If REVIEWS.md is removed or becomes
unreadable between the -r check and the scan, the gate reports no conflicts and
convergence proceeds. Proven: unreadable file -> captured empty -> 0.
The status is now inspected, and only exit 1 counts as zero; anything else
blocks.
My first attempt at this fix was itself wrong and my own harness caught it: I
wrote `if ! grep ...; then grep_status=$?`, but `!` inverts the status, so `$?`
in that branch is 0 and every failure reads as success — the clean-file case
printed "BLOCKED (grep exit 0)". The status must be read in the ELSE branch of a
non-negated `if`, which is what CodeRabbit proposed. Both traps are now pinned
by tests.
Verified end to end: all resolved -> 0, no conflicts at all -> 0, injected
heading -> 1, unreadable file -> BLOCKED with grep exit 2.
Refs #3771
* test(#3771): execute the conflict gate instead of reading it
CodeRabbit round three: 0 actionable, 1 nitpick — "these assertions inspect
Markdown source only; they do not prove that grep status 1 produces zero
conflicts or that a scan error exits before convergence." Rated Trivial. It is
the most valuable finding of the three rounds.
This gate has been wrong three times: a section scan a heading could truncate, a
`|| true` that laundered grep's error status into zero, and an `if !` whose `$?`
reported the negation rather than the command. Every one of those passed the
text assertions that existed at the time. I proved each fix by hand in a shell,
and none of that proof lived in the suite.
The gate is one self-contained fenced block, so the test now extracts it from
the workflow — located by content, not line number — writes it to a script and
RUNS it against fixtures: two open plus one resolved counts 2; no matches counts
0 and does not fail; a conflict below an injected `## ` heading still counts; an
unreadable path and an empty path both BLOCK with a non-zero status and no zero
count on stdout.
Non-vacuity proven by mutation rather than asserted. Reverting the gate to each
of its three historical broken forms reds the suite:
section-scan awk -> 7 failures (5 in the gate cases)
|| true -> 4 failures (3 in the gate cases)
if ! (negated $?) -> 4 failures (3 in the gate cases)
restored -> 69 pass, 0 fail
The prose assertions stay: they are the right instrument for a prompt. This
covers the one part of the change that is real shell an orchestrator executes.
Refs #3771
* test(#3771): route the gate harness through the shared test helpers
ESLint's project rules caught three violations in the new harness: an unbounded
execFileSync (DEFECT.UNBOUNDED-SUBPROCESS — an unbounded spawn is an indefinite
hang, and on macOS CI that is how a stuck run stops reporting instead of failing)
and two raw fs.rmSync calls, which skip the Windows-EBUSY retry budget that
helpers.cleanup carries.
Now uses createTempDir/cleanup from tests/helpers.cjs and passes an explicit
30s timeout. Suppressing the rules was available and would have been the wrong
call: both exist because of real CI failure modes on platforms I am not testing
on.
* chore(#3771): backfill the changeset PR number
The pr: field is drift-checked against the PR event payload, so it cannot be
written before the PR exists. Set to 3916.
* fix(#3771): close revision conflict persistence gaps
Use the authoritative review artifact, keep conflict and normal retry paths disjoint, and enforce one writer-reader grammar so malformed state fails closed.
Emitted-Drift-Ack-Growth: diagnose-issues.md — #3771 marks the gap-plan remediation hint non-binding while keeping root_cause authoritative
Emitted-Drift-Ack-Growth: gsd-plan-checker.md — #3771 separates binding required_property evidence from advisory fix_hint examples across the checker contract
Emitted-Drift-Ack-Growth: gsd-planner.md — #3771 declares the REVISION_CONFLICT return used when remediation contradicts governing constraints
Emitted-Drift-Ack-Growth: gsd-ui-checker.md — #3771 applies the same binding-property and advisory-hint split to UI review findings
Emitted-Drift-Ack-Growth: gsd-ui-researcher.md — #3771 defines the UI revision producer's structured REVISION_CONFLICT return
Emitted-Drift-Ack-Growth: plan-phase.md — #3771 routes and persists bounded revision conflicts before spending the normal retry budget
Emitted-Drift-Ack-Growth: plan-review-convergence.md — #3771 adds the fail-closed owned-block parser and prevents convergence over open conflicts
Emitted-Drift-Ack-Growth: review.md — #3771 emits and preserves the canonical writer-owned conflict block across review regeneration
Emitted-Drift-Ack-Growth: ui-phase.md — #3771 routes UI revision conflicts to resolution before consuming revision_count
Emitted-Drift-Ack-Growth: verify-work.md — #3771 gives gap-plan revision the same bounded conflict route before iteration_count
* test(#3916): guard rebases against schema drift
Load the current-base progressive-disclosure examples so every integrated issue remains bound by required_property after branch reconciliation.
* test(#3916): skip the extracted-gate suite's bash spawns on win32
Third review round's sole survivor: runConflictGate()/withReviews() spawn
bash against a Node-native temp path built by createTempDir(), which is
backslash-separated on the Windows CI lane and not a path Git Bash is
guaranteed to accept (DEFECT.WINDOWS-TEST-PORTABILITY, matching the
observed CI failure at revision-remediation-binding.test.cjs:844,
ENOENT on a path Windows read as a directory separator). No eslint rule
catches it since the call has neither a chmod nor a `bash -c` form.
Guards the four call sites with the repo's existing skipOnWin32
convention (describe/test `{ skip: IS_WINDOWS }`) rather than
normalizing the harness path to forward slashes, which would defeat the
one test whose purpose is proving the production gate does NOT rewrite
a literal backslash in a POSIX filename.
* fix(#3916): backfill changeset pr field to the fork validation PR number
* fix(#3771): forbid silently accepting an open plan-revision conflict at max-cycles escalation
The max-cycles escalation prompt only surfaced HIGH_COUNT and ACTIONABLE_COUNT; an open
plan-revision conflict (OPEN_CONFLICTS > 0) was never disclosed there, and "Proceed anyway"
could exit successfully over it — exactly the failure mode this PR exists to close (a success
banner over an unresolved conflict nobody resolved). Blockers still block: withhold "Proceed
anyway" and route to Manual review whenever a conflict is open.
* fix(#3771): do not hard-block REVISION_CONFLICT persistence when no REVIEWS.md exists yet
A phase's first-ever revision cycle can return REVISION_CONFLICT before any REVIEWS.md has been
written — REVIEWS_PATH is then legitimately empty, not a corrupt or deleted file. The persistence
gate's own accompanying prose already says the record channel applies 'when REVIEWS_FILE is
non-empty', but the bash condition never checked that, so it hard-blocked every conflict on a
brand-new phase regardless of whether persistence was even expected to run. Require a non-empty
REVIEWS_FILE before treating a missing file as an error.
* chore(#3771): raise the plan-phase.md ADR-857 host-loop ceiling to 96700
The frozen pre-phase-6 ceiling (94519) collided on rebase: this PR's own
REVISION_CONFLICT persistence/routing gate is core planner control flow, not an
un-extracted optional feature, and landed alongside an unrelated, already-merged
same-file growth (the #4.6 context-drift pre-check) already on next. Same
rationale #1298 already established for execute-phase.md's ceiling.
* chore(#3916): backfill changeset pr field to the upstream PR number
* fix(#3771): make the writer-side REVISION_CONFLICT sanitize step real shell
The Conflict Return record channel sanitized agent-authored fields via a
prose instruction ("Sanitize each agent-authored field before appending")
for the orchestrator LLM to apply by hand, while the reader-side gate in
plan-review-convergence.md parses the same slot with real, executed awk.
Flagged Minor across two review rounds (round 4, round 6) since no code
performed the sanitize anywhere.
plan-phase.md's Conflict Return step now runs a real bash gate: sanitize
each field (collapse newline/tab to space, strip a leading #/-/|/fence),
build the one-line record, skip the append if an identical line already
exists (idempotent), insert before the writer-owned end delimiter, and
fail closed if that delimiter is missing rather than silently dropping
the conflict.
tests/revision-remediation-binding.test.cjs extracts and RUNS the new
fence (matching how the reader gate is already tested), composing it
with the existing reader gate: hostile-field fast-check fuzzing, a
repeated-conflict idempotency check, and a missing-delimiter fail-closed
check that the file is left byte-for-byte unchanged on failure.
Emitted-Drift-Ack-Growth: plan-phase.md — #3916 turns the writer-side
REVISION_CONFLICT sanitize+insert step into real, executed shell instead
of a prose instruction, matching the reader gate's existing rigor
* fix(#3916): backfill changeset pr field to the fork validation PR number
Fork CI's changeset-lint reads the real PR number from its own event
payload; the fragment still carried the upstream number from the last
sync, so the DEFECT.CHANGESET-PR-FIELD-DRIFT check failed on this fork
PR. Re-backfill to the upstream number before the final push.
* fix(#3771): close the awk -v forgery and same-session close gaps agy found
Adversarial review (gemini-3.8-flash-high via the internal agy review
lane) on the full PR found two BLOCKERs against the just-added
writer-side conflict gate:
1. `awk -v line="${LINE}"` decodes escape sequences in its argument, so
a literal two-character `\n` in agent-authored text became a real
newline inside awk, splitting the appended record across two
physical lines. `tr` only strips actual control bytes, so it never
saw this — it defeated the exact forgery the gate exists to
prevent, both the reader's zero-count and the writer's own
idempotency check. Fixed by passing LINE/END through awk's
ENVIRON, which is not escape-decoded.
2. A conflict resolved and re-spawned within the same plan-phase
session was never flipped from `- [ ]` to `- [x]` — the record
channel bullet said "plan-phase closes it," but no step did. Only
a *separate* `--reviews` re-entry (line ~622, still prose-only)
closes conflicts; the in-session resolve path left them open
forever, permanently blocking convergence. Fixed by carrying the
just-written line in `PENDING_CONFLICT` and closing it in the
`Otherwise` branch before the checker re-spawns.
Also fixed a MAJOR: docs/COMMANDS.md described the `--max-cycles`
escalation gate as uniformly offering "proceed or review manually,"
but the code (this PR's own change) withholds "Proceed anyway"
specifically when a plan-revision conflict is open — only manual
review is offered in that case. Docs now say so.
Not applied: the reviewer's `\r` truncated to plain tr from a MINOR
that also asked for temp-file permission preservation across `mktemp`.
Applying `chmod --reference` is not portable to macOS/BSD `chmod`, so
this is left as a documented low-severity tradeoff — the temp file now
sits alongside REVIEWS.md (same filesystem, atomic `mv`), which was
the same finding's more substantive half. Also not applied: a
suggested `gsd_run review record-conflict` CLI subcommand to
deduplicate the two `awk` blocks — a new command plus wiring is out of
scope for a review-remediation fix.
tests/revision-remediation-binding.test.cjs adds regression coverage
for both BLOCKERs: a literal-backslash-n hostile field composed with
the reader gate, and a close-gate extraction that verifies the flip to
`[x]`, the reader's count dropping to 0, and a fail-closed path when
the pending line is missing.
Emitted-Drift-Ack-Growth: plan-phase.md — #3916 fixes an awk -v escape-
decoding forgery and adds the missing same-session conflict-close step
an adversarial review found in the writer-side gate
* chore(#3916): backfill changeset pr field to the upstream PR number
Fork-validation CI needed pr: 1 to pass its own changeset-lint; restore
pr: 3916 before this push reaches open-gsd/gsd-core.
* fix(#3771): trim plan-phase.md prose back under the XL byte cap
Merging origin/next's unrelated growth pushed plan-phase.md 473 bytes
past the workflow-size-budget XL cap and the ADR-857 phase-6 baseline,
both tripped by CI after review approval. Removed an unpinned inert
bash comment and tightened connective prose in three REVISION_CONFLICT
bullets; no executable shell or test-pinned substring changed.
* chore(rehearsal): pin changeset pr field to fork rehearsal PR #25
Scratch-only commit for the rehearsal branch's own CI. Will not be
carried onto the branch backing upstream #3916 — that keeps pr: 3916.
* fix(#3771): address CodeRabbit findings on the REVISION_CONFLICT protocol
Fork rehearsal PR #25's first CodeRabbit pass surfaced 7 findings against
the already-approved #3916 diff; each verified against current code
before fixing (none hallucinated):
- plan-phase.md: writer-side awk gates now strip a trailing \r before
comparing lines, matching the reader gate (plan-review-convergence.md)
-- a CRLF REVIEWS.md previously made both writer gates fail closed.
- plan-phase.md: the close-fence's REVIEWS_FILE/PENDING_CONFLICT/
CONFLICT_RESOLUTION were read without ever being (re)defined in that
fence -- shell state does not survive across separate fenced blocks
(same convention already documented in review.md). Added the explicit
recompute/set instruction.
- revision-loop.md: previous_conflict_property was never reset after a
normal (non-conflict) revision, so a later, unrelated conflict on the
same property could be misread as a repeat and escalate prematurely.
- gsd-plan-checker.md / few-shot-examples/plan-checker.md: two example
required_property strings were unconditionally binding in a way their
own dimension's rules aren't (no-analog RESEARCH.md fallback; tasks
that create no functions), now scoped to match.
- quick/steps/plan-checker-loop.md: added the same disjoint
"Otherwise (not REVISION_CONFLICT)" branch plan-phase.md already had,
closing an ambiguity between the conflict and non-conflict return paths.
- revision-remediation-binding.test.cjs: the REVIEWS_PATH init-order
assertion used indexOf() without checking for -1, so it would pass
vacuously if either anchor were renamed away.
Also restores an "Export the row's CONFLICT_*" instruction I had cut in
the prior byte-budget trim -- checked non-pinned by tests, but it was the
only text telling the agent to set those vars before the awk block reads
them via ENVIRON.
Net growth from these fixes required reclaiming bytes elsewhere in
plan-phase.md (verified against every pinned substring in
revision-remediation-binding.test.cjs) to stay under the XL tier's
hard 98304-byte cap; final size 98245 bytes.
* fix(#3771): resync the #4079 shrink-only mirror to the current PRE_PHASE6 line
tests/plan-phase-background-wait-wakeup.test.cjs (landed on next via an
unrelated #4079 PR, merged in by this branch's next-sync) mirrored
plan-phase.md's phase6 shrink-only ceiling as a hardcoded local constant
(94519) rather than reading tests/phase6-capstone-conformance.test.cjs's
PRE_PHASE6 value. That value has since been legitimately raised twice
during this PR's own review (94519 -> 96700 -> 98300) to accommodate the
REVISION_CONFLICT persistence/routing gate. The two branches' independent
histories left the mirror stale post-merge -- not a textual git conflict,
but the same class of thing. Resynced to 98300.
* fix(#3771): address round-2 CodeRabbit findings on the conflict gates
CodeRabbit's re-review of the previous remediation commit found two real
issues in what it had already flagged:
- Both writer-side awk CRLF fixes used \`sub(/\r$/, "")\` directly on \`\$0\`,
which mutates it in place -- \`{ print }\` then emitted the CR-stripped
copy for every passed-through line, silently rewriting an unrelated
CRLF REVIEWS.md to LF on any insert or close. Now compares against a
separate \`cur\` copy and prints the original, untouched \`\$0\`.
- The close-fence's "recompute REVIEWS_FILE/PENDING_CONFLICT" prose
implied in-fence derivation, but the fence has no such code and the
test harness (\`runCloseGate\`) deliberately supplies all three as
pre-set env vars -- matching how the open fence's "Export the row's
CONFLICT_*" instruction already works. Reworded to "export ... in the
same invocation", matching that established, test-verified pattern
instead of promising logic that isn't there.
Added a regression test proving the CRLF fix no longer touches
passthrough lines (red against the mutate-in-place version, green now).
* fix(#3771): use a CRLF-safe check in the new passthrough regression test
local/no-crlf-fragile-split forbids splitting readFileSync content on a
literal \n (Windows git-autocrlf checkouts yield \r\n). My CRLF
passthrough-preservation test from the previous commit did exactly that
to inspect the first line. Replaced with a direct startsWith() check
against the known CRLF-terminated header, which needs no split.
* test(#3771): assert the record itself is inserted in the CRLF passthrough test
CodeRabbit nitpick (round 3): the passthrough-preservation test checked
gate status and the pre-existing line's CRLF ending, but never asserted
the new REVISION_CONFLICT record was actually written.
* fix(#3771): address agy/gemini-3.8-flash-high adversarial review findings
Full-PR adversarial review (internal /gsd-review antigravity lane,
gemini-3.8-flash-high) surfaced 9 findings; each verified against current
code before fixing (none hallucinated):
HIGH:
- quick-batch/steps/plan-checker-loop.md never received the
required_property/fix_hint binding language or REVISION_CONFLICT
handling this PR added everywhere else -- a genuinely unmigrated
producing context. Migrated to match quick/steps/plan-checker-loop.md,
and added it to the ORCHESTRATORS consistency battery in
revision-remediation-binding.test.cjs so future drift is caught
automatically.
- The close-fence's PENDING_CONFLICT was an agent-supplied env var that
had to exactly reconstruct a five-field sanitized line across a
multi-minute subagent dispatch -- fragile, and a scalar var also meant
a second simultaneous conflict silently dropped the first on overwrite.
Redesigned to match the open conflict by CONFLICT_DIMENSION/
CONFLICT_PLAN identity instead: the agent re-supplies two short,
already-tracked identifiers rather than reconstructing the full
sanitized text, and each conflict resolves independently regardless of
how many are open. Updated the test harness's runCloseGate contract to
match, and added a two-open-conflicts regression test.
MEDIUM:
- plan-phase.md's `--reviews` replanning path told the reader to "flip
the matching line to [x]" in prose only, with no executable path to
it -- pointed it at the same close gate used in step 12.
- plan-review-convergence.md's reader-gate awk tolerated a blank line
before the opening delimiter but not before the heading that follows
it; a formatter or LLM writer inserting one would hard-abort
convergence on an otherwise well-formed REVIEWS.md. Added the same
tolerance already granted above it, with a regression test.
LOW:
- Clarified that the escalation destination for a stalled conflict is
the same iteration/revision-count cap gate already defined in each of
quick, quick-batch, ui-phase, and verify-work, rather than an
undefined "stall" concept.
- Clarified "twice in a row" means no successful revision intervened,
matching revision-loop.md's now-explicit previous_conflict_property
reset.
- Fixed gsd-ui-researcher.md's stale rationale text, copied verbatim
from planner-revision.md: ui-phase presents the conflict table
directly to the user, it does not persist to a shared file scanned by
heading.
Net growth again required reclaiming bytes in plan-phase.md (verified
against every pinned substring in revision-remediation-binding.test.cjs)
to stay under the XL tier's hard 98304-byte cap; removed a now-dead
PENDING_CONFLICT assignment in the process. Final size 98258 bytes.
* fix(#3771): scope row 48's quick/steps guard away from plan-checker-loop.md
tests/gsd-quick-batch-quick-regression.test.cjs's row 48 (#3676) flagged
this branch's quick-batch/steps/plan-checker-loop.md migration (the agy
HIGH finding) as a violation, because it also edits
quick/steps/plan-checker-loop.md for the same underlying #3771 protocol
fix.
Verified against git history before scoping:
|
||
|
|
26b8e9abad |
fix(#4306): forward real bytes through the #3912 A6 stderr-bytes mocks (#4328)
Same defect class as the bug #1008 fault-injection mocks: fabricated a return byte count without ever calling the real fs.writeSync, silently discarding any write to fd 2 landing during the mocked window instead of letting it reach the real pipe. Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
86b745b48b |
fix(#4270): forward Codex spawn model routing (#4281)
Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
7ff196c505 |
fix(#4096): honor --dry-run in todo complete and write completion keys inside the frontmatter fence (#4325)
* fix(#4096): honor --dry-run in todo complete and upsert completion keys inside the frontmatter fence * review(#4096): tighten todo complete flag rejection to any dash-prefixed token * chore(#4096): backfill PR number in changeset --------- Co-authored-by: sim <sim@local> |
||
|
|
3d03ae65e6 |
fix(#4094): withhold all four STATE.md progress counters under the milestone-unbounded guard (#4322)
* test(#4094): failing-first matrix for withholding all four progress counters * fix(#4094): withhold all four progress counters under the milestone-unbounded guard completed_phases/total_plans/completed_plans are accumulated from the same phaseDirs walk as total_phases, so the #3354/#3573 withhold condition makes them equally untrustworthy — yet only total_phases was withheld, and every resyncing state.* write silently clobbered the three stored siblings with the under-scoped disk numbers. Extend the withhold-then-fall-back-to-stored pattern to all three siblings: null sentinels in the disk-scan cache value, three new stored-counter readers threaded through all three buildStateFrontmatter call sites, and the same cached-else-stored consumer fallback. Milestone-bounded projects are untouched (gate-conditional). * fix(#4094): scope-requires for the new test block, keep the (#3573) warning token, and update two #3578 rows to the withheld-counter contract - the #4094 describe sat after the closing brace of the section that owned the module-level beforeEach destructure, so it needs its own local requires (mirroring the #3642 block); - the #3573 warning keeps its literal '(#3573)' tag (asserted by an existing test) with '#4094' appended as a separate token; - two #3578 status-guard rows in tests/state.test.cjs asserted the pre-#4094 unconditional disk-scan assignment of completed_phases under the roadmap-absent withhold — exactly the silent clobber #4094 removes; the status-guard conclusion (must not fire) is unchanged, the counter-value assertions now pin the withheld contract. * test(#4094): lint conformance — splitLines for the persisted-progress parser, local seeder, scoped rmSync disable * changeset(#4094) * changeset(#4094): backfill PR number --------- Co-authored-by: sim <sim@local> |
||
|
|
2e1ede6d99 |
fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline (#4318)
* test(#4093): regression matrix for advance-plan zero-labeled-fields decline * fix(#4093): give advance-plan's zero-labeled-fields failure a disk-derived recovery decline * refactor(#4093): collapse IIFE to a plain block (review finding) * docs(#4093): document the advance-plan recovery decline + changeset * chore(#4093): backfill PR number in changeset * fix(#4093): budget lint-compiled-artifact-sync's tsc compile as a compile, not a probe --------- Co-authored-by: sim <sim@local> |
||
|
|
d9d16551b7 |
fix(#4091): publish update-check cache via atomic temp+rename (#4313)
* test(#4091): RED — worker cache write must be rename-atomic Structural regression guard: gsd-check-update-worker.js must not write the shared per-package cache in place; assert temp+rename publish shape plus a behavioral no-residue e2e. * fix(#4091): publish update-check cache via atomic temp+rename writeFileSync on the shared per-package cache truncates before writing, so concurrent cross-runtime readers (statusline/banner) could see a torn or empty record. Stage under a unique same-directory temp and rename into place — POSIX rename(2) is atomic, so readers see the old or new record, never a partial one. Degrade policy (#3582) unchanged: errors swallowed, temp best-effort removed. * refactor(#4091): hoist temp path, tighten uniqueness assertion (review) * changeset(#4091): add fragment (pr backfill to follow) * changeset(#4091): backfill PR 4313 --------- Co-authored-by: sim <sim@local> |
||
|
|
b327331747 |
fix(#4086): resolve skills/ manifest keys at the runtime's actual skills root (#4311)
* fix(#4086): resolve skills/ manifest keys at the runtime's actual skills root Codex installs skills to ~/.agents/skills (skills-kind home override), but saveLocalPatches() and verify-reapply-patches.cjs resolved every manifest key config-dir-relative only — every skills/ key missed, so user modifications to Codex skills were never hash-compared, never backed up, and silently overwritten on update; the reapply verifier false-failed the same keys with fail_installed_missing. configDir stays first (non-override runtimes byte-identical); the skills root (same _resolveSkillsRootDir / skillsManifestPrefix seams the write side uses) is a containment-guarded fallback when the config-dir path is absent. * fix(#4086): drop unused test param; add changeset fragment * chore(#4086): backfill PR number in changeset fragment --------- Co-authored-by: agent-4086 <agent-4086@local> |
||
|
|
70f22e4643 |
fix(#4213): keep STATE.md progress surfaces synchronized (#4231)
* fix(#4213): keep STATE.md progress surfaces synchronized * fix(#4213): clamp the shared progress bar and keep bold-first priority, changeset + property tests - formatProgressMachineSegment clamps through clampPercentFromFraction (ADR-3180 Decision 7 kernel) with a 0 floor, so a hand-edited out-of-range persisted percent renders a clamped bar instead of throwing RangeError on repeat() inside the write seam - stateReplaceProgressPercent restores the #2177 bold-first priority: **Progress:** anywhere in the body wins; a plain ^Progress: line is the fallback, so free text starting with Progress: cannot capture the rewrite ahead of the real status line - cross-reference comment names the three consumers and the cmdStateSync sanctioned exception (ADR-3408 §8.3) - CONTEXT.md: applyPostSyncPreservation reconciliation documented in the STATE.md Transition Module entry - property tests (never-throws/well-formed, idempotency, round-trip, bold-first) + two regression rows through the CLI --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
dad16b6ef9 |
fix(#4141): ignore Stryker's sandbox in eslint's global ignores (#4178)
* fix(#4141): ignore Stryker's sandbox in eslint's global ignores `stryker.config.mjs` sets `tempDirName: '.stryker-tmp'`, `.gitignore` ignores it, and Stryker's own always-ignored list carries it. eslint's global `ignores` did not, and there is no `.eslintignore`. A mutation run that dies before cleanup — chunk timeout, CI cancellation, Ctrl+C, a crash — leaves a full sandbox copy of the tree at `.stryker-tmp/sandbox-*/`, and `npm run lint` then lints the copy. The failure is not the duplicate-file failure that would suggest, which is what makes it confusing: the `local` plugin is registered only in path-scoped config blocks (`tests/**/*.cjs`, `scripts/**/*.cjs`, `hooks/**`), and a copy at `.stryker-tmp/sandbox-*/tests/*.cjs` matches none of them, so every inline `// eslint-disable-next-line local/<rule>` the original carries becomes `Definition for rule 'local/<rule>' was not found` at the copy's path. The error text names rules rather than paths, so the first read is "my change broke the local plugin" — observed as 834 errors on a branch whose own diff was clean, from a sandbox eight days stale. CI never sees this (fresh checkout), so it is purely a local-contributor tax, but a loud and misleading one. The regression test asserts the invariant the way ESLint resolves it — `isPathIgnored()` over real flat-config precedence, not a textual scan of the ignores array — mirroring the #551 block it sits beside, and carries the control that the real `tests/worktree.test.cjs` at the mirrored path is still linted, so a pattern widened enough to swallow the tree cannot pass. Scoped to the reported bug: `reports/` may deserve the same treatment, but no lint failure has been reproduced from it, so it is not claimed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR * chore(#4141): backfill the changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR * chore(#4141): restore the file's 2-blank-line block separator Review nit: the #4141 block left three blank lines before the RETIRED block, where every other block boundary in this file uses two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mw7WhYnrWdY8cmj8NSJj4R --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
294ec29857 |
fix(#4053): quote decimal-shaped frontmatter scalars for spec YAML readers (#4165)
* fix(frontmatter): quote decimal-shaped scalars so a spec YAML reader preserves them A decimal phase identifier written to STATE.md frontmatter (e.g. `current_phase: 22.10`) was emitted BARE, because `scalarNeedsDoubleQuoting` only asks whether a value can OPEN a plain scalar — which `22.10` can. A YAML-spec reader (js-yaml, the statusline, any external tool) then reloads bare `22.10` as the float 22.1, colliding with `22.1` and dropping the trailing zero. gsd's own tolerant line-scanner (`extractFrontmatter`) round-trips the raw text and so hid the defect; a spec reader does not. Fix: `reconstructFrontmatter`'s general scalar path now also quotes numeric- looking strings that are not plain all-digit integers (decimals, exponents, sexagesimal, hex/oct/bin) via `generalScalarNeedsNumericQuoting`, reusing the existing `YAML_NUMERIC_RE`. Every all-digit string — integer counts, phase numbers, and leading-zero fixtures like `02` — stays bare, so the state-rebuild idempotency baseline and the rest of the state corpus are unchanged. This also quotes `gsd_state_version: 1.0` on write, which matches the authoritative STATE.md template (`src/state.cts` already emits it quoted). Regression test drives the real write path and asserts, via js-yaml, that `22.1` and `22.10` no longer collide and read back string-typed; guards that integers and free-text stay unquoted. Fixes #4053 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBicDMJyh3AH56ZFbUsyxC * chore(changeset): add Fixed fragment for #4053 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBicDMJyh3AH56ZFbUsyxC * docs(frontmatter): trim the generalScalarNeedsNumericQuoting comment Cut the over-long doc block down to the essential why and drop the inline comment that repeated it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZKeSj55VakqQajtoBgCTC * docs(test): drop the #4053 explanatory comments from the touched tests The assertions speak for themselves; remove the added narrative comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZKeSj55VakqQajtoBgCTC * fix(#4053): correct the trade-off comment, changeset PR number, and cover every claimed numeric form Review follow-ups (trek-e): - The doc comment claimed a plain integer round-trips harmlessly. That is false for leading-zero values (`02` -> 2, `017` -> 17 under js-yaml). Rewrite it to state the real, deliberate trade-off: all-digit strings stay bare because zero-padded ids (`plan: 01`, `phase: 02`) are the pervasive GSD convention and quoting them all is the blanket quoting #4053 asked to avoid; the loss is padding not identity (`02` and `2` normalize to the same phase, `22.1` and `22.10` do not). - Changeset carried the auto-closed draft's number (4151); correct to 4165. - Test exponent, hex, octal, binary and sexagesimal forms through js-yaml, and pin the leading-zero trade-off so the documented behaviour is asserted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qc7VN4zTpTSDTS9JXM2cFB --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
723ea08dc2 |
fix(#4016): imperative-override injection patterns tolerate filler words (#4061)
* fix(#4016): imperative-override patterns tolerate filler words The narrow imperative-override family tolerates no filler between the verb and the noun, so a planted "Forget all of your instructions" (measured in a real public transcript) matched none of the 14 patterns and both consuming hooks stayed silent. One combined filler-tolerant pattern is appended; the narrow four stay untouched to keep the change merge-friendly. Known trade-offs, disclosed in #4016: linter-doc prose like "ignore rules on a single line" now trips a LOW advisory, and the overlap with the narrow patterns means one sentence can count twice toward severity thresholds. Regression tests assert the previously-missed phrasings fire in BOTH consuming hooks (gsd-prompt-guard and gsd-read-injection-scanner), not just in the raw pattern list, per the agent brief in #4016. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aGr6fvmDMLznT7TvTXrsb * chore(#4016): changeset fragment for PR #4061 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015aGr6fvmDMLznT7TvTXrsb * test(#4016): pin the disclosed linter-doc FP as single-pattern LOW, never blocking Review follow-up on PR #4061: the combined filler-tolerant pattern's disclosed false-positive class (linter-doc prose such as "use eslint-disable-next-line to ignore rules on a single line") was documented in prose only. Two tests now pin it: - the prose matches exactly ONE shared pattern (the #4016 combined pattern, not a narrow one), so it cannot silently start double-counting toward the 3+ HIGH threshold; - through the real gsd-read-injection-scanner subprocess with security.injection_blocking=true, the prose yields a single-finding LOW advisory and no block decision — with an in-test positive control proving a 3+-pattern payload DOES block in the same directory, so the non-blocking assertion cannot pass vacuously. Samples are fragment-built like the existing SAMPLES rows so this file's own diff does not trip the CI injection scanner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#4016): replace the five narrow imperative-override patterns with one superset The first cut appended a filler-tolerant combined pattern next to the five narrow verb patterns. Both consumers count one finding per matching pattern toward the severity threshold, so the overlap made one sentence count twice: "Ignore previous instructions. Forget your instructions." scored 2 (LOW) on next and 3 (HIGH, blockable) on the branch. It also left `override` out of the combined pattern. Replace the narrow family (ignore x2, disregard, forget, override) with ONE superset pattern over ignore|disregard|forget|discard|override. At least one filler (all|of|the|your|my|system|previous|prior|above|earlier) must sit between verb and noun, enforced by a lookahead with no repetition; the two noun-less/bare forms the old list accepted (`disregard (all) previous`, `forget instructions`) are kept as explicit tails so the new pattern is a strict superset. Bare "override rules" / "ignore instructions" are ordinary repo prose (6 measured hits across docs and source) and stay unmatched. Corpus measurement over 3019 .md/.js/.cjs/.mjs files (injection-sample tests excluded): the old family hit 2 lines, the new pattern hits 3, the only new one being a documented injection example in planner-reversibility.md that the old family missed (the issue's own class). Tests: SAMPLES reshaped to the 10-entry list; superset proof table (17 legacy phrasings, each matching exactly one pattern); five issue phrasings including `override all of your previous instructions` counted exactly once through both hook subprocesses; double-count regression (1 finding, LOW); design pin that bare verb+noun matches nothing; linter-doc FP pin split into bare (silent) and determined (single LOW, never blocks). All fragment-built; the CI prompt-injection scanner reports 0 findings on every touched file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rvqL1wk6s8dQEXsFm4RB1 * chore(#4016): changeset body in the canonical bold-lead format .changeset/README.md Format: a leading bold change sentence, then an em-dash explanation. Also drops the verbatim planted phrase from the body so the rendered CHANGELOG line does not trip the pattern it describes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rvqL1wk6s8dQEXsFm4RB1 * fix(#4016): render a bounded pattern label in the prompt-guard advisory, pin plural prompts Review round 4 of PR #4061 left two nits open. 1. gsd-prompt-guard.js pushed `pattern.source` verbatim into its typed finding and, through renderFinding, into the user-facing advisory. With the #4016 superset pattern that source is 300 characters, so a genuine hit surfaced an advisory dominated by a raw regex dump. The read scanner has trimmed its equivalent since #3523 (`\s+` -> `-`, strip `()\`, cut at 50). That transform is hoisted into hooks/lib/injection-patterns.js as `describePattern` and used by BOTH hooks, so one finding renders the same label everywhere. Byte-identical to the scanner's old inline output for all 10 patterns (measured). No new staging dependency: both hooks already require this module. 2. The noun alternation `prompts?` had no positive coverage for the plural branch. One filler-regression row now exercises `... previous prompts ...` and runs through the existing once-per-hook, exactly-one-pattern loops. The parity test's prompt-guard count assertion moves off substring-matching the advisory prose onto the typed `findings` surface added in #3546, per CONTRIBUTING's raw-text-matching prohibition. New test: the superset source exceeds the bound (positive control), the prompt guard never embeds it, and both hooks carry the identical label in `findings[0].match`. Tests: parity, read-scanner, kimi field-shadowing, prompt-injection-scan, hooks-crash-policy, dead-exports: 206 run, 196 pass, 0 fail, 10 pre-existing platform skips. eslint clean; changeset lint ok; hooks runtime-build-seam lint ok; the CI prompt-injection scanner reports 0 findings on the PR diff. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GGp8kEB5zCDmJ6TYHP1Nj --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
5d804dd287 |
fix(#3709): clear the context-monitor warn sentinel on PreCompact (#3808)
* fix(#3709): clear the context-monitor warn sentinel on PreCompact The monitor's per-session warn sentinel survived a compaction, so once the first CRITICAL of a session had fired, `lastLevel` stayed pinned at 'critical' for the rest of the run. The hook was already wired to PreCompact (#772), but read the event only at the very END, and solely to pick an output envelope. Two documented behaviours died as a result: - "First warning always fires immediately" — the first warning of the post-compaction cycle was debounced instead. - "Severity escalation (WARNING -> CRITICAL) bypasses debounce" — computed as `lastLevel === 'warning'`, which can never be true again, so every later CRITICAL waited out the full five-tool-use debounce, exactly when an immediate warning matters most. `criticalRecorded` was equally sticky: a session that compacted and later truly ran out kept a /gsd:resume-work breadcrumb (#1974) describing the earlier near-miss rather than the exhaustion that ended the run. Reproduced first, with the issue's own literal repro, including the detail that the compaction consumed a debounce slot (callsSinceWarn 0 -> 1). The reset runs BEFORE the metrics read, deliberately: a post-compaction reading is healthy again, so the ENOENT / stale / above-threshold branches would all exit first and never reach it. Returning early also stops the compaction from eating a slot of the cycle it was meant to restart. The event name is now read once through a shared `readEventName()` helper, so this reset and the #2289 output allowlist cannot drift on what counts as "no event name". Seven rows against a real sequence (the defect is state carried ACROSS calls, so they need their own driver — the existing helpers delete the sentinel after each invocation). Reverting the reset turns SIX of them red; the seventh is the non-vacuity row asserting a NON-compaction event must not clear the sentinel, which correctly passes either way. AC4 initially passed with and without the fix — asserting `criticalRecorded === true` is vacuous when the seeded stale sentinel already carries it. It now seeds a `staleProbe` marker that can only survive if the sentinel survives, so its absence is what proves the state was rebuilt. hooks/dist/ is gitignored and regenerated by build:hooks, so no committed dist copy needs syncing. Verified: `npm run lint:ci` exit 0; acceptance criteria 1-6 driven end-to-end against the real hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#3709): reset ahead of the config gate, and pin the placement itself Codex review of the #3709 fix, before opening the PR. Three findings, all in this change's own new code. 1. `context_warnings: false` prevented the reset. The config early-exit sits ABOVE where the reset was placed, so a session that disabled warnings, compacted, then re-enabled them mid-session resurrected the stale sentinel and the original bug with it. Config is re-read per invocation, so that sequence is supported rather than hypothetical. The reset now runs ahead of the config gate: clearing the sentinel is CLEANUP, not a warning — state that must not outlive a compaction should not outlive it merely because warnings are switched off right now. It cannot emit anything from there, so the disabled contract is untouched. 2. Nothing pinned the "before the metrics read" placement. Every row wrote a fresh metrics file, so the reset could have been moved below the metrics read, the stale check, or the healthy-threshold exit with all seven rows still green — while a REAL PreCompact, which carries no fresh metrics and follows a recovery to healthy usage, silently kept its sentinel. Three rows now pin it: no metrics file at all, usage recovered to healthy, and warnings disabled. Each catches a distinct wrong placement — moving the reset below the config check reds the third; below the metrics read reds all three. 3. The absent-sentinel row proved nothing. `assert.doesNotThrow` was vacuous because the driver caught every child exit, so a hook that exited 1 on the ENOENT unlink would still have passed. The driver now returns the exit code and the row asserts it is 0. Also corrected the `readEventName` comment: it said the event is "read once", which is not literally true — there are two call sites. The point is one DEFINITION of what counts as an event name, so the reset and the #2289 allowlist cannot drift; the comment now says that. Verified: 60 rows in tests/perf-317-context-monitor-fs.test.cjs, 0 fail, with both placement mutations driven to red and reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(#3709): backfill changeset pr number The fragment shipped with the documented `pr: 0` placeholder, which the changeset lint treats as always-silent, because the PR number does not exist until the PR is opened. Backfilled to 3808 now that it does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#3709): a compaction clears the stale reading too, not just the state Review round 1. Major 1 was right and it mattered: clearing only the sentinel traded a warning that never fires for one that fires when it must not. The statusline bridge still holds the PRE-compaction reading, and STALE_SECONDS is 60, so for up to a minute it still reads fresh and still says the context is exhausted. With the sentinel gone, firstWarn is true, so the next PostToolUse emitted a spurious CONTEXT CRITICAL immediately after the compaction that FREED the context — and flipped criticalRecorded, spawning a false context-exhaustion breadcrumb. That is the same breadcrumb inaccuracy #3709 exists to fix, re-entered from the other side. Reproduced before fixing, exactly as the review described. A compaction now invalidates the warning state AND the reading that produced it. Removing the bridge loses nothing: the statusline owns that file and rewrites it on every render, and its absence is already the "no reading yet" state a fresh session starts in, which exits silently. Two things my own verification caught while fixing it: - The first attempt did NOTHING. metricsPath was declared below the PreCompact block, so referencing it hit the temporal dead zone, threw, and the outer catch swallowed it into a silent exit 0. The probe still printed "silent", which looked like success but was the old debounce. metricsPath is now hoisted beside warnPath. - The new Major 1 row was VACUOUS. The driver's `metrics: false` DELETES the bridge, but the defect is a bridge that is still there and still reads fresh, so the row passed on the ENOENT early-exit rather than on the fix. Only the sentinel-only mutation exposed it. The driver grew a `metrics: 'keep'` mode that leaves the stale file in place; both Major 1 rows now red under that mutation. Also from the review: - Minor 1 — the compaction-abort path is now stated in the source rather than left silent, including why a conditional reset (SessionStart source "compact") is out of scope for this fix. - Minor 2 — docs/context-monitor.md completed: PreCompact wiring and the early return under How It Works, a table of all three things the reset clears, the breadcrumb guard, the warnings-disabled interaction, and the never-block property under Safety. - Minor 3 — changeset trimmed from ~1,400 chars of implementation narration to the user-visible change. - Nit 1 — a failed unlink (Windows EPERM/EBUSY) no longer leaves the bug silently intact: the file is neutralised in place instead, with a shape safe for each (an empty sentinel, a timestamp-0 bridge). - Nit 2 — reviewer-process narration removed from shipped test source. The remaining "Codex" mentions are pre-existing and name the RUNTIME. - Nit 3 — the debounce-slot row now asserts the observable consequence (the first post-compaction warning fires) rather than repeating AC1's assertion. - Nit 4 — the file docblock now lists the folded-in blocks and asks the next contributor to extend it. Verified: `npm run lint:ci` exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#3709): the unlink-failure fallback truncates to empty, matching deletion The fallback wrote well-formed neutral values, and neither was equivalent to the deletion it stood in for: '{}' parses, so firstWarn was false and the first post-compaction warning was debounced — AC2 undone on exactly the path the fallback exists for — and '{"timestamp":0}' was never stale (the guard is `metrics.timestamp && ...`), so the flow reached emit with remaining === undefined and injected a literal 'Usage at undefined%'. Truncating to '' makes JSON.parse throw on both reads: the sentinel read keeps firstWarn true, the bridge read falls to the outer catch and exits 0 silently (review of #3808, Blocker 1). The branch is now executed for real: an EPERM is injected into the child's fs.unlinkSync via --require preload — method monkeypatching, never chmod 0o000, which root bypasses under Docker/CI (Blocker 2). Both rows proved failing-first against the neutral-value fallback. The boundary trios at WARNING=35 / CRITICAL=25 are completed on the emit path with 34, 26, and 24 (Major 3); 36/35/25 were already pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3709): the truncation fallback refuses to follow a planted symlink The per-session files live in a shared sticky tmpdir, where an unlink failing EPERM is exactly what another user's planted file produces — and a planted SYMLINK would make the fallback's plain truncating write empty out its TARGET, weaponising the hook against any file its own user can write. Open with O_WRONLY|O_TRUNC|O_NOFOLLOW instead: a symlink fails ELOOP into the same give-up arm. On Windows the constant is absent and '|| 0' keeps the fallback alive there, where the held-handle case it exists for occurs and temp dirs are per-user. Found by Codex review; the new row proved failing-first against the writeFileSync fallback (victim file truncated to zero bytes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3709): refuse non-regular files everywhere, not only where O_NOFOLLOW exists Codex round 2: '|| 0' removed the no-follow protection exactly where it cannot be expressed as an open flag — Windows, whose tmpdir is NOT guaranteed per-user (TEMP/TMP overrides, system-temp fallback). An lstat isFile() guard now rejects symlinks and every other non-regular shape on all platforms before the truncating open; O_NOFOLLOW stays, as the lstat->open substitution-race backstop where the platform has it. The symlink row additionally asserts the planted link SURVIVES the call, so a preload match that stops engaging can no longer pass the row vacuously off a successful unlink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(#3709): tolerate the Windows give-up, still outlaw neutral values Both windows-latest CI lanes fail the two EPERM rows deterministically: the runners hold freshly written files with a share mode that allows DELETE (every real-unlink row passes) but refuses a truncating write-open, so the fallback's give-up arm engages — which is the fallback working as designed, not the defect the rows exist to catch. The rows are now platform-aware: POSIX still requires exact truncation and the behavioural follow-ons; Windows accepts truncated-or-untouched but still rejects the Blocker-1 regression class (a parseable neutral value is never legal anywhere), with the follow-ons gated on the truncation actually landing. Also corrects the hook comment: libuv defines O_NOFOLLOW as 0 on Windows — a no-op, not an absent constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3709): a compaction watermark closes the window bridge deletion only narrowed Round-3 Major 1: the statusline is an uncoordinated process that re-writes the bridge on every render, so a render landing between the PreCompact clear and the compaction's completion re-created the PRE-compaction reading under a CURRENT timestamp — past STALE_SECONDS, into a spurious post-compaction CRITICAL and a false exhaustion breadcrumb: the exact failure the deletion was added to prevent. PreCompact now also writes claude-ctx-<id>-compacted.json ({at}) and the metrics read drops any reading not STRICTLY newer than it — which also covers unstamped/zero timestamps once a compaction happened. Written unlink-then-O_EXCL so a planted file or symlink is never followed; failure degrades to the old narrowing. Docs and changeset now describe the watermark instead of overclaiming for the deletion. Round-3 Major 2: DEBOUNCE_CALLS and STALE_SECONDS get their trios — the gate increments BEFORE comparing, so seeds 3/4/5 pin 4-debounced, 5-emits, 6-emits; ages 59/60/61 pin the strict >. The child's clock is pinned via a --require preload (a wall-clock boundary row would flip on one second of startup delay). timestamp-0's falsy bypass is pinned directly as characterized behaviour. Mutation-proven: dropping O_NOFOLLOW, <= for <, and >= for > each red exactly one row. Minors: the symlink row's comment now names the lstat guard it actually pins, and a preload-blinded-lstat row drives the O_NOFOLLOW substitution -race backstop for real (3); absence assertions use warnRaw so a corrupt leftover cannot pass as deleted (4); the Windows give-up is an explicit t.skip, never a silent if (5); readEventName is total via String(), keeping #2289's side-effects-always-run contract for malformed event names, with a row (6); the PreCompact rationale lives once in docs/context-monitor.md with the code keeping only line-level constraints (9); the changeset is release-note-sized (10). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3709): the grace window covers the compaction's duration, not just its start Codex on the first watermark cut: the watermark stamps the compaction's START, so a statusline render one second later — still mid-compaction, still the old reading — passed 'strictly newer' and re-fired the false CRITICAL. Readings inside COMPACT_GRACE_SECONDS (60) past the watermark are now dropped: the window covers the compaction's own duration, a healthy reading dropped there behaves identically to an accepted one (it exits above-threshold anyway), and a genuine exhaustion warning is delayed at most one window after a compact. A watermark stamped ahead of the reader's clock is ignored — a clock step backwards or a stray file must degrade to plain staleness, never mute the monitor indefinitely. Both proven failing-first. readEventName is strict about TYPE, not coerced: String() rendered ['PreCompact'] as 'PreCompact' and would run the reset off a malformed payload. typeof: every non-string is 'no event' — silent, side effects intact — with rows for the number, hostile-object, and array-wrapped cases. The lstat-claim preload arm now writes an engagement marker the substitution-race row asserts on, so a match string that silently stops matching can no longer let the row pass off the real lstat guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI — the previous wave was cancelled by an Actions outage, zero job failures Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3709): drive the compaction rows on the clock, not on a future stamp Round-4 review raised three majors, all in the test scaffolding around the fix rather than in the fix itself. Major 2 (taken first — it is the cheapest and it unblocks Minor 6): call() passed process.env to the child unmodified, so two rows depended on ambient GEMINI_API_KEY. The preserved Gemini fallback is `eventName === "" && !!process.env.GEMINI_API_KEY`, and readEventName returns "" for every malformed name, so with the key set the malformed-event row's `stdout === ''` assertion failed outright — reproduced by running it under GEMINI_API_KEY=x. call() now takes an explicit env, the way the sibling runMonitor helper in this file always has, and both rows pin the variable unset. (The array row survived an ambient key only because its reading was debounced — incidental, not independence, so it is pinned too.) Major 1: the AC2/AC3 rows drove the hook with a bridge stamped 62 seconds in the FUTURE — a shape hooks/gsd-statusline.js cannot produce, since it always stamps Math.floor(Date.now()/1000) on the same clock. They proved "the sentinel was cleared" while their assertion messages claimed the documented immediate-warning behaviour, which is gated behind the grace window and went unexercised. Both rows now run the real sequence on the clock-pinning preload this PR already added for the STALE trio: PreCompact at a fixed instant, then a normally-stamped render one second past the window. Verified non-vacuous — stubbing the sentinel unlink reds both. Major 3: COMPACT_GRACE_SECONDS, the one constant this PR introduces, was the only threshold without a limit-1/limit/limit+1 trio, in a PR that adds full trios for four pre-existing ones. The seeded offsets were +0, +1 and +61; the boundary itself (+60) and limit-1 (+59) were untested. Added, driven by advancing the reader's clock rather than post-dating the reading, so the reading is never ahead of the reader and only the grace gate can drop it. Verified against three mutations — `>` to `>=`, the constant to 59, and the constant to 61 — each of which reds exactly one row of the trio. No production code changed. Verified: 85/85 in this file, lint:ci exit 0, and the two Minor-6 rows now pass under GEMINI_API_KEY=x as well as unset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG * fix(#3709): harden the watermark read and pin the thresholds it introduces Codex review of the full PR found four majors. All reproduced here against the real hook before fixing. MAJOR — the watermark was write-hardened but read-untrusted. PreCompact already refuses to follow or overwrite a planted object (unlink-then-O_EXCL), but the read was a bare readFileSync, so anything the write side gave up on was followed by every later invocation. In a shared sticky os.tmpdir() that is a mute primitive — a planted recent watermark suppresses monitoring — and a symlink to a FIFO stalls a synchronous read. Measured against the pre-hardening file: a symlink to a planted watermark WAS honored and muted the monitor. The read now uses the same lstat + O_NOFOLLOW pair the sentinel path uses, plus a size bound; symlink, directory and oversized cases are all refused, with a plain-file control proving watermarks still work. MAJOR — the `now + 5` skew tolerance was an unnamed, untested threshold. It is now WATERMARK_SKEW_SECONDS with a +4/+5/+6 trio, verified against two mutations (`<=` to `<`, and the constant to 6), each of which reds one row. This is the same class as round 4's Major 3, one layer up. MAJOR — the malformed-event row shared one session across both subcases, so the hostile-object iteration's `assert.ok(s.warn())` passed off the sentinel the `42` iteration left behind. A regression throwing before the bookkeeping would have kept it green — vacuous for exactly the subcase it exists for. Fresh session per subcase, with an explicit no-sentinel precondition. MAJOR — the stale-reading row's non-vacuity is an artifact of call()'s future stamp: with a production stamp the watermark suppresses the same reading, so the row cannot isolate bridge deletion. The two guards genuinely overlap inside the window, so no end-to-end row can separate them; the comment now says so and points at the direct pin (s.metrics() === null) instead of claiming an isolation it does not have. Docs corrected where measurement contradicted them: the window NARROWS the race rather than covering the compaction's duration, and the delay is not bounded by the window alone — first recovery is watermark+61s with no skew but watermark+66s at the accepted +5s skew. Aborted compactions are muted the same way. The truncation fallback is documented as best-effort, which is what the code and the Windows rows already do. Verified: 89/89 in this file, lint:ci exit 0, symlink/directory/oversize all refused where the pre-hardening file honored them, both new trios mutation-checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG * fix(#3709): move the PR's two new exits onto the declared-policy vocabulary #3911 / ADR-3889 migrated this hook off raw process.exit() while this PR was in review, replacing every exit with hooks/lib/hook-exit.js's allow(), which forces each call site to name its crash policy. The PreCompact reset and the watermark gate are added by THIS PR, so they did not exist to be migrated and came through the merge as the only two raw exits left in the file — caught by the new local/require-registered-exit rule. Both are ALLOW: a compaction is never blocked by this hook, which is the policy the rest of the file declares. Caught only in CI, not locally: `npm run lint` runs eslint with --cache, and the cached entry for this file predated the new rule, so a warm local cache reported clean. Re-verified with the cache cleared. allow() terminates rather than throwing, which matters for the watermark call site because it sits inside a try/catch — a throwing helper would unwind into that catch and silently drop the grace-window mute. Verified behaviourally, not by reading: the grace trio, the skew trio and the non-regular-file rows all still pass. Verified: lint:ci exit 0 with a cold eslint cache, full suite exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG * fix(#3709): harden the routine sentinel writes and the read beside them Round 7 ruled that the three routine debounce-accounting writes to the warn sentinel must match the three writes this PR already hardened: leaving the fourth unhardened beside them is the asymmetry that invites the defect back. They now go through one writeSentinel() helper using the compaction watermark's own unlink-then-O_EXCL shape, rather than a second policy — the unlink removes any existing object, and O_EXCL then refuses to create through one, so a write can only land on a fresh regular file this process made. The routine READ beside them was the last bare readFileSync on warnPath, and the same rationale applies to it verbatim; the watermark's read was hardened in round 4 for exactly this reason. Same lstat + O_NOFOLLOW + size bound. Its scope is stated in the test rather than overclaimed: lstat establishes that the sentinel is a plain regular file, not that it is trustworthy, so a cross-owner regular file at the predictable path is still read and is left as a disclosed pre-existing residual. Also fixes an accept-direction regression this PR introduced and six rounds of review missed. readEventName collapsed an ABSENT event name and a MALFORMED one onto the same '', and the preserved Gemini fallback keys off eventName === "", so with GEMINI_API_KEY set a malformed payload began emitting an AfterTool envelope. At the merge-base, data.hook_event_name.trim() threw on a truthy non-string after the side effects and nothing was ever emitted. Measured base-vs-head with a fresh sentinel per run: 42, ['PreCompact'] and {} all went silent -> EMITS, while an absent name and 'PostToolUse' were unchanged. readEventName now returns '' only for an absent name and null for a present-but-non-string one; both call sites compare for equality only, so every well-formed payload behaves identically. Five new rows, each proven fail-first with the mutations attributed separately: reverting the writes reds the write-through and non-regular rows, reverting the read reds the mute and non-regular rows, and reverting the absent/malformed split reds the Gemini row. The changeset's "behaves like a fresh session" is narrowed to name the 60-second suppression window and the best-effort reset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9 * test(#3709): pin both 4096-byte read bounds at their boundaries Round 8 asked for limit-1/limit/limit+1 coverage on the size bound the round-7 sentinel read-hardening introduced (gsd-context-monitor.js:335). The existing refusal row pads to 8192 -- a full 4096 bytes clear of the fence -- so `>` vs `>=`, or an off-by-one in the constant itself, was invisible to it. Covers the sibling bound too. The identical check guards the round-4 WATERMARK read at :278 and its refusal row pads to 8192 in exactly the same way; the review's own rationale (this file already holds WATERMARK_SKEW_SECONDS to a boundary trio, so an uncovered bound is the odd one out) applies to it unchanged. That half is a class sweep of a pre-existing bound and is test-only -- say the word and it comes out without touching the rest. Both trios assert on observable hook output rather than an internal error. Sentinel: an honored {callsSinceWarn:1,lastLevel:'warning'} keeps the debounce arm taken at remaining=30, so nothing is emitted, while a refused one falls back to first-warn defaults and emits. Watermark: honored mutes (stdout empty), refused leaves the warning. The 4097 row is the non-vacuity control for the two accept rows. Payloads are sized by measurement, with Buffer.byteLength asserted to equal the target, not by arithmetic on an assumed prefix width. Proven fail-first in both directions, with the hook restored after: `> 4096` -> `>= 4096` reds both trios (94/96); `> 4096` -> `> 4097` reds both trios (94/96); restored, 96/96. Under both mutations only the two new rows fail -- the pre-existing 8192-padded rows stay green, which is the review's fencepost claim demonstrated rather than assumed. * fix(#3709): correct the changeset's mute-window claim and a superseded comment Both from the pre-push Codex pass on the full PR. The changeset said readings are "suppressed for up to 60 seconds after a compaction starts". That is false at the accepted skew boundary, and this repo's own docs/context-monitor.md already carried the accurate figure: first recovery is watermark+61s with no skew and watermark+66s for a watermark at the +5s skew limit. Measured independently at +64 silent, +65 silent, +66 warning. The changeset now states the window plus the accepted skew, matching the doc rather than contradicting it. A comment in the malformed-event row still described readEventName as returning "" for every malformed name. Round 7 superseded that: a present-but-non-string name returns null and only an ABSENT one returns "", so a malformed payload can no longer reach the Gemini fallback at all. Marked as historical and corrected. The GEMINI_API_KEY pin stays -- the row is about readEventName's typing, not the fallback, and an ambient key would still change what it measures. Codex's three Major findings are not taken, on attribution rather than logic; the reasoning is in the PR reply. In short: the watermark does not exist at the merge-base at all (0 occurrences), so "base emits, HEAD mutes" compares a new feature against its absence rather than showing a regression; and the base sentinel read is a bare readFileSync, which blocks on a planted FIFO exactly as the hardened read would, so the TOCTOU stall is not introduced here. The underlying limits -- watermark provenance, and lstat->open races on a non-symlink substitution -- are real, pre-existing, and already offered to the maintainer as follow-ups. * fix(#3709): read both sentinels through one hardened helper; state the two limits precisely Round 9's Major, with a correction to its premise, and both Minors. The review names "watermark read/write helpers this PR adds" that a call site at :238-250 duplicates inline. There are no such helpers: this PR adds readEventName and writeSentinel, the latter a write-side primitive a read cannot call, and :238-248 is base code the diff never touched. What IS duplicated is the hardened READ. The watermark read (round 4) and the warnPath read (round 7) are the same ten lines twice -- lstat, isFile and a 4096-byte bound, O_RDONLY|O_NOFOLLOW, readSync, close -- differing only in the path variable and the error string, and that is two copies to keep in step by hand. Now one function, readSentinel(target), beside writeSentinel. Refusal throws; both callers already wrapped the read in a try/catch that degrades to "no file", so behaviour is unchanged by construction. Proven rather than assumed: with the helper replaced by a bare readFileSync in a complete scratch tree, exactly the five hardened-read rows in tests/perf-317-context-monitor-fs.test.cjs go red -- round 7's symlinked and non-regular sentinel and its size bound, round 4's non-regular watermark, round 8's watermark size bound -- so the helper carries both call sites' guarantees and the rows pin it. 96/96 with the helper in place. Minor, drop vs delay: the grace-window comment said "dropped" on one line and "delayed" three lines later, and docs/context-monitor.md said "delayed". A genuine exhaustion reading inside the window is skipped, not queued: its warning and its #1974 breadcrumb both fire on the next reading after the window, so both are delayed when a later reading comes and lost when none does -- a session ending inside the window records neither. Comment and docs now say exactly that, and that the loss is accepted over trusting a reading that may be the pre-compaction value under a fresh timestamp. Minor, ordering: the PreCompact unlink and the debounce writeSentinel(warnPath) are two writers with nothing serialising them; a debounce invocation that read pre-compaction state and lands its write after the unlink would resurrect the sentinel the reset removes. The hook relies on the host dispatching a session's hooks one at a time, which Claude Code does and the other runtimes are assumed to. Stated at the reset as an assumption, with the lock-file alternative named and not taken. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy * fix(#3709): write the compaction watermark through writeSentinel Review of #3808, round 10. The PreCompact watermark write was the block writeSentinel was lifted from in round 7, and it kept its own inline copy of unlink-then-O_EXCL a few lines below the helper. Round 9 flagged that write-side duplication; the round-9 reply misread it as the read side and unified only the reads. The write now calls the helper too, so the hook holds one copy of the hardened write, not two. Behaviour is unchanged: same unlink-then-O_EXCL sequence, same flags, same best-effort outer catch. The one difference is that writeSentinel closes the descriptor in a finally, where the inline copy leaked it if writeSync threw before closeSync. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7bQLXAKubb4EFLtCPiLiL * fix(#3709): read the statusline bridge through the same hardening as the sentinels Review of #3808, round 11. `metricsPath` is built one line from `warnPath` and `watermarkPath` — same tmpdir, same predictable `claude-ctx-{sessionId}` shape, same threat model this PR documents at length for its siblings — and it is the only one of the three read on EVERY invocation. It was also the only one still reached by a bare `readFileSync`, so the symlink-follow and the symlink-to-FIFO stall that rounds 4 and 7 closed on the other two stayed reachable here, on the file's highest-traffic path. It now goes through `readSentinel` like the rest. The 4096-byte bound is ample for it: the statusline writes four fixed fields (`gsd-statusline.js`), about 140 bytes with a UUID session id, so no legitimate bridge approaches it. A refusal lands in the same rethrow an unreadable or malformed bridge already did. The comment introducing `readSentinel` claimed the warn sentinel was "the one bare readFileSync". Read as scoped to `warnPath` that was true, but it reads as a claim about the file and it is not one — the bridge kept its own until this round. Corrected rather than left to mislead the next reader. Round 11 Minor: `readSentinel` discarded `fs.readSync`'s return value and assumed the buffer was full, so a file truncated between the `lstat` and the read left a zero-filled tail. It now refuses a short read. Stated plainly because it was measured: this guard has NO observable behavioural delta — deleting it leaves the new row green, because the NUL tail makes `JSON.parse` throw one line later and both paths degrade to "no sentinel". It is a consistency fix in a function whose purpose is refusing to trust what it read, and the test comment says exactly that rather than implying coverage it lacks. Five rows added: the bridge refusing a planted symlink (with an attacker-chosen reading that WOULD warn if followed, so silence is proof), a non-regular bridge, an oversized bridge, the shrink path end to end, and the direction that matters most — a healthy bridge still warns, so the hardening is not a mute. Proven by mutation: reverting the bridge to `readFileSync` reddens two rows. The shrink injection carries an engagement marker for the same reason the lstat-claim one does, learned the same way: the hook rewrites the sentinel later in the invocation, so a size check afterwards passes whether the truncation landed or not. An independent full-PR pass on this round added two more, both taken: `writeSentinel` discarded `fs.writeSync`'s return value, and a short write is permitted by the syscall — so a truncated sentinel could reach disk and every later read would reject it, silently losing the debounce accounting or the watermark this write exists to record. It now loops until the payload is written, as Node's own `writeFileSync` does, with an explicit no-progress guard. Pinned by a row that injects a one-byte first write; reverting the loop reddens it. The directory row's comment claimed it pinned the `lstat` isFile() check. It does not — measured: deleting that condition leaves the row green, because reading a directory fails on its own a line later. The comment now says the row pins the outcome, and names the symlink row as the one that pins isFile(). DISCLOSED, NOT FIXED HERE — a session id long enough to push the derived filenames past NAME_MAX. The bridge is `claude-ctx-{id}.json`; the sentinel and watermark add longer suffixes, so on a 255-byte limit the watermark stops fitting at a 230-character id and the sentinel at 233. Measured base-vs-HEAD at 233+: base is SILENT, HEAD emits the warning, because the bare `writeFileSync` base used threw ENAMETOOLONG out of the warning path while `writeSentinel` degrades best-effort and lets the warning through. That is an accept-direction delta and it is in the delivering direction — base swallowed a warning the user should have seen, which is this issue's own failure class. The underlying limit is a property of the per-session filename scheme, shared by two files that predate this PR, and bounding session ids belongs to whatever writes them (`gsd-statusline.js`), not to the sentinel logic. Happy to fold a length guard in here if you would rather have it in this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRMEuzNMWn3gs5uUW2ghcF --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
f9bb489363 |
fix(#4204): isolate verify:post CLI test capability discovery from real HOME (#4293)
tests/loop-hooks-verify-post-e2e.test.cjs's runCli stripped ambient GSD_* env vars but never sandboxed HOME, so the spawned gsd-tools CLI subprocess fell through to os.homedir() (capability-loader.cts's overlayRoots and capability-state.cts's resolveCapabilityRuntimeState both thread process.env['GSD_HOME'], which falls back to os.homedir() when unset). Any capability genuinely installed at ~/.gsd/capabilities on the machine running the suite (e.g. beads, markdown-linting) leaked into the verify:post registry and inflated the file's exact-count assertions (3 -> 5 active hooks, 0 -> 2 on the all-off case, etc). Switch runCli to helpers.cjs's installSpawnEnv(), the helper ~370 other test files already use for this: it sandboxes HOME/USERPROFILE to a per-file mkdtemp'd fixture and clears the full config-location env list (GSD_HOME, GSD_RUNTIME, CLAUDE_CONFIG_DIR, etc.), so the CLI subprocess sees only the core registry regardless of what's installed on the host. The pure resolveLoopHooks() tests in the same file were already unaffected — they call the resolver directly with realRegistry, bypassing CLI env resolution entirely. Fixes #4204 Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
5869febb16 |
enhance(#4155): invalidate verification results when covered inputs change (#4290)
* enhance(#4155): invalidate verification results when covered inputs change readVerificationStatus() now recomputes a deterministic sha256 fingerprint over a VERIFICATION.md's declared covered_files (phase PLAN/SUMMARY, requirements, implementation files in the verified change set) and returns stale on any mismatch, fail-closed when a covered file is missing, unreadable, or escapes the project root. Legacy reports with no fingerprint metadata keep the prior SUMMARY-mtime staleness check unchanged. The verifier computes covered_digest via the new verification.fingerprint CLI command rather than by hand, since a digest is deterministic math, not an LLM-estimated value. * chore(#4155): backfill fork PR number in changeset * fix(#4155): trim gsd-verifier.md fingerprint instructions to fit LARGE tier byte cap * fix(#4155): address CodeRabbit findings on fingerprint fail-closed behavior Partial fingerprint metadata (one of covered_files/covered_digest present, the other missing or malformed) now fails closed to stale instead of silently downgrading to the legacy mtime-only check. computeCoveredDigest also canonicalizes with realpathSync before re-confining, so an in-root symlink whose target escapes the project root can no longer produce a matching digest. gsd-verifier.md restores the completeness requirement and checklist item trimmed by the earlier size-budget fix, within the LARGE tier byte cap. * chore(#4155): acknowledge gsd-verifier.md growth for the #4155 fingerprint instructions Emitted-Drift-Ack-Growth: gsd-verifier.md — adds the covered-input fingerprint instructions and frontmatter fields the #4155 verification staleness mechanism requires; trimmed to stay within the LARGE tier byte cap * fix(#4155): address gemini adversarial review findings computeCoveredDigest now threads the caller-supplied opts.fs seam through its confinement and read paths instead of always using raw node:fs — a caller like planning-inspect.cts's containmentEnforcingVerificationFs (GAP 2, #2790 follow-up) was silently bypassed for covered-input reads. The project-root anchor itself still canonicalizes through real fs (it is a trusted value the caller derived, not attacker-influenced covered-input data); only per-file candidate reads go through the injected seam. Covered-file paths are now canonicalized (./ prefixes, redundant slashes, internal .. segments) before becoming dedup/sort/hash keys or confinement subjects — closes both a spurious-stale false positive (two spellings of the same file hashing differently) and a confinement gap (an internal .. segment that doesn't start the string). gsd-verifier.md now states covered-file paths are project-root-relative, not phaseDir-relative, closing an ambiguity that would have made a real verifier agent's first fingerprint invocation fail closed. defaultFsImpl's methods now late-bind through fs.<method> rather than capturing function references at module load — the earlier direct-capture form was invisible to existing tests' t.mock.method(fs, 'statSync', ...) seams, a real regression caught by the full suite (not the reviewer). * fix(#4155): catch a plan/summary added to the phase dir after verification but never declared The content digest only recomputes hashes for paths the verifier actually declared in covered_files — it had no way to notice a plan or summary added to the phase directory after verification if that new file was never declared, silently regressing behind the legacy mtime check it replaces (which scans the live directory, not a declared list). findUncoveredCurrentArtifact re-scans the live phase directory for every current *-PLAN.md/*-SUMMARY.md and requires each to be represented in covered_files, closing that gap; a directory scan failure fails closed to stale rather than silently skipping the check. CONTEXT.md's Verification Module entry corrected to describe the fingerprint path's stricter fail-closed FS-error contract (routes to stale) instead of the module's original degrade-to-safe one (missing / not-stale), which only the legacy path still keeps. * refactor(#4155): extract canonicalizeCoveredFiles, add real nested-project e2e test computeCoveredDigest and cmdVerificationFingerprint each normalized/deduped/ sorted covered_files independently — one shared helper now backs both (gemini review's ponytail-lens finding). Adds one CLI-to-readVerificationStatus test against a genuine .planning/phases/NN-x/ project with an implementation file outside .planning/ entirely, closing the review finding that prior #4155 unit fixtures put phaseDir directly under an ownerless tmpdir (findProjectRoot falls back to phaseDir itself there) and never exercised real multi-level path resolution. * fix(#4155): route computeCoveredDigest through real fs, fail closed on unreadable plans/ Two independent review rounds (opus critical-reviewer + opus ponytail + agy, run twice) found two instances of the same fail-open class: - computeCoveredDigest's per-file reads routed through the caller's injected fsImpl. planning-inspect.cts passes a `.planning/`-confined containment fs into readVerificationStatus's opts.fs, so any covered implementation file outside `.planning/` (mandatory per the issue) made the confinement wrapper throw, which was caught and turned into a stale digest -- reporting every fingerprinted phase permanently stale via `planning.inspect`, regardless of actual drift. Per-file reads now always use real node:fs, matching the pre-existing treatment of root canonicalization; the realRel-vs-realRoot check is the real confinement boundary for this data and needs no seam. - allCurrentArtifactsCovered's try/catch never fired (scanPhasePlans reports readdir failures via a `scope` field, it never throws), so an unreadable nested plans/ dir was silently treated as "zero artifacts, all covered" instead of failing closed. Now branches on scope !== SCOPE.COMPLETE. Also, per ponytail's second-round findings: reverted an unwarranted FINGERPRINT_VERSION bump and digest length-prefix from the first fix (no v1 digest has ever existed -- the feature is unreleased -- and the prefix closed a collision that grants no capability beyond what a writer of covered_files already has more cheaply); removed a verifier-facing escape-hatch instruction whose own example was a case that should trigger staleness, not bypass it; corrected CONTEXT.md references to the renamed allCurrentArtifactsCovered and a stale "unconditional" rescan claim; simplified the isStale derivation, removed dead FsLike members, and tightened test coverage. Regression tests for both fail-open bugs are included and were each confirmed to fail against the pre-fix code before the fix landed. full test suite: 2558/2560 pass, 2 skipped, 0 fail * fix(#4155): trim gsd-verifier.md under the LARGE size cap Fork CI caught what my local runs missed: the superseded/nested-plans instruction added earlier pushed gsd-verifier.md to 49299 bytes, 147 over the LARGE tier's 49152-byte hard cap (tests/agent-size-budget.test.cjs). Tightened the #4155 instruction's wording and dropped a redundant inline comment tag; no content lost. * chore(#4155): point changeset at the upstream PR number pr: 19 was the fork PR opened for internal review-lane CI; now that open-gsd/gsd-core#4290 exists, the changeset field must match it per CONTRIBUTING.md's release-notes convention. --------- Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
5ad9a36f35 |
fix(#4255): resolve reviewer-lane effort from the lane, not from gsd-plan-checker (#4275)
`review-lane plan` resolved every cross-AI reviewer lane's reasoning effort by spawning `query resolve-execution gsd-plan-checker --host <slug>`. The agent id was a hardcoded literal, so `--host` chose only the argv RENDERING while the LEVEL always came from the installed plan-checker's frontmatter — `low` under every shipped model profile. Every prompt-fed lane therefore ran at a fast structural verifier's effort, and because the rendered argument is a CLI config override it silently beat the effort the operator had configured for that CLI. At `low` a large source-grounded prompt makes a model end its turn with no final message, so the lane came back empty and its stub read as a crash. Effort is a property of the review, so the lane declares it. Two new fields on ReviewerLane — `effortConfigKey` (`review.effort.<slug>`) and `defaultEffort` — carried through each capability manifest and the generated registry, set on the three lanes with an argv effort channel and null on the other nine. A new pure `resolveLaneEffort()` resolves config key -> lane default -> nothing, where "nothing" emits no effort argument at all and the reviewer CLI's own configuration decides; `inherit` selects that path explicitly and an unrecognized level falls back to the lane default rather than being forwarded to a CLI that would reject it. The host's negotiated effortSurface still gates the rendering, so ADR-1239/#2481's trust boundary holds on this path too. Resolving in-process also removes up to twelve subprocess spawns per review. The empty-output stub now names the effort the lane ran at and distinguishes a clean exit from a timeout kill, a non-zero exit, and a process that never ran — `status` is null for both a timeout and a signal, so those were indistinguishable before. The hint is hedged: a clean empty exit is most often a model stopping short, but it is also consistent with a CLI writing its output elsewhere. Also: the capability validator now knows both fields, rejects a malformed key or an out-of-vocabulary default, and rejects a default declared without a config key (a level the operator could never override). An existing end-to-end row in tests/effort-surface-axis.test.cjs asserted the old coupling; it now configures the lane's own key and pins the decoupling in the same real spawn, with the agent execution tier set to a level that must not appear. Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort; leaving the new key undocumented there is the same invisibility that made the plan-checker coupling survive this long. Emitted-Drift-Ack-Growth: review.md — the effort/model resolution-order table this fix adds. The workflow is where an operator looks to find out which knob set a lane's model and effort, so leaving the new key undocumented there is the same invisibility that let the plan-checker coupling survive. Claude-Session: https://claude.ai/code/session_01CRMEuzNMWn3gs5uUW2ghcF Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
925a363879 |
enhance(#4032): apply configured agent tool grants (#4238)
* test(4032): add failing installed-agent grants contract Cover global and project agent_tools precedence at the real Claude installer seam before adding implementation. * feat(4032): apply configured agent tool grants during staging Resolve selector-level global and project config once per staging call, then append validated grants before runtime conversion. * test(4032): cover host grant and quoted MCP contracts Exercise installed host artifacts and prove ZCode must treat quoted MCP scalars like plain MCP grants. * feat(4032): apply configured agent tool grants across runtimes Move augmentation and scalar identity into the converter seam so every staged artifact preserves host policy. * fix(4032): register agent tool grants in configuration Accept documented agent_tools config without unknown-key warnings.\n\nKeep installer fixtures on the shared temporary-directory helper. * fix(4032): translate configured MCP grants for Kilo Reuse the converter-owned scalar decoder so quoted canonical grants reach Kilo's native permission keys without altering other host policies. * fix(4032): decode YAML-escaped tool grants * fix(4032): emit valid inline agent tool grants * fix(4032): reject invalid trailing-colon grants * test(#4032): cover cross-review remediation gaps * fix(#4032): close cross-runtime grant gaps * test(#4032): expose Kimi global project context * fix(#4032): preserve Kimi project config context * chore(#4032): add release note * test(#4032): expose fork review regressions * fix(#4032): address fork review findings * test(#4032): make byte-stability assertion portable Compare repeat installs at one root so platform-specific path rendering cannot masquerade as an agent_tools behavior change. * chore(#4032): bind changeset to upstream PR 4238 * fix(#4032): address trek-e review findings (2,3,4,5,6,7,8) Fixes fail-closed decode-failure handling in ZCode's mcp__ stripper, a comment-only `tools:` header mis-parse that silently dropped configured grants, and a naive comma-split that could tear a quoted scalar containing a literal comma. Documents Kilo's inherent `{server}_{tool}` MCP-permission-key collision (external, fixed format — not ours to widen) and locks the existing first-seen-wins resolution in with a regression test. Opts kimi/kimi-code out of the ADR-1235 pre-converter path-rewrite step: routing Kimi through that pipeline (needed so project-scoped agent_tools selectors reach it) was short-circuiting Kimi's own neutralizeKimiAgentPrompt, which expects the original ~/.claude/gsd-core text rather than a pre-rewritten Kimi path. Extends the fast-check token pool and per-runtime install coverage with the missing comment/comma/broad-runtime cases the prior review flagged as untested. * docs(#4032): add CONTEXT.md glossary entries for agent_tools resolver + pre-converter step Documents readGsdEffectiveAgentTools (Install Model Override Resolver Module) and the appendAgentTools pre-converter pipeline step (Runtime Artifact Conversion Module), per contributor-standards.md's new-seam glossary requirement (finding 1). * fix(#4032): address agy adversarial review findings An agy (gemini-3.8-flash-high) adversarial pass over the prior review-fix commit found the fixes for findings 3, 4, 6 and 8 had unfixed sibling gaps, plus a genuine new regression and two CONTEXT.md inaccuracies: - ZCode's comment-only `tools: # note` header matched the inline-value branch instead of falling through to the block-list scan, so a following mcp__* item leaked through unstripped — the exact defect finding 4 fixed in appendAgentTools, unfixed in this sibling function. - Reverted capabilities/kimi-code/capability.json's noPathRewrite: true. kimi-code uses the standard 'agents' kind with converter: null (not kimi-agents — confirmed by reading the descriptor, not its prose description), so it never went through the pipeline change finding 5 fixed, and disabling its path rewrite broke every ~/.claude/ embed in its shipped agents instead. - decodeToolScalar never stripped a trailing ` # comment` from a bare (unquoted) scalar, so a comment after a block-list item, or after an appended grant on an inline line, became part of the "tool name" — fixed at the source (one call site fixes every consumer). - appendAgentTools's comment-index scan wasn't quote-aware, so a `#` inside a quoted scalar (`"mcp__server #1"`) was mistaken for a comment start and corrupted the quote. - parseFrontmatterTools (Kimi/Qwen's tool-list reader, downstream of appendAgentTools's own output) had the same naive comma-split and comment-only-header gaps as findings 4 and 6, unpatched. - The all-runtime smoke test's presence assertion was built on a guessed omit-list; empirically only 7 of 17 runtimes keep an arbitrary mcp__ grant recognizable, replaced with a verified allowlist. - CONTEXT.md claimed a `project:<agent>` selector prefix that does not exist (project override is a same-key merge across two config files) and mislabeled stageAgentsForRuntimeWithConverter's module. * fix(#4032): address full-PR review (Opus critical/ponytail + agy) A whole-PR pass (critical-code-reviewer + ponytail-review on Opus, plus a second agy full-source adversarial pass) surfaced defects the earlier finding-scoped passes couldn't reach: - appendAgentTools corrupted a `tools:` line whose ENTIRE value is a leading quoted scalar (`tools: "Read"` -> `tools: "Read", Write`, invalid YAML) — there is no safe line-surgical rewrite here, so it now refuses to touch that shape instead of emitting broken frontmatter. - decodeToolScalar's malformed-trailing-quote check ran BEFORE comment stripping, so a bare tool name with a quote inside its own trailing comment (`Bash # note: "internal"`) was wrongly rejected. Reordered. - findUnquotedCommentIndex (added in the prior remediation commit) was built on a wrong model of YAML: a `#` after whitespace starts a real comment in a plain scalar regardless of nearby quote characters — verified against the actual parser. The one case that DOES need protection (a leading quoted scalar) is now refused outright above, so the quote-tracking scan was dead weight solving a problem that no longer reaches it. Removed; reverted to the plain `[ \t]#` scan. - Kilo has a SEPARATE agent-frontmatter parser (convertClaudeToKiloFrontmatter, distinct from the buildKiloAgentPermissionBlock fixed earlier) with the same comment-only-header and naive-comma-split gaps as findings 4 and 6 — unfixed in both its src/ and bin/install.js copies. Fixed in both, exporting splitToolScalars for bin/install.js to reuse rather than reimplementing it. - Pipeline docstring in stageAgentsForRuntimeWithConverter still listed 5 steps, omitting appendAgentTools (now step 3 of 6). - docs/CONFIGURATION.md didn't state that a --global install still discovers agent_tools from the cwd's .planning/config.json (confirmed intentional and already covered by a dedicated test, not a bug). - Removed install-engine.cts's deps.cwd injection seam: zero callers or tests ever populated it. Two claims from this round were verified and rejected, not fixed: prototype pollution via a `__proto__` selector key (empirically confirmed `Object.prototype` is never touched — only reassigns the resolver's own local object's prototype, with no observable effect), and a `*` grant value crashing YAML parsing as an alias reference (empirically confirmed it parses as plain scalar text, no crash). A pre-existing, unrelated defect (extractFrontmatterField returns null for block-list `tools:` on Copilot/Antigravity/Cursor/Codex/Qwen, affecting two shipped agents today) was filed as a follow-up rather than fixed here — it predates #4032 and isn't caused or worsened by this PR. * fix(#4032): update stale slug-derivation-drift-guard fixture line normalizeKimiSkillName's real closing brace moved from line 616 to 635 as a side effect of this PR's edits to runtime-artifact-conversion.cts; the MAJOR-1 fixture's hardcoded realEndLine had gone stale. * fix(#4032): address CodeRabbit findings on projectDir threading and flow-sequence tools bin/install.js's installAgentsKindStandalone call site omitted the projectDir argument the function already supports, so a global install through this legacy branch silently fell back to the runtime config dir instead of process.cwd() when resolving project-scoped agent_tools grants — inconsistent with the sibling installOpencodeFamilyArtifacts call site, which already threads it correctly. appendAgentTools' leading-quoted-scalar bailout did not cover a YAML flow sequence (`tools: [Bash, Read]`): splitToolScalars tore it apart on the in-sequence commas and appended past its closing bracket, producing invalid frontmatter. Extended the bailout regex to also refuse a value starting with `[`, matching the same "whole node, nothing may follow" reasoning already applied to quoted scalars. --------- Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
e8800287d5 |
enhance(#4153): fail closed unresolved update targets (#4237)
* test(#4153): cover unresolved update target * fix(#4153): fail closed unresolved update target * test(#4153): require a concrete recovery installer * fix(#4153): use concrete unresolved recovery command * chore(#4153): bind changeset to fork PR * test(#4153): cover portable update diagnostics * fix(#4153): keep update diagnostics portable * fix(#4153): harden update version diagnostics * test(#4153): reject jq in update version checks * test(#4153): expose step-local parser gap * fix(#4153): keep JSON parsing step-local * docs(#4153): align update target guidance * test(#4153): expose workflow runtime fallback * test(#4153): expose resolver runtime fallback * fix(#4153): leave unknown workflow runtime empty * fix(#4153): stop inferring Claude for unknown targets * test(#4153): preserve Claude workflow targeting * test(#4153): preserve known runtime directory identity * fix(#4153): recognize Claude workflow paths * fix(#4153): reuse known runtime directory identities * chore(#4153): acknowledge emitted workflow growth The fail-closed diagnostic and known-runtime preservation deliberately add 48 emitted bytes. Emitted-Drift-Ack-Growth: update.md — explicit unresolved-target diagnostics and known-runtime preservation * test(#4153): expose missing Windsurf workflow contract * docs(#4153): document Windsurf update targets * chore(#4153): bind changeset to upstream PR * fix(#4153): gate unresolved-target exit before the VERSION-missing fallback The VERSION-missing bullet in get_installed_version sat before the UPDATE_TARGET_UNRESOLVED exit and shared its trigger condition (version 0.0.0). An LLM agent reading the workflow top-to-bottom could satisfy "proceed to install" without ever reaching the fail-closed exit this PR adds, reopening the ill-defined mutating path #4153 closes. Reorder so the unresolved-target gate runs first and scope the VERSION-missing bullet to require an already-resolved target. Also drop two vacuous mutationSpies entries: they checked '--sync'/ '--reapply' (commands/gsd/update.md content) against `step`, a slice of workflows/update.md — always -1 regardless of correctness. Those routes bypass get_installed_version entirely and are already covered by install.test.cjs, reapply-patches.test.cjs, and skill-frontmatter-contract.test.cjs. * chore(#4153): point changeset pr field at fork PR #10 for fork CI * test(#4153): guard RUNTIME_DIRS/update.md table parity, confirm narrowing intent Nit 1: update.md's PREFERRED_RUNTIME prose and RUNTIME_DIRS (src/update-context.cts) are two independently maintained copies of the same runtime->dir mapping with no parity check; add one so a future edit to either surface without the other fails loudly instead of silently drifting. Nit 2: call out in the changeset that a custom --config-dir matching no known runtime, marker file, or env var now resolves unresolved instead of silently defaulting to claude -- this narrowing is intentional, it's the fail-closed behavior #4153 asks for. * fix(#4153): drop dead $UC fallback in check_latest_version's uc_field, cover unresolved-runtime fast path agy (gemini-3.8-flash-high) adversarial review of the full PR: 1. check_latest_version's uc_field() copy-pasted get_installed_version's `${2:-$UC}` fallback, but every call site here passes $2 explicitly and $UC does not exist in this step's scope -- dead, misleading reference. Use $2 directly. 2. No unit test covered resolveUpdateContext's preferredConfigDir fast path returning runtime: '' for a custom --config-dir matching no RUNTIME_DIRS suffix, marker file, or env var (the exact fail-closed case #4153 adds). Added. A third finding (update.md:90 using /gsd:update vs docs using /gsd-update) was investigated and rejected: /gsd:update is the actual registered Claude Code command name (commands/gsd/update.md name: gsd:update) and is locked by this PR's own test (tests/update-workflow.test.cjs); /gsd-update is a separate, pre-existing, intentional prose convention used in audience-facing docs (README/INVENTORY/FEATURES). Not a defect. * chore(#4153): backfill changeset pr field to upstream PR #4237 --------- Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
77e2472ca0 |
enhance(#4221): replace installer Read() deny rules with a managed secret-read guard hook (#4236)
* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration Add hooks/gsd-secret-read-guard.js, a blocking PreToolUse guard on Read|Grep|Bash that denies reads of .env, .env.<suffix> and .secrets (the .env.example/.sample/.template/.dist templates stay readable). Read checks file_path; Grep checks an explicit path and judges the glob per brace alternative; Bash runs a two-pass token scan (quotes, comments, redirects with fd digits, separators, $( )/backtick/<( ) recursion, heredoc bodies never scanned as commands, nested bash -c/eval rescans, git <ref>:<path> shapes) with a closed non-reading exemption set for existence checks. Fail-open crash policy; 1 MiB commands are denied as command-too-large; more than 64 glob alternatives as glob-too-complex. Why: Claude Code 2.1.259 makes every `cd DIR && grep …` compound prompt for approval whenever any Read() deny rule exists, even in auto mode. A hook denial is not a permission rule and never arms that check. The installer-written deny rules are retired in the follow-up commit. Registration: hooks.json (Read|Grep|Bash, timeout 5), build-hooks HOOKS_TO_COPY, managed-hooks-registry, runtime-hooks-surface (blocking guard with BLOCKING_GUARD_TIMEOUT_S; Kimi ReadFile|Grep|Shell), shell-command-projection managed sets, installer-migration-report, OpenCode/Kilo plugin (grep tool mapping, include -> glob, dispatch), docs tables in five locales, ADR-766 always-on list, regen:derived fixtures, and a new table-driven unit suite. * test(#4221): pin the secret-read guard in existing hook gates Register gsd-secret-read-guard.js in every existing hook gate: the hooks-crash-policy table (deny row; 6 -> 7 deny cases), plugin-manifest REQUIRED_HOOKS and its Read|Grep|Bash group, docs-hooks-table-parity EXPECTED_SURFACE_HOOKS, install.test MANAGED_JS_HOOKS, install-minimal- hooks JS_HOOKS/BLOCKING_GUARDS, portable-node-runner GUARD_HOOKS, kilo-upgrades PLUGIN_GUARD_HOOKS, the Kimi normalization-parity and typed-payload floors, the OpenCode adapter (grep mapping, include -> glob, three dispatch tests) and a Kimi TOML matcher assertion. * fix(#4221): retire installer Read() deny rules (legacy filter) Rename GSD_CLAUDE_DENY_PERMISSIONS to GSD_CLAUDE_LEGACY_DENY_PERMISSIONS and stop adding the three Read(.env) / Read(.env.*) / Read(.secrets) strings. mergeClaudePermissions now only filters them out of an existing permissions.deny: an absent deny key stays absent, a malformed one is still repaired to [], and an array emptied by the filter is deleted so no `"deny": []` residue is left. Uninstall filters the same legacy list and, symmetric with the Antigravity branch, drops an emptied allow or deny key and an emptied permissions object. Unlike the #2278 allow-side migration there is no surviving current deny list, so the constant is renamed rather than mirrored. Removal is byte-exact: a hand-written identical rule is indistinguishable from the installer's and is removed too (the manifest never recorded permission strings). USER-GUIDE and CONTEXT.md updated. * test(#4221): flip install-regressions deny-rule assertions to the retired shape The fresh-merge, non-destructive merge, idempotency, end-to-end install, reinstall and uninstall assertions now expect no Read(.env*) deny rules and no permissions.deny key on a fresh install; the deny:null repair case is kept. A new describe block covers the legacy filter: retired strings removed with a user entry kept, partial sets, near-miss strings untouched, idempotency, GSD-only deny array deleted, a pre-existing empty deny preserved, and uninstall symmetry for allow/deny/permissions. * chore(#4221): add changeset fragment for PR #4236 * fix(#4221): case-fold names; scan shell stdin and xargs pipes Review round 1 (trek-e): - Blocker: secret-name matching is now case-insensitive in the Read, Grep (path and glob) and Bash paths, so `.ENV` / `.Secrets` on a case-insensitive filesystem are recognized as the same secret file. - Major: a shell interpreter's script is now scanned wherever it comes from. The tokenizer keeps heredoc bodies as per-segment tokens and records separator operators; pass 2 groups by segment id and resolves bash/sh/zsh/dash/ksh/su invocation mode: `-c` (including combined `-lc`) scans the script operand, a file operand is checked as a file (a `<( )` operand's echo/printf output is reconstructed), otherwise stdin is the script and heredocs, here-strings and a piped echo/printf source are scanned. `eval` joins all its operands; `source`/`.` handle process substitution. Data heredocs (`cat <<EOF`, the commit-message shape) stay unscanned. - Major: `… | xargs <cmd>` checks the upstream segment's operands as file names when the sub-command reads (`echo .env | xargs cat`, `find . -name .env | xargs cat`); `-a`/`--arg-file` suppresses the inference; a shell sub-command's `-c` script is scanned. Header, USER-GUIDE bullet and changeset updated; documented gaps now include piped scripts from non-echo sources and `exec`/`timeout` wrappers. 60 new suite cases pin the block and allow shapes. --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
d0d542e478 |
fix(#4183): resolve root phase Fallow base (#4215)
* test(#4183): reproduce root phase Fallow base failure * fix(#4183): resolve root phase Fallow base Fall back to the phase root commit when its parent is unresolvable. * chore(#4183): add release note * chore(#4183): bind changeset to fork PR * docs(#4183): describe root fallback precisely * chore(#4183): bind changeset to upstream PR --------- Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
eedb6b5431 |
enhance(#4107): sequence external review after internal fixes (#4206)
* enhance(#4107): sequence external review after internal fixes Teach the planner to finish internal review and accepted fixes before opening a PR known to trigger automatic external review. If an open-time property exists, re-check it immediately before opening with nothing intervening; post-open CI, review, changeset, and tracking work may follow. Emitted-Drift-Ack-Growth: gsd-planner.md — issue #4107 adds the review-before-publish ordering rule * chore(#4107): add PR #11 changeset * chore(changeset): link upstream PR 4206 * fix(#4107): ground external-review terms and tighten ordering test Addresses trek-e review on PR #4206: - Ground 'known automatic external review' and 'open-time property' with concrete anchors (CodeRabbit App / .coderabbit.yaml, not-behind-base). - Suffix the antipatterns heading with (#4107), matching sibling sections. - Replace vacuous negative assertion with inverted-order fixtures that prove the ordering regexes reject bad phrasing, not just co-occurrence. * fix(#4107): make directionality fixtures genuinely adversarial agy (gemini-3.8-flash-high) adversarial review found the two negative fixtures added in 584ec1cda were vacuous: they proved the ordering regexes require certain keywords, not that they reject inverted order — the bad strings simply omitted required tokens rather than reordering them. - Rebuild both fixtures to contain every required token, reordered/negated, so a real reordering would still slip past a weaker regex. - Drop the unsupported 'changeset' mention from the Wave 4+ antipatterns example — gsd-core/workflows/ship.md never references changeset work, so naming it here implied a step this rule doesn't actually govern. * fix(#4107): make the full review-then-fix-then-open sequence explicit CodeRabbit (fork PR #11) flagged that the planner prose only ordered accepted fixes before PR open, without explicitly naming 'run internal review' as its own earlier step, and that no fixture tested the planner text's own wording for inversion (only the antipatterns example had one). - Prose now reads 'run internal review and apply the accepted internal-review fixes before the final open'. - Added a planner-text-specific inverted-order fixture alongside the existing antipatterns-example one. --------- Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
a262ad6b61 |
fix(#4148): dispatch wave-pre step hooks (#4185)
* fix(#4148): dispatch wave-pre step hooks External capabilities can render step hooks before a wave, but the execute workflow consumed only contributions and silently skipped every step. Reuse the shared dispatch contract before executor spawning and pin the capability-validator boundary with a red-first regression. Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning * test(#4148): pin wave-pre dispatch ordering * test(#4148): pin wave-pre dispatch contract * chore(#4148): bind upstream changeset PR * chore(#4148): restore fork changeset identity * fix(#4148): align wave-pre dispatch contract Mirror the sibling wave-post all-shapes clarification while pruning redundant prose so the rebased workflow remains below its frozen byte ceiling. Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning * chore(#4148): restore upstream changeset identity * fix(#4148): align wave-pre capability guidance * docs(#4148): identify wave-pre manifest input Name the third-party manifest trust origin at the wave-pre dispatch boundary so the reviewer-requested validation guidance matches wave-post. Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning * docs(#4148): preserve execute-phase byte budget Remove a redundant advisory label while retaining the non-blocking contract, keeping the reviewer-required trust-boundary wording at the enforced 93,400-byte ceiling. * fix(#4148): mark wave-pre manifest-input validation as security-relevant Reviewer nit on PR #4185: wave-pre's step-dispatch sentence had the (third-party manifest input) parenthetical but dropped the ⚠ marker that wave-post's parallel sentence (execute-phase.md:1044) carries, losing the visual flag that this validation is security-motivated. Trims the redundant "of one" from "not one shape of one" to reclaim the 4 bytes the marker adds — the ADR-857 byte-margin gate (tests/claude-orchestration.test.cjs) leaves zero slack at the 93,400-byte ceiling. * fix(#4148): trim wave-pre step-dispatch prose to clear ADR-857 byte ceiling Merging next's unrelated growth (#3990's TDD_APPLICABLE conditional) pushed execute-phase.md 116 bytes past the 93,400-byte ceiling, failing CI on all three platforms. The security-relevant ⚠ marker and ref.command validation call-out (added per prior reviewer nit) are preserved verbatim per the pinned regression test in capability-registry.test.cjs; only the non-pinned connective prose is trimmed. * fix(#4148): recalibrate execute-phase.md self-imposed margin, restore security marker next grew execute-phase.md by ~230 bytes across two unrelated merges during this fix (#3990's TDD_APPLICABLE conditional, then a further step-extraction commit), consuming this test's own self-imposed 93,400 safety buffer under ADR-857's actual, unmodified 93,600 ceiling (docs/adr/857-capability-system.md:22). The wave-pre step-dispatch sentence cannot shrink further without dropping one of the pinned substrings this same test file asserts on (kind=="step", loop-hook-dispatch, never blocks or redirects executor spawning, Validate `ref.command`). Raises the self-imposed margin to 93,550 (still 50 bytes under the real, untouched ADR ceiling) and restores the ⚠ marker the prior reviewer round required for the ref.command validation call-out, which byte pressure had dropped. * fix(#4148): restore full ref.command validation wording, drop self-imposed margin Adversarial review (agy/gemini-3.8-flash-high) flagged two issues in the prior CI-recovery commit: 1. Trimming "in-context before any shell use" from the step-dispatch warning weakened the inline operational instruction (the reader is told WHAT to validate but not the specific in-context-not-shell mechanism the referenced loop-hook-dispatch.md:45-51 threat model requires). Restored it - the merge with next since the last commit freed enough real margin (77 bytes under the untouched 93,600 ADR-857 ceiling) to afford it without any margin change. 2. The prior commit self-imposed margin bump (93400 to 93550) was, on reflection, the wrong lever: it is a number this PR invented, not an ADR value, and re-bumping it every time next grows execute-phase.md is a losing pattern (already needed twice in one session). Removed the redundant assertion; the same line existing bytes-under-93600 check against the real, frozen ADR-857 ceiling (docs/adr/857-capability-system.md:22) is the actual invariant and is untouched. workflow-size-budget.test.cjs tier hard cap (98304 bytes, extract-not-bump by design) remains the correct backstop for runaway growth. --------- Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
0f3959516c |
fix(#4106): drop the orphaned test:mutation:since script (#4179)
* fix(#4106): drop the orphaned test:mutation:since script `"test:mutation:since": "stryker run --incremental --since origin/next"` could not run. Stryker has no `--since` flag — verified against the pinned `@stryker-mutator/core` 9.6.1, whose options schema has no `since` property and whose CLI exits `error: unknown option '--since'`. It went unnoticed because nothing invokes it. `.github/workflows/mutation.yml` uses the per-module matrix instead: `scripts/mutation-matrix.cjs` computes the changed covered modules and each shard runs its own `npx stryker run --incremental --mutate "${MUTATE_GLOB}"`. That path works, so no gate ever exercised the script. It is still worth removing rather than leaving: its name makes it the obvious thing to reach for when a reviewer asks for a mutation score on changed scope, and the Commander parse error it produces says nothing about the matrix being the real entry point — the next person has to reverse-engineer mutation.yml to find that out. Delete rather than repoint. The issue offered both, and the maintainer had not picked; delete is the option that stays scoped to the reported bug. The suggested replacement (`node scripts/mutation-matrix.cjs --base origin/next`) emits a CI matrix rather than running mutants, so shipping it under a `test:mutation:*` name would be a second, differently-shaped claim, and documenting the per-module invocation in CONTRIBUTING.md is enhancement-shaped work that belongs in its own issue. The discovery command is named in the changeset instead. Flipping this PR to the pointer script is a one-line change if that is preferred. The regression test asserts that every `stryker` script in package.json passes only flags Stryker's own options schema defines. It carries its own control — the historical `--incremental --since origin/next` string is checked to still be rejected by the same predicate — so the sweep cannot go quietly vacuous if the remaining invocations lose their flags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR * chore(#4106): backfill the changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR * docs(#4106): mark the changeset docs-exempt `type: Removed` makes docs-lint require a `docs/` change. Nothing under `docs/` or in CONTRIBUTING.md ever referenced `test:mutation:since` — the script was orphaned and could not run — so there is no documented behaviour to update. Using the gate's own per-fragment escape hatch rather than weakening the changeset type to dodge the requirement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zgWzB96uz3LVKdJePYTLR --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
c6efe2905c |
fix(#4087): stage the hook helpers the Codex bundle's hooks require (#4117)
* fix(#4087): stage the hook helpers the Codex bundle's hooks require CODEX_HOOKS_TO_COPY is a flat, hand-maintained filename allowlist that never recursed, and Codex is excluded from installSharedHooksBundle() — the path that stages hooks/lib/ for full-bundle runtimes — by an !isCodex gate. Excluding hooks/lib/ was a correct scoped decision for #3579 until #3911 ( |
||
|
|
0ea012c519 |
fix(#3939): parse decision bullets with a wrapped bold lead-in (#3953)
* fix(#3939): parse decision bullets with a wrapped bold lead-in parseDecisionLines matched every PHYSICAL line against the three decision-bullet grammars, and all three require the closing `**` in the same string as the `- **D-` anchor. A declaration whose bold lead-in wraps across a line break — the shape discuss-phase itself writes whenever a decision title runs past the wrap column — matched none of them and fell to the #1365 parse-miss guard, which forces `could-not-parse` and hard-blocks check.decision-coverage-plan on a well-formed CONTEXT.md. Fold physical lines into logical bullets before matching: a declaration whose bold lead-in is still open at end-of-line absorbs following lines until that run closes. The three grammars are untouched, so every single-line form parses exactly as before. Joining is bounded and preserves the fail-loud contract. A blank or whitespace-only line, any block-level construct (a list marker of any family, an ATX heading, a blockquote, a table row), or the end of the block stops it, and a lead-in that never closes is emitted unchanged — so a genuinely malformed bullet still reaches the parse-miss guard and still fails loud (#1365), and cannot be "closed" by an inline `**` belonging to the block below it. The joined line keeps the first physical line's indent, so the nested cross-reference signal (#3169) is unchanged. Absorbed lines are scanned once each rather than re-searching the accumulated candidate, keeping a pathological unterminated run linear on the plan gate's hot path. Regression coverage lands in tests/decisions.test.cjs (the owning module's file, per the regression-test placement policy): all three grammars wrapped, a three-line wrap, tags/category/continuation preservation, one-line parity (including inline bold and emphasis inside a wrapped title), CRLF, the markdown-header path, plus negative proof that every join terminator still yields could-not-parse and that the FIX-B and #3169 fixtures are unchanged. Fixes #3939 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3939): add changeset fragment for PR #3953 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3939): property-test the wrap-position invariant Review follow-up: RULESET.TESTS.property-based-testing requires a parsing / transformation contract to carry at least one fast-check property asserting a domain invariant, and the join added by the fix is exactly such a transformation. The example-based tests pinned four hand-picked wrap points; these generalize over the whole dimension. Three properties, on the shared tests/helpers/fast-check-setup.cjs config (numRuns 200, seeded): - round-trip: for every grammar (colon-immediate, titled-colon, em-dash), every id shape, every tag, with and without a category heading, wrapping the bold lead-in at ANY interior space is deepStrictEqual to not wrapping it — where a line happens to break carries no information; - domain invariant: a well-formed wrapped declaration never reaches the parse-miss guard (outcome `parsed`) and keeps its declared id; - fail-loud preservation: an unterminated bold run followed by 0-12 prose lines still yields `could-not-parse` with no decision manufactured, however many lines the join would have to absorb before giving up. The corpus is deliberately free of markdown metacharacters: `:` and `*` select a different grammar (#1639's `[^:*]*` discipline) and a block-construct token legitimately terminates the join. Both are separate behaviours, example-tested above; these properties isolate the wrap-position dimension. Rebuilding the module from `next` with these in place fails 14 (was 12); the two new failures are the round-trip and never-a-parse-miss properties. The fail-loud property passes before and after, which is the point of it. Refs #3939 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3939): fail loud when a wrap splices a decision tag token Addresses review rounds 2 and 3 on PR #3953. Folding a soft line break to a single space is markdown's own rule and is invisible everywhere in a decision bullet except inside the id-adjacent `[tags]` bracket, which the three grammars turn into `tags` and therefore into `trackable`. There a spliced space splits one tag token into two (`[defer` + `red]` -> `defer red`), which does not fail: it parses to a DIFFERENT tag, silently flipping whether check.decision-coverage-plan demands coverage for that decision. The join now stops at such a splice, so the bullet reaches the #1365 parse-miss guard and fails loud instead of guessing. The check is delimiter-aware, so wraps that land next to `[`, `,` or `]` still join and still parse identically to the one-line bullet -- a comma-separated tag list may wrap at any of its separators, across any number of lines. A bracket further along the title is ordinary text and does not restrict the join. Also in this round: - blockConstructRe's doc comment claimed parity with the sectionizer seam's `iterateBullets`, which recognises only the `N. ` ordered form while this set also stops at `N) `. The widening is deliberate and one-directional (a terminator set may recognise more block openers than a bullet iterator; a spare terminator can only make a malformed bullet fail loud, never manufacture a decision). Comment corrected to say so, both marker forms now tested, and a drift guard asserts the seam still does not yield `N)` so the divergence cannot widen silently. - Documented that the table-row alternative deliberately has no trailing whitespace requirement (CommonMark tables may open flush), and that over-termination on prose opening `10.` or `|` is accepted fail-loud behaviour -- now pinned by a test. - Coverage the review asked for: a WRAPPED bold lead-in nested under an already-open decision (#3169, the existing guard used a single-line nested bullet), and title/body whitespace fidelity across every wrap position around a double space. - A fourth fast-check property: wherever a wrap lands inside a `[tags]` bracket, the parse either matches the one-line bullet exactly or fails loud with nothing extracted -- never a decision whose tags differ. - Property helpers render through `renderBullet`, which asserts the form exists instead of letting an unchecked map lookup yield undefined. Fail-first: tests/decisions.test.cjs run against origin/next's decisions.cts fails 18 of 131; against the previous PR head it fails the 2 new tag-splice guards. All 131 pass with this change. Real-world CONTEXT.md from the report is unchanged at 37/44 parsed. Refs #3939 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3939): arm the tag-splice guard on any wrapped line, not just the first The #3953 round-3 guard read the id-adjacent `[tags]` bracket only from a bullet's FIRST physical line, via a regex anchored to the bullet start. A lead-in that wraps twice can open that bracket on a LATER absorbed segment, where the guard was never armed and `wouldSpliceTagToken` became a no-op: - **D-01 [inform ational]: A title.** body text here. folded to the tag `inform ational` and `trackable: true`, where the one-line form gives `informational` and `trackable: false` — a silently wrong answer to the coverage gate, with no thrown error and no parse-miss to signal it. Exactly the re-classification the round-3 guard exists to prevent, for the case it did not cover. `tagBracketOpenAtEolRe` becomes `tagRegionRe`, which asks whether the id-adjacent bracket REGION is still unsettled rather than whether it opened on one specific line: group 1 present means the bracket is open, group 1 absent means the id is read but a `[` may still follow. `joinWrappedBoldLeadIns` keeps the assembled text in `tagRegion` only while the bracket has yet to open, so a bracket opening on any segment arms `tagTail`; once armed, the pre-existing O(1) tail update takes over and `tagRegion` is dropped. A non-empty segment that is not a bracket-open settles the region immediately, so this bounds the string to a single extra join and leaves the 5000-line unterminated run linear. The id class widens to admit an empty id, so a bare `- **D-` still counts as unsettled. This regex only answers "may an id-adjacent bracket still open here?", where matching MORE shapes is the conservative direction: an over-broad match can only make a malformed bullet fail loud, a missed one re-classifies silently. The existing property test wraps at exactly one point, and only at spaces — which round-trip exactly, since the join re-inserts the space it replaced — so neither the bracket-opens-later state nor an observable splice was reachable from it. `wrapBoldLeadInMulti` breaks at two or more arbitrary positions after the id and asserts the same disjunction: parse identically to the one-line bullet, or fail loud with nothing extracted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCPSU591zVS9vLPd3gKnqn --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
eb336e9f77 |
fix(#4081): decode git C-quoted paths in codebase-drift --name-status parser (#4307)
* test(#4081): failing-first regression for quotepath C-quoted paths in codebase-drift * fix(#4081): decode git C-quoted paths in codebase-drift --name-status parse * test(#4081): set drift_threshold 1 so decoded-path test triggers action_required * chore(#4081): add changeset fragment * chore(#4081): fix changeset fragment formatting * chore(#4081): backfill PR number in changeset --------- Co-authored-by: sim <sim@local> |
||
|
|
9ee6d54cc3 |
fix(#4306): extend fault-injection fd-swallow fix across the whole suite (#4308)
* fix(#4306): forward real bytes through io.test.cjs's fault-injection mocks The bug #1008 fault-injection tests mock fs.writeSync scoped only by file descriptor. On their "success" arms (the retry-after-EAGAIN/EINTR call, and the short-write simulation) they fabricated a return byte count without ever calling the real writeSync -- the bytes went into a local array and nowhere else. node:test's process-isolation runner (default on Node >= 22) reads each test file's own stdout to parse its child-to-parent result protocol. If the runner's own reporter write for an adjacent test lands on fd 1 while one of these mocks is installed, that write was silently swallowed instead of reaching the real pipe -- observed in CI as "Unable to deserialize cloned data" (a corrupted/truncated byte stream on the parent's read side), not a thrown exception. Every "success" arm now forwards the real bytes to orig()/restore() instead of fabricating a return value, so anything else sharing the fd during the mocked window still gets its bytes delivered for real. writeAllSync (the only production caller reaching this mock) always passes a Buffer, so the forwarded calls use the buffer-form fs.writeSync overload unambiguously. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4306): extend fault-injection fd-swallow fix across the whole suite The originally-fixed instance (tests/io.test.cjs) was one occurrence of a copy-pasted defect: mocked fs.writeSync arms fabricated a return byte count without ever forwarding the call to the real fs.writeSync, silently discarding bytes. Under node:test's process-isolated runner, the parent reads the child's real stdout to parse v8-serialized report frames interleaved with plain output (confirmed against node's own lib/internal/test_runner/runner.js and a matching upstream issue, nodejs/node#64061) — a swallowed write on that fd corrupts the parent's parse ("Unable to deserialize cloned data"). Adds a shared, safe capture helper to tests/helpers.cjs, captureFdSync(fd, fn): it always forwards every write to the real fs.writeSync first, then records only the observed fd's bytes, sliced by the real return count (not the requested length), decoded once via Buffer.concat so a short write can't split a multi-byte codepoint across two decodes. 17 test files migrate their local copy of the unsafe mock to this shared helper. tests/worktree-base-ref.test.cjs keeps a narrower in-place fix instead (it needs to record every fd a write touched, which the shared helper doesn't expose). tests/io.test.cjs gets two follow-up correctness fixes on top of the already-committed forwarding fix: the EAGAIN/EINTR/short-write arms now derive their recorded chunk from the real return count everywhere (including the string-form overload), and the short-write test no longer forces a Buffer-shaped truncation call onto a string-form write that could land on the same fd. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
4499933807 |
fix(#3802): resolve the heredoc body before validating the commit subject (#3816)
* fix(#3802): resolve the heredoc body before validating the commit subject With hooks.community: true, gsd-validate-commit.sh blocked EVERY heredoc-form commit with CONVENTIONAL_COMMITS_VIOLATION regardless of the message, including Claude Code's own documented idiom: git commit -m "$(cat <<'EOF' feat(auth): add login flow EOF )" Reproduced before changing anything: conforming heredoc -> exit 2; plain -m "feat(auth): add login flow" -> exit 0. Root cause is the extraction regex `-m[[:space:]]+"([^"]+)"`. Bash `[^"]` matches newlines, so the capture ran from the quote after -m to the FINAL quote at `)"`, swallowing the whole span. `head -1` then returned the literal `$(cat <<'EOF'` as the subject, which can never satisfy Conventional Commits. Fixed by not answering a regex bug with another regex. hooks/lib/git-cmd.js already exists because "a naive regex misses all three" invocation forms, and extractBranchArgument is the established precedent for pulling an argument off a git command line. extractCommitSubject joins it on the same tokenizeShellLike seam — which, checked first, already returns the entire heredoc span as ONE token, leaving only "resolve the body to its first line" as new logic. Because the walk starts at the subcommand, `git -C <path> commit` and env-prefixed invocations now extract correctly too — forms the raw string scan never handled. Deliberately unchanged, and pinned as such: a glued `-mfeat: x` and `--message=...` still yield no message, exactly as the regex left them. The fix stays scoped to the reported defect rather than widening on a true observation. Two things I got wrong and corrected by measuring rather than reasoning: - I expected `git commit -m ""` to be blocked. Checked against the ORIGINAL hook: allowed before, allowed now, identical. The scanner drops the empty token so it takes the null path. My expectation was wrong, not the code. - That exposed a false comment I had just written, claiming the exit-status split prevents silently allowing `-m ""`. It does not. The split IS load-bearing, but for a heredoc whose body's first line is blank, which resolves to an empty subject and is correctly blocked. The comment now names the real case and records that `-m ""` is not it. Tests at both layers: 9 unit rows on extractCommitSubject beside its sibling in tests/worktree-safety.test.cjs, and 5 behavioral rows piping real PreToolUse payloads through the hook in tests/hooks-opt-in.test.cjs. Replacing firstLineOfMessageArg with a plain first-line return reds 8 of them across both files. (A first mutation attempt silently no-opped and reported green — the mutated body is echoed in the transcript for the run that counted.) Out of scope, per the issue: the hooks.commit_types config surface, split off by the maintainer as #3811 and explicitly sequenced after this. Verified: `npm run lint:ci` exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#3802): confine the fix to heredoc resolution, closing four regressions Codex review of the first attempt. It was right, and the finding is one my own rules already name: a true observation is not a licence to widen the diff. The first attempt replaced the shell's `-m` extraction with a token walk. That looked like the better abstraction — this module exists precisely because a naive regex misses invocation forms — but selecting WHICH argument is the message was never the defect, and changing it regressed four forms that upstream allowed, plus opened a bypass: - `git commit -- -m WIP` -- introduces pathspecs; `-m` is a path - `git commit --amend && echo -m WIP` a later command's flag became the message - `git commit -m "" --allow-empty-message` the shared scanner drops empty tokens, so the next flag became the message - `git commit -m WIP` unquoted argument - `-m "WIP notes <<EOF\nfix: smuggled subject"` was ALLOWED — the opener was recognised unanchored, so validation skipped past the real, non-conforming subject. An enforcement bypass, not a misclassification. Now confined to the actual defect. The shell's `-m` capture is restored byte for byte, and only the subject-from-message step is delegated, to a PURE STRING helper `resolveCommitSubject()` that never tokenizes. Verified as a differential against the upstream hook run inside the real tree: the only behaviours that change are the two intended heredoc rows (2 -> 0); all four forms above read identical, and the bypass case blocks. That differential also corrected my own control. An earlier comparison ran the upstream hook from a scratch directory, where its `lib/` could not resolve `../../gsd-core/bin/lib/token-scanner.cjs`, so the classifier failed open and reported exit 0 for everything. That made a real regression look pre-existing. Re-run inside the tree, `<<-"TAG"` (a double-quoted tag nested in the double-quoted argument) is genuinely pre-existing — the capture truncates — and is now recorded as a known limitation rather than silently "fixed". Also fixed from the review: - `<<-` strips leading TABS from body lines; returning the raw line blocked a conforming message. - a non-identifier tag such as `END-MSG` is a valid bash word and was rejected. - an immediately-following terminator is an EMPTY message, not a subject. - a node/library failure now falls back to the previous `head -1` instead of skipping validation, so a broken extractor degrades to old behaviour rather than becoming a new silent-allow path. Tests strengthened per the review: the opener-spelling rows now assert BOTH directions per spelling, since "conforming passes" alone would also pass if the resolver returned an empty subject for a spelling it failed to parse. Added differential rows pinning the five previously-allowed forms, and a row for the bypass. Dropped two rows whose comments claimed the raw scan could not handle `-C`/env-prefix invocations — it could; the claim was wrong. Replacing resolveCommitSubject with a plain first-line return reds 9 rows across both files. (Mutant body echoed in the transcript; an earlier mutation attempt on this branch silently no-opped and reported green.) Verified: `npm run lint:ci` exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#3802): keep the installed hook runtime-neutral `hooks/lib/git-cmd.js` ships into every runtime, including hermes and qwen, where tests/install.test.cjs enforces that no Claude reference leaks into the installed tree. My JSDoc named the idiom after the runtime that documents it. Reworded to describe the SHAPE rather than the vendor; the runtime is still named in the changeset, which feeds CHANGELOG.md where such references are allowed, and in the tests, which are not installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(#3802): backfill changeset pr number The fragment shipped with the documented `pr: 0` placeholder, which the changeset lint treats as always-silent, because the number does not exist until the PR is opened. Backfilled to 3816 now that it does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#3802): close the truncated-capture hole, add the required test artifacts Review round 1. Major 3 was the one that mattered, and it disproved a claim I had stated in falsifiable form — the PR body said only two behaviours change; the differential found five. Major 3 — an embedded `"` truncates the `-m` capture, so the resolver received a PREFIX of the real subject and the length gate measured the wrong string. Before this fix the whole form was blocked outright, so the gate was unreachable; the fix opened the path and then mismeasured it. A new enforcement hole, so it is CLOSED here rather than declared. Closed precisely rather than bluntly. A first attempt refused to resolve any body with no terminator, which also blocked commits whose SUBJECT was intact and whose quote sat further down the body — a false positive of its own. Truncation is only fatal to the line it lands IN, and a captured line is complete exactly when another line follows it, because the capture kept its newline. So an unterminated body whose subject line is followed by more text stays measurable; only a subject line running to the end of a truncated capture falls back to the opener, which fails the format gate exactly as this form did before the fix. Major 1 — fast-check property rows for the new parser, via the shared seeded setup helper rather than requiring fast-check directly, per repo convention: totality (a security property here, since an exception on this path fails OPEN), idempotency, and that the result is always a single line drawn from the input — the third catches a resolver that concatenated or trimmed while satisfying the first two. Major 2 — the 72-char gate is now exercised at {71, 72, 73} on the RESOLVED heredoc subject, with the fixture length asserted so a mis-built fixture cannot silently pass. 92 chars did not show which side of `> 72` the code sits on. Minor 1 — leading blank body lines are skipped, as git's cleanup=whitespace does. A conforming commit written that way was still blocked, which is the same defect class #3802 reports. Nit 1 — a backslash-escaped delimiter (`<<\EOF`) is now the same delimiter rather than failing closed on a delimiter that includes the backslash. Nit 5 — changeset trimmed from 2,208 chars of design note to the user-visible change. Mutation discipline, including a correction to my own: dropping the truncation guard reds the unit rows, and the pre-review naive shape reds the hook-level row too. My first mutant did NOT distinguish the hook row — removing the guard made an empty slice and blocked for an unrelated reason, so the row passed and looked proven. Only mutating to the actual pre-review shape showed it discriminates. Verified: `npm run lint:ci` exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#3802): measure the subject as git does — strip trailing whitespace, split CRLF git's cleanup=whitespace strips whitespace at BOTH ends of a line; the resolver handled only the leading direction, so a 72-char subject with trailing spaces measured 75 and stayed blocked — the defect class #3802 reports, surviving one round further (review of #3816, Major 2). The resolved subject now drops trailing spaces and tabs; the plain non- heredoc path is untouched, keeping the fix confined to heredoc resolution. The length-gate boundary rows gain dirty fixtures: 72+3 trailing spaces passes, 73+1 stays blocked on LENGTH. split('\n') left \r on every body line, so on CRLF input the delimiter never matched: the truncation guard was inert, an empty message resolved to 'EOF\r', and a real 72-char subject measured 73. Split on /\r?\n/ (Minor 3). The three property tests never reached the parser — the pinned-seed fc.string corpus contained no newline and no opener, so every property reduced to f(s) === s (Major 1). The generator now constructs heredoc- shaped input (all opener spellings, <<- tabs, optional terminator, CRLF) and each property asserts a floor on inputs its corpus actually resolved. All new rows proved failing-first against the pre-fix resolver. Also records the unquoted-delimiter expansion limit as one JSDoc sentence (Informational 5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3802): close two recognition bypasses, pin the dquoted-delimiter limit Codex whole-PR review found two enforcement bypasses in the resolver: - The opener's path prefix was \S*, which accepted `id;/bin/cat` — the resolver then validated the heredoc BODY while bash runs `id` first and git's real subject is id's OUTPUT. The prefix is now a path-character class; any shell metacharacter fails recognition and the form falls back to the opener line and the format gate. - The blank-line skip used JavaScript trim(), whose Unicode whitespace class skips lines git KEEPS: a NBSP first body line resolved to the SECOND line while git's real subject is the NBSP line (verified against git stripspace — the c2a0 bytes survive). Blank is now git's ASCII space/tab only; a Unicode-blank line is returned and fails the format gate, the same fail-closed direction git takes. Both proven failing-first at resolver AND hook level. Also: the <<"TAG" spelling is recorded as a documented limit — the -m capture stops at the delimiter's own quote so the caller can never deliver it (fail closed; widening the capture would change every embedded-quote case) — with a hook-level row pinning the limit; and the derivation property no longer accepts '' unconditionally, only for heredoc-shaped input, so a conditional constant-'' regression can't satisfy the corpus floor unnoticed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3802): recognition whitespace is ASCII, and '' answers to the generator Codex round 2: the opener's \s accepted Unicode whitespace bash does not split on — $(<NBSP>/bin/cat was recognized here while bash reads <NBSP>/bin/cat as the executable NAME, so recognition claimed a substitution that does not run cat. Every whitespace position in the recognition is now [ \t], the same ASCII rule as the blank-line skip, proven failing-first. The derivation property's ''-acceptance now consults GENERATION-TIME metadata: the heredoc generator records whether it built an empty message (terminator reachable, all scanned lines ASCII-blank, <<- tab stripping accounted for), and '' is accepted exactly then — a resolver conditionally degrading to '' on non-empty heredocs now fails, closing the residual round-1 permissiveness without re-deriving resolver logic. The changeset no longer overstates the opener spellings: it names the capture-deliverable set and the documented <<"EOF" limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3802): nothing after the terminator escapes measurement Round-3 BLOCKER: everything after the heredoc terminator was silently discarded, so `-m "$(cat <<'EOF'\nfeat: ok\nEOF\n) <200 a's>"` — one 200+ char real subject once bash substitutes — measured 8 chars and dodged COMMIT_SUBJECT_TOO_LONG, a hole the base did not have. The canonical idiom's tail is exactly one closing-paren line; any other tail now falls back to the opener line and the format gate, the pre-fix behaviour for the whole form. Proven failing-first at resolver and hook level, including the glued-text and second-substitution variants. Also from round 3: `cat<<'EOF'` (no space) is legal bash and now resolves — the token before << is still literally cat; the env-prefixed and option-terminated spellings join the JSDoc KNOWN LIMIT list instead (fail closed, modelling bash prefix words is cost with no reported user); the changeset states the embedded-quote truncation limit for the message body, not just the <<"EOF" spelling; the dquoted unit and hook rows now cross-reference each other; and the fast-check setup helper's docstring no longer claims property-file exclusivity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3802): glued text outside the closing quote must not shrink the measurement Codex on the round-3 guard: bash concatenates -m "$(…)"suffix into ONE argument, but the capture holds only the quoted part — so the resolver measured the heredoc body (8 chars) for a 200+ char real subject, a net-new length-gate bypass the base did not have (base measured the opener and blocked). When the closing quote is followed by anything but whitespace or end-of-command, the hook now skips the resolver and keeps the pre-fix first-line subject: the heredoc form fails the format gate exactly as on base, and the plain single-line form keeps base behavior unchanged — both pinned as differential rows, the glued-suffix row proven failing-first against the unguarded script. The property generator's ''-oracle now models the post-terminator guard it previously predated: expectEmpty requires the FIRST reachable terminator to be followed by the one canonical closing-paren line, so a resolver regressing to '' on a non-canonical tail (e.g. a body line that doubles as an early terminator) fails the derivation property instead of being blessed by stale metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI — the previous wave never started (Actions queue stall) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#3802): only resolve a heredoc whose body bash does not rewrite Round-4 review found two net-new enforcement bypasses: commands the base hook blocked (exit 2) that this branch allowed (exit 0). Both reproduced as a base-vs-head differential against the real hook, not inferred. The predicate "may I resolve this?" was computed from the resolver's input string alone, while two of its determinants live outside that string: 1. WHICH -m quote arm produced the input. Inside -m '...' bash performs no command substitution, so $(cat <<'EOF' is literal text and git's real subject is the opener line. The resolver ran on both arms, so all four delimiter spellings went 2 -> 0 on the sq arm — reachable by the ordinary slip of typing ' for ". The hook now records MSG_QUOTE and gates the resolver on dq; sq keeps head -1, exact base parity. 2. WHETHER the delimiter suppresses expansion. Only <<'D', <<"D" and <<\D do; a bare <<D is expanded by bash before git sees it. Resolving the literal dodged the format gate (feat: $UNSET_VAR reaches git as feat:) and the length gate (feat: ${LONG} reaches it at any length). The opener regex now separates the backslash-quoted and bare alternatives and refuses the bare one — the same fail-closed rule the metacharacter, truncation and post-terminator guards already follow. A test row asserted exit 0 for a bare-delimiter body, so the suite defended the second bypass and the fix could not land without editing a test that read as intentional. That row and its two unit counterparts now assert the block, per RULESET.TESTS.delete-bad-tests. Two unrelated rows used <<-EOF to exercise tab stripping; they move to <<-'EOF' so each tests what it names. Scoping the adjacency guard to the matched arm — required by the fix above — also removes a spurious block (round-4 Minor 1): a double-quoted heredoc whose body mentioned a glued single-quoted token tripped the sq arm. The JSDoc claimed <<"EOF" was unreachable through the caller and that the bare-delimiter gap was pre-existing. Round 4 disproved both; both corrected here, along with the matching changeset sentence. Verified: 7 bypass commands now block at head (was allow), the #3802 fix and plain-form parity are unchanged across 8 control commands, hooks-opt-in 44/44, worktree-safety 401/401, property-test non-vacuity 73/200 against a floor of 20, lint:ci exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG * fix(#3802): resolve only where the captured text is provably git's subject Codex review of the full PR found two more inputs where the validated text is not the subject git receives, both net-new bypasses (base 2 -> head 0), plus one escalation of round-4 Minor 2. All reproduced here against the real hook and confirmed against real commits before fixing. BLOCKER — the matched -m need not be git's message. The capture is a search over the whole command and the double-quoted arm runs first, so it could select a -m that is not the subject at all. git concatenates multiple -m values and takes the FIRST as the subject, so git commit -m 'WIP first' -m "$(cat <<'EOF' … )" commits the subject `WIP first` while the hook validated the heredoc. Same for an unquoted earlier -m, for a heredoc after `--` (a pathspec, not a message), and for one belonging to a later `&& echo`. The mis-selection is pre-existing; resolving it is what made it a bypass. The hook now resolves only when nothing before the matched -m could have been an earlier message, an end-of-options marker, or another command. BLOCKER — cleanup mode is part of the predicate. The resolver skips leading blank lines and strips trailing whitespace because git's DEFAULT cleanup=whitespace does. Under --cleanup=verbatim git does neither, so a 72-char subject plus three trailing spaces is committed at 75 bytes while the hook measured 72 — COMMIT_SUBJECT_TOO_LONG dodged. This one hides from `git log --pretty=%s`, which strips trailing whitespace in its own output; the raw commit object shows 75 vs 72. Any named mode other than whitespace, in either the --cleanup= or -c commit.cleanup= form, now refuses to resolve. MAJOR — recognition trusted any path ending in /cat, so a planted `../evil/cat` printing `WIP injected` had its heredoc body validated while git's real subject was `WIP injected`. Only a bare `cat` or an absolute path is recognised now. A bare `cat` shadowed on PATH is a documented residual and is not fixable from a string — nor a meaningful boundary, since planting an executable already allows running git directly. The changeset and the JSDoc both asserted that a `"` anywhere in the message blocks. Measured false: a `"` on a later body line resolves fine, because the subject completes before the truncation point; only a `"` in the subject line blocks. The changeset also listed <<"EOF" as covered when it measures 2/2. Both rewritten to claim only what is measured, and the residual false positives are now named. Verified: 4 + 2 + 3 new bypass commands now block, with non-vacuity controls proving the default path still resolves; all round-4 maintainer blockers stay closed; the #3802 fix and plain-form parity unchanged across 7 controls; hooks-opt-in 47/47, worktree-safety 402/402, property non-vacuity 73/200 against a floor of 20, lint:ci exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAbQy7n4mLMB7h3TnZ8GdG * fix(#3802): scope the cleanup-mode guard to the command outside the message The guard scanned the whole $CMD for `--cleanup=` / `commit.cleanup=`, and the heredoc BODY sits verbatim inside $CMD, so any conforming message that merely MENTIONED the token was refused, fell back to the opener line, and was blocked with CONVENTIONAL_COMMITS_VIOLATION. These are ordinary English in this repository, whose own hooks and docs discuss cleanup modes constantly. Reproduced against the real hook: `fix: document commit.cleanup=strip behavior` blocked, the same message without the token allowed (review of #3816, round 5 — BLOCKER). Scoping to $MSG_PREFIX alone, as prescribed, would have reopened the round-4 length-gate bypass the guard exists for: git accepts the flag on EITHER side of -m, and `git commit -m "<heredoc>" --cleanup=verbatim` is caught today only because the scan is command-wide. Measured, not assumed. The scan now covers MSG_PREFIX + MSG_SUFFIX — the whole command minus the one span that is message text — joined with a space so a token cannot be forged across the seam. Swept the guard class rather than the reported instance. The adjacency guard does not share the defect: an in-body `-m "foo"bar` is refused by the already-documented embedded-quote capture limit (any `"` in the subject line truncates the capture), and an in-body `-m ` without quotes resolves and is allowed. Deliberately untouched. Both directions pinned failing-first: the three false-positive rows red against the unscoped guard, and the trailing-flag row reds against prefix-only scoping. Each mutation was echoed back to prove it landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XogDtuuuGQEfsWaLSaZCLB * fix(#3802): read commit options the way bash hands them to git Round 6 reported the adjacency guard scanning all of $CMD for a glued `-m "..."`, so a glued -m belonging to a chained-after command refused a heredoc that was never truncated. Glue is a property of the ONE character following the matched span, so that character is now the whole window. Separators and redirections are excluded because bash does not concatenate across them: in `-m "msg"&& echo hi` the argument ends at the quote, so there is no truncated capture to defend against. An independent full-PR pass then found three accept-direction defects this PR had introduced in earlier rounds, each measured against a real commit by reading the raw commit object — `git log --pretty=%s` strips the trailing whitespace that makes the length wrong and hides it: --cle=verbatim git accepts any unambiguous prefix of a long option, so the mode was set by a token that is not the literal --cleanup. 75-byte subject recorded, 72 measured. -am 'WIP first' git reads this as -a -m, so the real subject is `WIP first` and the heredoc is only the second message. The scan looked for a standalone -m. --clean""up=verbatim bash removes quotes before git sees the -""m argument, so a spliced spelling is the same option and matched no literal. The two option-name scans now read their window with quote characters removed, which is what bash does to it, and the cleanup class covers git's abbreviations. The adjacency test deliberately keeps the raw text: it asks about a literal character position, not an option name. Narrowing the cleanup window to git's own command segment was tried and reverted. `;`, `&` and `|` end a command only outside quotes, and this is a substring scan, not a parse: an unconditional trim cut the window short on `--author "a&b"`, and a quote-aware trim still cut it on `--author a\&b`. Each hid a real trailing --cleanup=verbatim and accepted a 75-byte subject. The resulting false positive — a --cleanup carried by a chained command refuses the commit — is documented and pinned instead. Refusing a commit git would take is recoverable; accepting an over-long subject is not. Sixteen rows in tests/hooks-opt-in.test.cjs. Seven mutations, including both reverted narrowings, so no dead end can be reintroduced silently. * fix(#3802): close six accept-direction bypasses in the resolve guards Round 7's FIRST-MESSAGE GUARD Major does not reproduce. Measured against the real hook in a complete tree at the reviewed head: the classifier gate runs before any guard, so `git add -A && git commit …` (git->add stops on a non-commit subcommand) and `cd dir && git commit …` (the first executable is not git) exit 0 without a guard being evaluated. The control is the proof — a subject the bare form blocks with CONVENTIONAL_COMMITS_VIOLATION exits 0 in both chained forms, so the hook never validated them and cannot be over-blocking them. The guard is unchanged; scoping this scan to $MSG_PREFIX alone is what reopened the round-4 trailing-flag bypass. The class was real, though, one shape further out: `FOO=bar; git commit …` IS classified and then refused, because assignment detection is prefix-anchored and the tokenizer does not split operators. Pinned as a counterexample and disclosed rather than generalised away; narrowing it means changing isGitSubcommand, the shared git-commit detector every gating hook uses, and it fails closed. Six accept-direction bypasses are fixed. Each let the hook resolve and ALLOW a commit whose real subject the rules refuse; the three that turn on git's recorded subject were confirmed against the RAW COMMIT OBJECT, since `git log --pretty=%s` strips trailing whitespace and hid two of them: --cleanup=whitespace -m <72+spaces> --cleanup=verbatim git kept 75 bytes -mWIP -m <heredoc> git recorded `WIP` --mes=WIP -m <heredoc> git recorded `WIP` -\m WIP -m <heredoc> git recorded `WIP` git commit --amend --no-edit \n echo -m <heredoc> echo's argument read --squash=HEAD -m <heredoc> `squash! …` Causes: one BASH_REMATCH inspected only the FIRST cleanup directive while git applies the last, so multiplicity now refuses rather than guesses at an argument order a substring scan cannot recover; the option scan required a trailing space or `=`, missing attached values and long-option abbreviations; dequoting removed quotes but not the syntactic backslashes bash also removes; the separator scan omitted newline; and --squash/--fixup have git compose the subject, so the supplied message is not the subject at all. Every fix widens refusal, the direction this file documents as recoverable. The multiplicity count first broke the hook outright: the script runs under `set -euo pipefail` and grep exits 1 when it matches nothing, which is the common case, so every ordinary commit died at exit 1 with no verdict. Guarded, and only caught because the probe runs the real hook rather than the scan. Five new rows, all five proven red against the pre-fix hook, each carrying a non-vacuity assertion that the canonical single-`-m` heredoc still resolves. Changeset corrected on three counts: "all fail-closed" was wrong (persistent commit.cleanup fails OPEN, as do the -C/-c/-F/-t message sources), "global options are all walked through" was too broad, and the chained-before claim now states what is measured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018FUAVz49BghqxoJgwt7EW9 * fix(#3802): stop the separator and glue classes matching a literal backslash Round 8's Major, with two corrections to its account. `;`, `&` and `|` are metacharacters inside `[[ ]]`, so an inline bracket class must escape each one. POSIX bracket expressions have no escape mechanism of their own, so on bash 3.2 -- the system /bin/bash on macOS, already a supported target here per the `declare -A` ban in tests/install.test.cjs -- those backslashes reach the regex engine and add a literal `\` to the class. bash 4+ consumes them, which is why this is invisible on a modern bash. The hazard is specific to bracket expressions: `\(` outside one is made literal correctly on every version, and the subject validator and the `-m` capture classes were checked and are unaffected. The prescribed fix is not taken, because it does not parse. Inline `[;&|]` is a bash SYNTAX ERROR on 3.2 and on 5.3 alike -- the backslashes exist to get the metacharacters past the `[[ ]]` parser, so removing them leaves an unparseable script. Each class is held in a variable and expanded unquoted on the right of `=~` instead, which is a plain regex on both versions. One root cause, consequences in BOTH directions. The reported half is the separator scan over-blocking. The half not reported is the accept direction, and it is the more serious: the glue class is NEGATED, so on bash 3.2 a backslash-glued suffix fell inside the exclusion and the hook RESOLVED a heredoc it should have declined -- measured exit 0 on 3.2 against the unfixed hook, exit 2 everywhere else, with a letter-glued control refused in all four cells. The reported repro is not actually fixed by this, and the changeset says so. A `\`-newline line continuation carries a literal newline, which the round-7 separator guard refuses on every bash, so that shape stays blocked with or without this change. Narrowing the newline guard is not attempted: telling a continuation from a separator by substring scan is the class that was tried twice in earlier rounds and reverted both times, and an escaped backslash sitting immediately before a real newline is indistinguishable from a continuation. Disclosed as a known fail-closed limit instead. Every new row runs under each bash on the machine. Against the unfixed hook both bash 3.2 rows go red while all four bash 5.3 rows stay green -- written the ordinary way these rows would run under PATH bash, pass against the broken hook, and prove nothing. Two non-vacuity controls per interpreter prove the validator is reached rather than passing everything. All 8 rows of the established differential harness are byte-identical before and after on both versions: no regression, no new refusal. * fix(#3802): remove the $ of a dollar-quote from the option-name scans Independent round-8 review, accept direction. The option-name windows are dequoted so they match "the command as bash hands it to git" -- round 6 removed quote characters, round 7 removed syntactic backslashes. Both passes missed that bash has two further quoting forms whose introducer is a `$`: `$'...'` and `$"..."`. Removing the quote characters alone left that `$` stranded INSIDE the option name, so `-$"m"` dequoted to `-$m` and matched no literal, while bash passed a real `-m` to git. Measured on bash 3.2.57 and 5.3.15 against a real repository: the hook allowed git commit --allow-empty -$"m" WIP -m "$(cat <<'EOF' fix: a perfectly ordinary conforming subject EOF )" with exit 0, and `git cat-file -p HEAD` recorded the subject `WIP`. The comparison that establishes this is HEAD-internal, not a differential: the same command spelled `-m WIP` is refused (exit 2). The merge-base refuses EVERY heredoc form, including a perfectly conforming one, so its exit 2 on this input says nothing about whether any guard fired -- it is the absence of the feature, not a working check. The same miss covered `$'m'`, spliced `--message`, `--cleanup`, `--squash` and `--fixup`. An option NAME finished by a command substitution -- `--clean$(printf up)=verbatim` -- is a different problem and gets its own guard: bash runs a program to complete the name, so the argv git receives is not derivable from this string at all, and resolution is refused rather than guessed. The guard is scoped to the NAME: the class is a `-`-leading token whose characters up to the substitution contain no `=`. A substitution supplying a VALUE -- the ordinary `--author="$(git config user.name)"`, spaced or glued, in either window -- is untouched and still resolves, pinned in both directions. It is a SHAPE, not a segmentation of the command line; segmenting was tried twice in earlier rounds and reverted both times, and that reasoning stands. Both new rows fail against the unfixed tree with their own assertions, proven in a complete worktree at the previous head rather than a hook copied out of its tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy * fix(#3802): recognise a canonical cat, not any absolute path ending in /cat Independent round-8 review, accept direction. Round 4 restricted heredoc-opener recognition to an absolute path, after a relative `./cat` was measured being trusted to echo its stdin. It stopped at "absolute", so any absolute path ENDING in `/cat` was still trusted -- the same claim the round-4 reasoning had rejected one spelling earlier. Measured on bash 3.2.57 and 5.3.15 against a real commit: with an executable at `/.../fake-cat/cat` printing `WIP injected`, the hook validated the conforming heredoc body and allowed the commit (exit 0) while `git cat-file -p HEAD` recorded the subject `WIP injected`. The same command through `./cat` was already refused, which is the control that shows this is the round-4 class one spelling out rather than a new one. Recognition is now the canonical system locations -- bare `cat`, `/bin/cat`, `/usr/bin/cat` -- which is the only identity claim a string can support. `/usr/local/bin` is deliberately excluded: it is user-writable on ordinary machines, which is the plantable case this guard exists for. Anything else falls back to the opener line and the format gate: fail closed, exactly the pre-fix behaviour for the form. The pre-existing residual is unchanged and still documented: a bare `cat` shadowed earlier on PATH is indistinguishable here, and is not a meaningful boundary -- anyone able to plant an executable on PATH can run `git commit` directly. This hook stays an authoring guard, not a security control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy * fix(#3802): an option name carrying a shell expansion is unresolvable Independent review, round 9, accept direction. Four more spellings, and a change of strategy that is the actual point of this commit. Rounds 6, 7 and 8 each tried to EMULATE what bash does to an argument before git sees it -- round 6 removed quote characters, round 7 syntactic backslashes, round 8 the `$` that introduces a dollar-quote -- and each round review found another transform that had been missed. Round 9 found four more. All measured on bash 3.2.57 and 5.3.15 against a real repository, each with the plain spelling of the same command as its control (refused, exit 2) and `git cat-file -p HEAD` for the subject git actually recorded: -$'\155' WIP hook 0, real subject `WIP` ANSI-C octal -> m -$'\x6d' WIP hook 0, real subject `WIP` ANSI-C hex -> m -`printf m` WIP hook 0, real subject `WIP` backtick substitution x= … -${x}m WIP hook 0, real subject `WIP` parameter expansion -? WIP hook 0, real subject `WIP` pathname expansion and the same class through the cleanup guard, where git recorded a 75-character subject the length gate had measured as 72: --cle$'\141'nup=verbatim, --clean`printf up`=verbatim, --cle?nup=verbatim The last two settle it. An option name finished by a PARAMETER expansion depends on a variable's value at run time; one finished by a PATHNAME expansion depends on the contents of the working directory. Neither is derivable from the command string at any level of effort, so emulation cannot be completed -- not "has not been completed yet". A fifth patch in that direction would have the same shape as the previous four. The rule is therefore no longer "normalise it and match the literal". It is: an option NAME carrying a shell expansion or quoting construct is UNRESOLVABLE, and unresolvable refuses. One rule covers every spelling above and every spelling nobody has thought of yet, in the fail-closed direction. The dequoting passes are kept rather than replaced: they still normalise the deterministic removals, so the guards RECOGNISE `--clean""up=` and `-\m` as the options they are instead of merely refusing them, which keeps the existing rows meaningful. Scope is unchanged and still pinned in both directions: the class is a `-`-leading token whose characters up to the construct contain no `=`, so a construct supplying a VALUE -- `--author="$(git config user.name)"`, the backtick spelling, `--date="${NOW}"`, a glob character inside an author string, a pathspec after `--` -- still resolves. Nine such forms are asserted to pass beside the seven that must refuse. The class is bracket-only and holds no backslash, per round 8: a POSIX bracket expression has no escape mechanism, and a backslash written inside one becomes a literal member on bash 3.2. The new rows fail against the previous head with their own assertion message, in a complete worktree with the lib built, not a copied hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy * docs(#3802): disclose and pin the two spellings the round-9 class over-blocks A scoped review of the round-9 class asked one question -- does it refuse a conforming heredoc commit that the previous head accepted -- and found two spellings that it does. Both measured on bash 3.2.57 and 5.3.15, previous head 518d97b64 exit 0, current head exit 2: git commit -S$SIGNING_KEY -m <conforming heredoc> git commit -m <conforming heredoc> -- -*.txt Disclosed and pinned rather than narrowed, for two reasons. Narrowing is not available cheaply. Dropping the bare `$` member reopens `-$xm`: with `xm=m` bash hands git a real `-m`, which is the parameter expansion bypass the round-9 commit exists to close. Skipping tokens after `--` means deciding where git's options end from a substring scan, which is the class this file has already reverted twice for opening accept-direction holes -- a `--` inside a quoted value (`--author "a -- b"`) would truncate the window and hide a real trailing directive. And the limits are narrower than they look, because in both cases the spelling a developer actually reaches for still resolves: -S "$KEY" and --gpg-sign="$KEY" resolve '-*.txt', "-*.txt", ':(exclude)-*.txt' resolve The pathspec one is worth stating precisely: a glob only reaches git AS a pathspec when it is quoted, because an unquoted one is expanded by the shell before git is executed. So the refused spelling is not passing a glob to git at all, and the spellings that do are unaffected. Refusing a commit git would take is the recoverable direction; accepting a non-conforming subject is not. That is the trade this file already makes everywhere else, and it is made explicitly here. Nine rows pin the working spellings beside the three that refuse, so a later narrowing cannot silently drop the cases that must keep working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy * fix(#3802): join backslash-newline continuations before the resolve guards Round 9's Major, with a correction to its diagnosis. The cited bracket classes at :223 and :260 no longer exist -- round 8 moved both into SEP_CLASS and GLUE_CLASS, and a lone backslash before -m resolves (exit 0) at the reviewed head on both bash 3.2.57 and 5.3.15. What refuses the repro is the NEWLINE a `\`-continuation carries: round 7's separator guard reads any newline in a window as a command boundary, and `git commit \` newline ` -m "$(cat <<'EOF' …` was refused for that reason. Round 8 disclosed it as a fail-closed limit; round 9 calls the idiom common and the limit a Major, and it is fixed here. It was left as a limit because "is this newline a continuation" looked like the segmentation question this file has reverted twice. It is not: bash's rule is local and character-level. A newline preceded by an ODD run of backslashes is a continuation and bash removes both; an EVEN run (`\\` then newline) is a literal backslash followed by a real newline, which IS a separator. Both scan windows are joined that way immediately after they are cut from the command and before any dequote copy is derived, in three bash-3.2-safe parameter expansions: every `\\` pair is parked on \x01, any backslash-newline that remains is a lone one and is removed, then the pairs are restored. Measured on both bashes, both directions: git commit \<nl> -m <heredoc> 2 -> 0 the fix git commit \\<nl> -m <heredoc> 2 -> 2 literal \ + real separator git commit<nl> -m <heredoc> 2 -> 2 bare newline -m <heredoc>\<nl>suffix 2 -> 2 bash glues it; the glue guard sees it glued git commit … \<nl> --allow-empty<nl>echo -m … 2 -> 2 the REAL newline still separates The prescribed `[\;&|]` is not taken: a backslash written inside a bracket expression becomes a literal member on bash 3.2, which is the round-8 defect from the other side. Rows run under each bash on the machine. The fix row fails against the previous head in a complete worktree with the lib built; the four control rows were measured against that same head and were already refused, so they pin existing behaviour rather than the change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TadqrpTE2m6gCB7CaNNLcy --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
c27da5e993 |
fix(#4079): forbid ScheduleWakeup wake-tool escape hatch at background-wait sites (#4299)
* test(#4079): guard every background-wait site against ScheduleWakeup wake-tool escape hatch Content-contract tests over plan-phase.md, stall-detection-helpers.md, and manager.md: every wait-instruction site must forbid ScheduleWakeup (the host /loop tool whose partial-args call surfaces the red validation error in #4079), while the sanctioned wait mechanisms stay byte-preserved. * fix(#4079): forbid ScheduleWakeup wake-tool escape hatch at every background-wait site The orchestrator could literalize 'I'll wait' by calling the host's ScheduleWakeup tool (/loop pacing surface) with partial arguments while a background subagent was in flight, surfacing the red validation error '`prompt` is required when `stop` is not true.' GSD never sanctioned a wake call; now every wait-instruction site says so explicitly: the two synchronous stop-and-wait ORCHESTRATOR RULEs in plan-phase.md, the stall-detection-helpers.md fragment (loaded before every gsd_stall_watch wait), and both manager.md background-dispatch rules. The sanctioned wait mechanisms (blocking Agent() return, gsd_stall_watch polling, dashboard loop) are unchanged. The regression test uses the shared readFileNormalized helper (code-review finding). Emitted-Drift-Ack-Growth: plan-phase.md — #4079 guard sentence at the two stop-and-wait rules (+396 B, within the phase6 shrink-only line) Emitted-Drift-Ack-Growth: manager.md — #4079 guard sentence at both background-dispatch rules * chore(#4079): add changeset fragment (pr placeholder, backfilled after PR open) * chore(#4079): backfill PR number in changeset --------- Co-authored-by: sim <sim@local> |
||
|
|
02ad0b91f3 |
fix(#4078): phase.complete next-phase cascade reads dash-grammar checkbox rows (#4301)
* test(#4078): phase.complete mixed-grammar roadmap picks lowest outstanding phase, not positional-last * fix(#4078): accept dash-grammar checkbox rows in phase.complete next-phase cascade Stage 2 (roadmap identity scan) and stage 3 (#2028 lowest-outstanding override) required a colon separator after the phase number, while the canonical phase lookup has accepted the bullet-house dash grammar (- [ ] **Phase N — Name**, #2199) for years. On a mixed-grammar roadmap the only parseable row above N was a later phase.add-ingested colon-form phase - positionally last - and it won the numeric-minimum vote it should never have been alone in: completing Phase 1 of 18 selected Phase 18 and skipped phases 2-17 (#4078). The checkbox branches now accept the #2199 separator class (em/en-dash, hyphen, colon); heading branches stay colon-only, mirroring findRoadmapPhaseInContent exactly. * test(#4078): align regression fixtures with slug name + checked-box semantics * fix(#4078): drop unnecessary type assertion flagged by eslint * chore(#4078): add changeset fragment * chore(#4078): backfill PR number in changeset --------- Co-authored-by: sim <sim@local> |
||
|
|
30c40e0fe2 |
fix(#4302): bound restatement detector's deferral window structurally, not by a flat char count (#4303)
* test(#4302): add failing regression test for deferral-window paragraph leak RED: hasNearbyDeferralMarker's 500-char trailing window has no structural boundary, so a compact citation-free restatement immediately followed by an unrelated section that cites tdd.md borrows that neighbor's citation and evades restatesCycleStructurally(). Reproduces the exact shape confirmed via mutation testing against the real agents/gsd-executor.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4302): bound the deferral window at the next heading, not a flat char count hasNearbyDeferralMarker's 500-char trailing window had no structural boundary, so a compact citation-free restatement immediately followed by an unrelated section that cites tdd.md borrowed that neighbor's citation and evaded restatesCycleStructurally() (confirmed on the real gsd-executor.md, whose next section after the cycle pointer has its own independent citation 82 chars past the REFACTOR anchor). First attempt bounded at the next blank line instead, and was rejected after it broke a real case caught by direct execution before committing: execute-plan.md's FIRST RED/GREEN/REFACTOR occurrence is a numbered list's own intro sentence, separated by a genuine blank line from the list item that actually carries the citation — a blank line is not reliably "still the same statement" once list structure is involved. Bounds at the next markdown heading (`\n#`) instead, capped at 500 chars as before when no heading appears. Verified against both real files, the new regression fixture, and every pre-existing #4268 fixture/property test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4302): close a narrower same-section evasion found by orthogonal review The heading-only fix left a narrower gap: a citation-free restatement followed by a blank line and an unrelated PROSE paragraph (no heading) that cites tdd.md for an unrelated reason still evaded detection — confirmed by the reviewer via execution. Generalizes the boundary rule: any blank line whose following line is NOT a markdown list continuation is now also a boundary (in addition to the existing heading boundary), reconciling with the earlier-rejected flat blank-line bound by adding the list-continuation exception that execute-plan.md's real shape (a numbered list's intro sentence, then a blank line, then the list item carrying the citation) needs. Verified against both real files, all three restatement fixtures (heading-bounded, prose-bounded, list-continuation negative-space), every pre-existing #4268 fixture, and the fast-check property test (200 runs). Also restores an honest, updated limitation disclosure describing the one narrower residual case not resolved by this design (a citation reachable via exactly one list-item hop from an unrelated list) — matching this repo's disclosed-not-silently-deferred practice from the original #4268 PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
0fca71eaae |
enhance(#2529): cover every workflow with response-language directives + CI lint (#2558)
* enhance(#2529): cover every workflow with response-language directives + CI lint Every workflow now carries response-language coverage in one of three forms, and a CI lint keeps it that way. - 43 workflows load the new shared reference, `gsd-core/references/response-language-directive.md`, by eager `@`-import. - Lazy-loaded modes/steps/templates, which cannot rely on an eager import, carry an exact inline directive; 35 such paths are pinned by exact path. - Fragments dispatched by a covered parent inherit coverage, proven per file rather than granted per directory. The 45 workflows whose directive covered only "questions, prompts, and explanations" now name inter-tool narration, which is the defect #2529 reports: the running commentary between tool calls stayed English while the answers around it were translated. `scripts/lint-response-language-coverage.cjs` enforces it and fails closed on three independent discovery failures (unreadable catalog, empty catalog, unfollowed symlink). It resolves which reference a workflow imports and applies the same four-predicate test to that file, so a weakened shared reference uncovers its importers instead of passing silently, reported once as a systemic failure rather than 43 times. The walk follows symlinked subtrees with a realpath cycle bound. `lint:ci` invokes it by name. REQ-LANG-03 and REQ-LANG-04 state the contract in docs/FEATURES.md; REQ-LANG-04 names the two forms that satisfy it ("narration", "between tool calls") rather than enumerating class members an author cannot use verbatim, and a test pins that text to what the matcher accepts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2529): register the coverage test in the docs-guard lane `107eb8c1` (#3787) landed the docs-guard lane on `next` while this PR was open: a test that reads a `docs/` path must be named in `scripts/docs-guard-registry.cjs` or carry a `docs-guard-exempt` marker, so the guards that read a doc run on the PR that changes it. `tests/response-language-coverage.test.cjs` reads `docs/FEATURES.md` -- it extracts every form REQ-LANG-04 offers an author and runs each through the matcher that enforces it. Registration, not exemption, is the correct side of that gate: a reword of the requirement with no code change is precisely the diff this test exists to catch, and it is the diff the lane would otherwise skip. Registered narrowly (`['docs/FEATURES.md']`) rather than with the `'*'` sentinel, so an unrelated docs change does not pull this test into the lane. Verified: lint-docs-guard-registration 0 violations, tests/ci-docs-guard-registry.test.cjs 51/51, lint:ci exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2529): consolidate this PR's emitted-growth acks into its own fragment This PR ripples emitted bytes across 85 workflow paths. Until now each ripple was acknowledged by appending to whichever live fragment owned that path, because two ack sources may never name the same path. `a84f7563` (#3078) swept all 45 fully-spent fragments off `next`. Forty-two of the paths this PR grows were owned by swept fragments, so those keys are now unowned and this PR's own fragment declares them directly -- one path, one source, and no dependence on a fragment that no longer exists. Each adopted entry keeps its measurement and records where it came from. Two paths are handled differently, because the sweep did not free them: - `review.md` is now owned by `3034-parallel-reviewer-lanes.json`, which landed on `next` after the sweep. Its entry is live, so the old route still applies: this PR's note is appended to that entry rather than declared a second time. - `plan-review-convergence.md` keeps the arrangement made in round 24. Result: 3 fragments in the directory, 85 keys in this PR's own, 0 cross-source duplicates. `lint-emitted-drift-ack` exit 0, `tests/emitted-attribution.test.cjs` green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2529): move REQ-LANG-03/04 into the feature fragment that now generates them `36375513` (#3845) made docs/FEATURES.md a generated projection of docs/features/*.md, marked "do not edit by hand". This PR wrote REQ-LANG-03 and REQ-LANG-04 straight into the generated file, so the rebase left the requirement present in the projection and absent from its source -- the next regeneration would have deleted both, and `tests/features-index-gate.test.cjs` was already red on the mismatch. Both requirements now live in docs/features/response-language-config.md alongside REQ-LANG-01 and -02. Regenerating produces a docs/FEATURES.md that is byte-identical to the committed one, so the text this PR shipped is unchanged -- only its source of truth moved to where #3840 put it. The docs-guard registration is widened to name the fragment as well as the projection. The requirement's source is the fragment now, and an edit there that skips regeneration would otherwise reach this guard through neither path. Verified: features-index-gate 68/68, lint-docs-guard-registration 0 violations, ci-docs-guard-registry + response-language-coverage 142/142. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2529): hand the plan-phase ack back to its new live owner `c933184b` (#3825) landed `3172-stated-failing-direction.json` on `next` after fragment had adopted that path when the sweep left it unowned, so the merged tree named it from two sources -- a hard failure in `scripts/lint-emitted-drift-ack.cjs`. The path has a live owner again, so the append route applies: this PR's note joins that entry, carrying its own measurement, and the key is dropped from this PR's fragment (84 keys left, the others untouched). The provenance sentence written for the swept-fragment case is removed rather than reused -- this path was never orphaned, so that account of it would be false. Same shape as `review.md` and `plan-review-convergence.md`: ownership is a property of the merged tree, and a fragment landing upstream after a push can reclaim a key no local check would have flagged. Verified: lint-emitted-drift-ack exit 0, lint:ci exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2529): state byte figures that are true against the tree The reference claimed `execute-phase.md` has "2 bytes of headroom under the ceiling named below". That was true when the sentence was written -- the file sat at 93398 against the 93400 comfort assert -- and upstream has since shrunk it to 91493 against a 93600 hard ceiling, so the figure now understates the headroom by three orders of magnitude. The rationale the sentence supports does not depend on the number, so the number is gone rather than refreshed: a restated figure would go stale again on the next upstream edit, and nothing parses it. Audited every other numeric claim this PR ships the same way, mechanically against the merge base: all 82 FILE-delta claims in the ack fragment match the real per-file delta exactly, and the 1,629-byte reference and 63-byte import line check out. One class was imprecise: the 41 notes for workflows whose inline directive was rewritten in place quoted the conversion counterfactual as "+1,692 bytes more loaded context", which is the reference form's whole weight, not the increase over the inline directive those files already carry. Each now names both quantities and the net (+1,605 / +1,609 / +1,584). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2529): one rule for pinned vs inherited coverage, and the docs to pick it Review measured that 14 of the 35 pinned fragments would pass by inheritance anyway, and that the PR asserted both readings at once: inheritance is real coverage (so those 14 pins are noise) or it is not (so 30 inheriting fragments are green-but-uncovered). Only one can be true. Inheritance is real: the predicate proves it per file -- the parent must dispatch this exact path from a read/execute context AND be covered itself -- so the parent's directive is in the loaded context by the time the fragment is read. The 14 pins are therefore removed along with the directive lines they pinned, and those files inherit like the 30 structurally identical ones. The rule is now stated where the set is declared, and enforced from the other side by a test: no member of the pinned set may be one that would have inherited. That is what decides the form for the next fragment. - pinned set 35 -> 21; 14 workflow files revert to their base content - `findViolations` no longer returns early on a pinned path: a file that becomes eagerly loaded and takes the shared reference is strictly better off, and the gate must not red that. The reference form is admitted because its own wording is validated in turn; an arbitrary reworded inline line still fails. - the reference-directive cache is keyed by size and mtime, not by path alone, so a rewritten reference re-asked in one process no longer returns the stale verdict - `carriesInlineDirective` names its negation blindness: four independent hits read vocabulary, not polarity - the real-tree scan asserts each source produced files instead of `> 152`, a constant that read as the workflow count and would have passed a scan that lost one of its two directories - the pinned-set size assertion goes the same way: the size follows from the rule, so the rule is what the suite asserts Docs, for the gate that now governs every future workflow: - `docs/contributing/response-language-coverage.md` -- why the narration class is the discriminator, the four coverage forms, the decision order that picks one, the pinned line, and what each failure message means - a row in CONTRIBUTING.md's CI checks table, matching the docs-guard row - `docs/CONFIGURATION.md` points at it from the `response_language` entry Also: the changeset said 45 reworded workflows; it is 44 (42 @-reference + 21 pinned + 44 rewritten = 107 touched). That text ships to CHANGELOG.md. `3707-parse-gap-reporting.json` landed on `next` reclaiming `audit-uat.md` and `progress.md`; both handed back by the append route, leaving 82 keys here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2529): correct the reference-taker count, 43 -> 42 The ack notes said the import line is byte-identical "in each of the 43 workflows that take the reference" and that the alternative would be "43 inline copies". The shared reference has 42 importers; the 43rd file in review's table is `execute-phase.md`, which imports the OTHER reference. Corrected in all 41 notes that carry the sentence, across this PR's fragment and the two it appends to. Found by re-running the numeric audit from the previous round after the rebase, which also re-verified all 84 FILE-delta claims against the new base -- all exact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2529): migrate the emitted-drift ack from a fragment to commit trailers ADR-3942 (#3954) landed while this PR was open: the acknowledgment is now a commit trailer and tests/emitted-drift-acks/ no longer exists. The fragment is deleted and each key it declared becomes one trailer, reasons unchanged. The four keys this PR had handed to 3034-*, 3172-* and 3707-* under the one-source rule come home here. That rule was the whole reason for the hand-backs, and the trailer model has no shared namespace to collide in -- five of this PR's rounds were spent on exactly those collisions. Emitted-Drift-Ack-Growth: add-backlog.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Emitted-Drift-Ack-Growth: add-phase.md — #2529 — RESTATED in round 10, superseding this PR's earlier "+63 bytes, prose only" wording, which reported a file delta as if it were the whole cost. The workflow gains the shared response-language directive as a single `@`-reference line. FILE delta: +63 bytes, byte-identical in each of the 42 workflows that take the reference. LOADED-CONTEXT delta: +1,692 bytes per workflow — the 63-byte import line plus the 1,629 bytes of `gsd-core/references/response-language-directive.md`, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"). The repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so they see 63 of those 1,692 bytes; the remaining 1,629 are declared here because no gate reads them. The eager import is accepted on its merits, not hidden: 42 inline copies would be 43 places for the wording to drift, and the reference is the one place it is maintained. Prose only: no step, gate, tool invocation, or subagent dispatch shape changed. Emitted-Drift-Ack-Growth: add-tests.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Emitted-Drift-Ack-Growth: add-todo.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Emitted-Drift-Ack-Growth: ai-integration-phase.md — #2529 MAJOR 1 (round 10): the workflow's pre-existing inline response-language directive is rewritten IN PLACE so the sentence names inter-tool NARRATION explicitly — narration between tool calls, status updates, progress notes, findings — instead of only "questions, prompts, and explanations". That older wording is the defect #2529 reports (it leaves the running commentary between tool calls in English while the answers around it are translated), and `scripts/lint-response-language-coverage.cjs` had been certifying it as coverage, so the gate legitimised the bug. +87 bytes, prose only: no step, gate, tool invocation, or subagent dispatch shape changed. FILE delta and LOADED-CONTEXT delta are both +87 here, and that identity is the point — the directive was deliberately NOT converted to an `@`-reference, because an `@`-import in this repo is EAGER (ADR-1610 Decision point 4; docs/ARCHITECTURE.md: moving prose into a file that is still eagerly `@`-imported "shrinks the measured file without shrinking loaded context"), so the conversion would have bought a smaller measured file at a cost of 1,692 bytes of loaded context per workflow — the 63-byte import line plus the 1,629-byte reference — against the 87 bytes this inline directive costs, a net +1,605. Stated plainly because the gates cannot state it: the repo's size gates — the tier hard caps in `tests/workflow-size-budget.test.cjs` and this size ratchet — measure the FILE, not the transitive inline, so an `@`-reference conversion would have READ as a smaller change to every gate in the repo while costing 1,605 bytes more loaded context per invocation. Re-homed in round 16: the fragment that carried this sentence (`3423-required-reading.json`) was retired on `next` by |
||
|
|
04ac8723b9 |
fix(#4024): flag quantitative-criteria trap shapes in verify plan-structure (#4288)
* test(#4024): pin quantitative-criteria trap shapes for verify plan-structure Rows 1-3 and 20 of the #4024 test matrix reproduce the issue's shapes (exact grep -c counts, bulk all-N observed-failing claims) and are expected to FAIL against unmodified next: nothing judges these shapes today. Corrected-arm rows pin that each rule is silent on its own fix. * fix(#4024): flag quantitative-criteria trap shapes in verify plan-structure Add scanQuantitativeCriteria, the third plan-discipline scanner in the cmdVerifyPlanStructure family (#429, #968). It judges criteria text in <acceptance_criteria>/<automated>/<verify> blocks against a six-rule ban list of shapes proven to be traps at HEAD: exact grep -c counts (R1), bulk all-N observed-failing claims (R2), unquoted $VAR in command position (R3), fallible git swallowed by a non-final pipeline stage (R4, warn), wc output compared by string equality (R5), and relative HEAD~N git anchors (R6; bare git diff warns). Legitimate exit: <!-- plan-criteria-allow: R# - reason -->. Pure text scan, fail open. * test(#4024): bind node:test before hook locally below the fold-point * fix(#4024): R3 command-position anchor tolerates list bullets and inline-code backticks * test(#4024): bind VERIFY_CJS locally in the unit block instead of relying on fold scope * fix(#4024): R6 argument span ends at inline-code backtick or redirection * fix(#4024): satisfy no-adhoc-markdown lint on the R4 stage-boundary regex * chore(#4024): add changeset fragment * chore(#4024): backfill PR number in changeset fragment * fix(#4024): escape backticks in regex literals so drift-lint tokenizers keep function attribution --------- Co-authored-by: sim <sim@local> |
||
|
|
1ec4b38bd3 |
test(#4298): add tdd-walk.cjs end-to-end sniff-test harness for TDD dispatch (#4300)
* test(#4298): add tdd-walk.cjs end-to-end sniff-test harness for TDD dispatch Epic #4272 Phase 5's own checklist named this deliverable ("the same class of coverage loop-walk.cjs gives the loop") separately from #4268. Adds tests/qa/tdd-walk.cjs, extracting and REALLY EXECUTING (via a real `bash -c` subprocess against a real temp fixture project) the shipped bash resolution snippets from both TDD dispatch backends — never reimplementing or grep-simulating the predicate. Proves, by execution rather than text-shape assertion: the CLI predicate and both backends agree for a type: tdd plan and a plain plan; the worktree backend's fail-closed guard genuinely halts (non-zero exit, FATAL stderr) on a missing plan file; and the tdd.md embed ternary's condition tracks the real resolved value (#3800). This is exactly the class of proof #4264/#4265 (unassigned/divergent predicate) and #4268 (static-shape checks can't see backend divergence) could not provide. Extraction uses indexOf/slice on fenced-code markers only, never a backtracking regex over whole-file text (per the #4228 incident this repo's tests already document). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(#4298): scrub ambient env, tighten fail-closed assertion, fix comment Standards+Spec review found: (1) executeBackendScript spread raw process.env unfiltered into the spawned bash subprocess, unlike tests/helpers.cjs's runGsdTools, which deliberately scrubs SESSION_IDENTITY_ENV_KEYS + config-location env vars before spawning (#2665) — an ambient developer/CI override could silently change what phase.tdd-applicable resolves to in a way a gsd-test bench container won't reproduce; (2) the row-5 fail-closed test asserted only `stderr.includes('FATAL')`, which would also pass if the file's unrelated ISOLATION fail-closed guard fired instead of the TDD one; (3) a docstring called the worktree backend's first fenced block a "shim preamble" when it's actually the whole ISOLATION-resolution block. Fixes: spread the exported TEST_ENV_BASE (every scrub-listed key set to '') before the two intentional RUNTIME_DIR/GSD_TEST_MODE overrides; assert the exact TDD-applicability FATAL text; correct the docstring. Re-verified by direct execution against real fixtures — all three precedence-tier cases and the fail-closed case behave identically to before the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
747a3730d4 |
fix(#4268): harden tdd-single-statement.test.cjs against reworded restatements and backend divergence (#4297)
* test(#4268): harden reworded-restatement and backend-predicate-divergence detection tests/tdd-single-statement.test.cjs's restatesCycle() keyed on the exact literal `commit: `test({phase}-{plan})`` substring, so a reworded restatement of the RED/GREEN/REFACTOR procedure shipped green. Adds restatesCycleStructurally(), a structural (span + list-marker) detector that stays linear-scan (per the #4228 catastrophic-backtracking incident this must not reintroduce) and is proven, empirically, to flag a paraphrased multi-step fixture while not flagging the real compact citations in execute-plan.md and gsd-executor.md (#4267's legitimate pointers). tests/tdd-backend-wiring.test.cjs never compared the two dispatch backends' `gsd_run query phase.tdd-applicable` calls against each other, so a one-word divergence between them (e.g. a changed --pick flag in only one backend) shipped green. Adds a byte-identity assertion on the command-substitution content (normalized for the two backends' differing variable-name prefixes), proven to have teeth via a RED-first mutation check before asserting it against the real files. The third gap in #4268 (nothing proves TDD_APPLICABLE has a real definition) was already covered by this file's existing assertTddApplicableIsComputed (epic #4272 Phase 2, #4266) — verified by inspection, no new test needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(#4268): redesign restatement detector around deferral, not length Standards+Spec review of the prior commit proved by execution that a compact, no-list-marker restatement (under the 200-char span threshold) sails past the span/list-marker-only signal. Redesigns the primary check: the actual invariant is deferral, not length — a legitimate RED/GREEN/ REFACTOR mention always names tdd.md as the authority nearby, a restatement never does. Flags when no tdd.md/canonical reference appears within a 500-char trailing window past the cycle mention, regardless of length or list-marker shape; keeps span>200 and three-distinct-list-marker-lines as secondary defense-in-depth OR-conditions. Verified independently against both real files (execute-plan.md span=10, gsd-executor.md span=14, both with a nearby deferral marker at +228/+82 chars) — no false positive, and the reviewer's exact gap class (a 189-char no-citation paraphrase) is now flagged. Also fixes: boundary coverage at the span threshold (199/200/201, isolated via a factored-out measureCycleSpan() helper), a fast-check property test proving the fix holds for arbitrary filler text, and a fragile line-match in tdd-backend-wiring.test.cjs that happened to work only because a FATAL echo message containing the same substring came later in document order than the real assignment line. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
5214ad5802 |
fix(#4267): correct tdd.md pointer citations; fix(#4269): collapse plan-level gate-rule duplication (#4295)
* fix(#4267): correct tdd.md pointer citations; fix(#4269): collapse plan-level gate-rule duplication execute-plan.md and gsd-executor.md each cited "Red-Green-Refactor Cycle" for three facts (commit-scope contract, fail-fast rule, error handling), but only the commit-scope contract lives there. Fail-fast is in tdd.md's "Fail-Fast Rules" subsection (under "Gate Enforcement Rules") and error handling is in tdd.md's "Error Handling" section — cite each correctly. gsd-executor.md's "Plan-Level TDD Gate Enforcement" section also fully restated the gate-sequence rules tdd.md's "Gate Enforcement Rules" already owns (and covers more thoroughly, including the actual git-log validation script). Collapse it to a short pointer, matching the treatment already used by the cycle-steps pointer immediately above it. Adds tests/tdd-reference-correctness.test.cjs asserting the pointer text cites the correct section names, that those sections actually carry the guidance, and that the old gate-sequence restatement is gone from gsd-executor.md. Closes #4267 Closes #4269 * docs(#4267): add changeset for tdd.md pointer correctness fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#4267): acknowledge execute-plan.md growth execute-plan.md grew 95 bytes (39766 -> 39861) from the corrected three-section citation in the #3990/#4267 cycle-steps pointer. Emitted-Drift-Ack-Growth: execute-plan.md — net +95 bytes from citing the "Fail-Fast Rules" and "Error Handling" sections by name instead of a single mis-scoped section (#4267). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4267): update PROSE_ALLOWLIST line numbers shifted by the pointer-citation edit This branch's edits to agents/gsd-executor.md and gsd-core/workflows/execute-plan.md shifted line numbers, leaving tests/no-bare-gsd-tools-command-position.test.cjs's PROSE_ALLOWLIST pointing at stale lines. Update both entries to their new correct lines (811 and 419 respectively) without changing the underlying prose. * fix(#4267): restore INVALID_RED citation, fix allowlist line shift after #3770 rebase The rebase onto next picked up #3770's already-merged fail-fast update to gsd-executor.md's plan-level gate section, which this branch's own commit collapses into a pointer. The conflict resolution kept the pointer but dropped the literal "INVALID_RED" term that tests/tdd-red-evidence.test.cjs requires gsd-executor.md to name — restored it. Also updates no-bare-gsd-tools-command-position.test.cjs's PROSE_ALLOWLIST line number for gsd-executor.md, shifted again by the rebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(#4267): fix non-matching regression-guard regex in tdd-reference-correctness The fail-fast regression guard asserted gsd-executor.md no longer contains "If a test passes unexpectedly during the RED phase" — but the actual old prose (removed by this branch's pointer-collapse) read "If a test passes unexpectedly during RED, STOP". The regex never matched the real old text, so the assertion would have passed even against the unmodified pre-change file. Caught by an isolated orthogonal review pass. Fixed to match the actual removed wording, and confirmed (via a direct grep) it is genuinely absent from the current file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4267): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
2e056488d9 |
fix(#4067): derive advance-plan phase-complete from disk, not the plan counter (#4292)
* test(#4067): pin advance-plan phase-complete guard matrix (RED) Five-case matrix: decline on unsummarized plans (regression), fire on fully-summarized phase, fail-open on unresolvable phase dir, idempotent decline, normal advance untouched. * fix(#4067): derive advance-plan phase-complete from disk, not the plan counter The phase-complete branch of state.advance-plan was decided purely by STATE.md's scalar plan counter (currentPlan >= totalPlans). A stale counter carried into a newly planned phase, or a counter raced by wave-parallel executors, let 'Phase complete — ready for verification' land while sibling plans were still executing. cmdStateAdvancePlan now re-decides that branch from disk before the write: every plan in the Current Position phase's directory must have a SUMMARY.md (scanPhasePlans single owner, the same source state.update-progress recalculates from). Outstanding plans decline the entire write byte-identically (idempotent, concurrency-safe, counter stays display-only); an unavailable disk answer fails open to the counter-derived decision. * fix(#4067): review round 1 — route phase-dir lookup through listMilestonePhaseDirs #3185 drift guard: no hand-rolled phases-dir readdirSync. Windowed (current-milestone) lookup first so an archived milestone's stale dir cannot shadow the live one; unscoped retry when the window cannot answer. Also restore the transform's undefined-data error semantics and extract scanOutstanding. * chore(#4067): add changeset fragment * chore(#4067): backfill PR number in changeset fragment --------- Co-authored-by: sim <sim@local> |