From 8f674281fd87bbe79346f26a306fd3d8767a56d7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 25 Aug 2026 23:17:29 -0400 Subject: [PATCH] chore(#3875): sweep the spent ack fragments and automate the sweep (#3877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(#3875): sweep the spent ack fragments and automate the sweep next has been red on every push since a84f75630 (#3823) — 24 consecutive pushes over two days — on two fully-spent emitted-drift-ack fragments nobody swept. #3823 introduced guard-no-ack-on-next together with a 45-fragment sweep, but computed that sweep as a static set of deletions fixed at its branch point. #3809's fragment merged to next while #3823 was in flight, so the guard reds on its own merge commit. The condition is evaluated dynamically at merge time and remediated statically at branch time; on a moving branch the second can never reliably satisfy the first. - delete tests/emitted-drift-acks/3809-* and 3866-* (3034-* and 3172-* stay -- the #3842 open-PR hold correctly defers them) - runGuardNext returns `sweepable`, the set the guard actually reasoned about, plus `legacyPresent` for the legacy document, which is a fixed path rather than a fragment basename and would otherwise be invisible to any sweeper - new --sweep-plan mode turns the guard into a work list: plan on stdout, prose on stderr, exit 0 so a non-empty plan does not fail the step that asked for it - main() is injectable in BOTH lanes; a half-injected seam lets a test that passes cwd silently read the real repository instead of its fixture - ack-fragment-sweep.yml derives its deletion list from that plan on a timer and opens a reviewable PR, so the sweep can no longer go stale between branch and merge Hardening found in review, each verified against a live reproduction: - git rm reads its arguments as PATHSPECS with wildmatch semantics, so a fragment named a bare-star .json name -- legal, and admitted by listFragmentFiles since it filters only on the suffix -- expanded to every fragment in the directory, including ones the #3842 hold withheld. Confirmed in a scratch repo: one such file deleted all three. Closed with a literal allowlist and a :(literal) pathspec, two independent layers. - an apostrophe inside a heredoc nested in a command substitution is an unterminated quote and a hard syntax error at runtime, not just under bash -n. - an empty plan no longer reports success unconditionally: the guard is re-run without the hold to tell "next is clean" from "everything is held", the commonest holder being the sweep PR from the previous run, which touches exactly the fragments it proposed to delete. - a branch pushed by a run that died before it could open the PR wedged every later run on a non-fast-forward push; re-pointed under a lease instead. - a guard crash in plan mode no longer reads as "nothing to sweep". Refs #3875 * chore(#3875): regenerate CONTEXT-INDEX.json for the glossary entry lint:generated-sync failed on CI: gen-context-index.cjs derives docs/CONTEXT-INDEX.json from CONTEXT.md, and the RULESET.EMITTED_ATTRIBUTION entry added in the previous commit left it stale. Refs #3875 * chore(#3875): regenerate the example CONTEXT-INDEX for the glossary entry CONTEXT.md feeds TWO committed indexes, not one: docs/CONTEXT-INDEX.json via scripts/gen-context-index.cjs, and the examples/dynamic-context-management copy that lint-example-parser-parity holds to a fresh parse. The previous commit regenerated only the first, so the parity check stayed red. Refs #3875 --------- Co-authored-by: sim --- .github/workflows/ack-fragment-sweep.yml | 280 ++++++++++ CONTEXT.md | 2 +- docs/CONTEXT-INDEX.json | 2 +- docs/TESTING-SUITES.md | 10 +- .../CONTEXT-INDEX.json | 524 +++++++++--------- scripts/lint-emitted-drift-ack.cjs | 78 ++- tests/emitted-attribution.test.cjs | 320 +++++++++++ .../3809-gsd-run-launcher-normalization.json | 9 - .../3866-verify-pre-dispatch-arms.json | 9 - tests/emitted-drift-acks/README.md | 19 +- 10 files changed, 952 insertions(+), 301 deletions(-) create mode 100644 .github/workflows/ack-fragment-sweep.yml delete mode 100644 tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json delete mode 100644 tests/emitted-drift-acks/3866-verify-pre-dispatch-arms.json diff --git a/.github/workflows/ack-fragment-sweep.yml b/.github/workflows/ack-fragment-sweep.yml new file mode 100644 index 000000000..de94bb252 --- /dev/null +++ b/.github/workflows/ack-fragment-sweep.yml @@ -0,0 +1,280 @@ +name: Sweep spent ack fragments + +# Companion to the `guard-no-ack-on-next` job in test.yml. That job DETECTS a +# fully-spent emitted-drift-ack fragment surviving on `next` and reds the branch; +# until #3875 the only remedy was a human reading CI prose and hand-authoring a +# `git rm` PR. That remedy is structurally unable to keep up: the guard evaluates +# dynamically at MERGE time, while a hand-authored sweep is a static set of +# deletions fixed at BRANCH time, so any ack-carrying PR that merges in between +# invalidates it. #3823 lost that race to #3809 on its own merge commit and left +# `next` red for 24 consecutive pushes. +# +# This sweep closes that window by deriving the deletion list from the guard +# ITSELF (`--sweep-plan`), on a timer, immediately before acting on it. It opens a +# PR rather than pushing to `next` directly: `next` is protected, and an +# acknowledgment is a reviewed artifact, so a human still approves the deletion. + +on: + schedule: + - cron: '30 */6 * * *' + workflow_dispatch: + +concurrency: + group: ack-fragment-sweep + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + sweep: + name: Sweep all-spent ack fragments on next + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: next + # The guard reads each surviving fragment at the commit before HEAD. + # Full history, not depth 2: the sweep branch is pushed from here, and + # a shallow clone cannot be pushed to a protected-branch repo cleanly. + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + + - name: Compute the sweep plan + id: plan + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # stdout is the machine-readable plan, stderr the guard's own prose — + # the prose is teed into the job log so a run that sweeps nothing still + # explains why (including which fragments the #3842 open-PR hold kept). + # + # `--defer-to-open-prs` is NOT optional here. Without it the plan names + # every all-spent fragment including ones an open PR still modifies, and + # deleting one of those hands that PR a modify/delete conflict it did not + # cause — the exact failure #3842 exists to prevent. + # + # The hold is evaluated at PLAN time, not at merge time, so it narrows but + # does not eliminate the conflict window: a PR opened after this step runs + # that re-arms a planned fragment by appending prose to it can still meet a + # modify/delete when this sweep lands. That residue is why the sweep opens a + # reviewable PR rather than pushing to `next` — a human sees the diff, and + # a conflict surfaces as a normal merge conflict on a bot PR rather than as + # a surprise on someone else's. + # + # No `--base-ref`: a scheduled run has no `github.event.before`, so the + # script's own `HEAD^` fallback applies. Note what that fallback actually + # does — it is NOT simply conservative. On a rebase-merge landing N>=2 + # commits whose first adds a fragment, `HEAD^` lands on that first commit, + # reads the fragment as already present, and plans it (the script says so + # itself, at `resolveBaseRef`). That is acceptable HERE, and only here: a + # fragment that has reached `next` is spent by the lifecycle definition + # regardless of which commit of the push carried it, and the open-PR hold + # below still protects any PR that is still touching it. The same fallback + # would be wrong for the push-lane guard, which is exactly why test.yml + # passes `github.event.before` explicitly instead. + # `|| true` would be wrong here. In plan mode the script exits 0 for + # EVERY verdict, so a non-zero status is a real fault — a crash, an + # unreadable base ref, a rejected flag — and must fail the job loudly. + # Swallowing it would leave plan.txt empty and report the fault as the + # cheerful "nothing to sweep", which is the silent-failure class this + # whole ack seam exists to end. + set +e + node scripts/lint-emitted-drift-ack.cjs \ + --guard-next --sweep-plan --defer-to-open-prs \ + > plan.txt 2> prose.txt + guard_status=$? + set -e + echo '--- guard output ---' + cat prose.txt + if [ "$guard_status" -ne 0 ]; then + echo "::error::guard exited ${guard_status} in plan mode — that is a fault, not a verdict" + exit 1 + fi + + if [ ! -s plan.txt ]; then + # An empty plan has two very different causes, and reporting both as + # "nothing to sweep" is how this automation would go quietly inert. + # + # Cause A: `next` is clean. Nothing to do, and silence is right. + # + # Cause B: every all-spent fragment is HELD by an open PR — and the + # commonest such PR is the sweep PR THIS WORKFLOW opened last run, which + # touches exactly the fragments it proposed to delete. So run #2 onward + # would report a cheerful green while `next` stays red and the sweep PR + # rots unmerged. Re-running the guard WITHOUT the hold separates the two: + # a non-empty unheld set with an empty plan means "blocked, not done". + set +e + node scripts/lint-emitted-drift-ack.cjs \ + --guard-next --sweep-plan > unheld.txt 2>/dev/null + unheld_status=$? + set -e + if [ "$unheld_status" -eq 0 ] && [ -s unheld.txt ]; then + open_sweep=$(gh pr list --base next --state open \ + --json number,headRefName \ + --jq '[.[] | select(.headRefName | startswith("chore/ack-sweep-"))] | .[0].number // empty' \ + 2>/dev/null || echo "") + if [ -n "$open_sweep" ]; then + echo "::warning::next still carries spent ack fragment(s); sweep PR #${open_sweep} is open and awaiting merge. Nothing further this run." + else + echo '::warning::next still carries spent ack fragment(s), but every one is held by an open PR that touches it (#3842). They will be swept once those PRs merge or close.' + fi + echo 'held fragments:' + cat unheld.txt + else + echo 'nothing to sweep' + fi + echo 'empty=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Every planned path is re-validated before anything is deleted, against + # a strict allowlist rather than a shape check. The plan comes from our + # own script, but the fragment BASENAMES in it come from readdirSync and + # are filtered only on the `.json` suffix, so any filename a merged PR + # can land in that directory reaches this point. + # + # Two concrete attacks this closes. (1) A file literally named `*.json` + # is a valid filename and passes a `tests/emitted-drift-acks/*.json` + # glob test — and `git rm` treats each argument as a PATHSPEC, so it + # would then expand to every fragment in the tree, including ones the + # #3842 open-PR hold deliberately withheld. Verified locally: one such + # file deletes all of them. (2) A filename containing backticks or + # newlines is interpolated into the PR body below, injecting markdown + # into a document this repo's review agents read. + # + # The allowlist admits only a leading alphanumeric followed by + # alphanumerics, dot, underscore and hyphen — no glob metacharacters, no + # whitespace, no backticks, no slash, so no traversal and no leading `-` + # for git to read as an option. A filename containing a newline arrives + # here as two lines, each of which fails the pattern: it fails closed. + # + # The plan carries two shapes: the legacy single document at a fixed path + # (`tests/emitted-drift-ack.json`), whose mere PRESENCE reds `next`, and + # fragment basenames under `tests/emitted-drift-acks/`. The legacy path is + # matched exactly and literally; only the fragment arm takes a name pattern. + while IFS= read -r line; do + [ -n "$line" ] || continue + if ! printf '%s' "$line" \ + | grep -qE '^(tests/emitted-drift-ack\.json|tests/emitted-drift-acks/[A-Za-z0-9][A-Za-z0-9._-]*\.json)$'; then + echo "::error::refusing to sweep unexpected path: $line" + exit 1 + fi + if [ ! -f "$line" ]; then + echo "::error::planned path is not a file: $line" + exit 1 + fi + done < plan.txt + + echo 'empty=false' >> "$GITHUB_OUTPUT" + echo 'planned fragments:' + cat plan.txt + + - name: Open the sweep PR + if: steps.plan.outputs.empty == 'false' + env: + GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} + HAS_BOT_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN != '' && '1' || '' }} + run: | + set -euo pipefail + # `GITHUB_TOKEN` cannot raise workflow runs for the events it creates, so a + # PR opened under the fallback never reports a single required check and + # sits permanently pending. That is a silent, confusing failure, so it is + # announced rather than discovered. + if [ -z "${HAS_BOT_TOKEN:-}" ]; then + echo '::warning::GSD_BOT_PR_TOKEN is unset; opening the sweep PR with GITHUB_TOKEN. No required checks will run on it and it will not be mergeable until a maintainer pushes to the branch.' + fi + SHORT_SHA=$(git rev-parse --short HEAD) + BR="chore/ack-sweep-${SHORT_SHA}" + + # An earlier run may already have opened a sweep PR for this same tip. + EXISTING=$(gh pr list --base next --head "$BR" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + echo "sweep PR #${EXISTING} already open for ${SHORT_SHA}" + exit 0 + fi + + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -b "$BR" + + # `:(literal)` pathspec magic, one path per call. `--` stops OPTION + # parsing but git still reads each remaining argument as a pathspec with + # wildmatch semantics, so a fragment named `*.json` would expand to every + # fragment in the directory. `:(literal)` disables that globbing and makes + # each argument mean exactly the bytes it contains. The allowlist in the + # plan step already rejects such a name; this is the second, independent + # layer, because over-deletion here is unrecoverable within the run. + while IFS= read -r frag; do + [ -n "$frag" ] || continue + git rm -q -- ":(literal)${frag}" + done < plan.txt + + COUNT=$(grep -c . plan.txt) + LIST=$(sed 's/^/- /' plan.txt) + + git commit -q -m "chore(#3875): sweep ${COUNT} spent ack fragment(s) from next" + + # A previous run can have pushed this branch and then died before + # `gh pr create`, or a human can have closed the PR without deleting the + # branch. In both cases the open-PR probe above finds nothing, the rebuilt + # commit gets a fresh committer timestamp and therefore a different sha, + # and a plain push is rejected as non-fast-forward — every six hours, + # forever, with no path to sweep that tip. Re-point the stale branch + # instead, under a lease so a branch someone else has moved is never + # clobbered blind. + if git ls-remote --exit-code --heads origin "$BR" >/dev/null 2>&1; then + git fetch -q origin "$BR" + git push -q --force-with-lease="${BR}:$(git rev-parse FETCH_HEAD)" origin "$BR" + else + git push -q origin "$BR" + fi + + # No apostrophes in this heredoc body: bash scans $( ) for quotes before + # it recognises the nested heredoc, so a lone ' here is an unterminated + # quote and a hard syntax error at runtime, not just under `bash -n`. + BODY=$(cat < XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name` `RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \`bug-3135-capture-backlog-workflow\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill` `RULESET.WORKFLOW_EXECUTE_END_TO_END=standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)` diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index bdde35eee..20492f1c2 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -992,7 +992,7 @@ { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", - "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other on the FILE; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins — #3078 made that error name its two resolutions (git rm an already-merged, spent owner; APPEND prose to a still-live one, which re-arms it), because the guard runs post-merge and cannot stop the colliding PR. `tests/emitted-drift-ack.json` (the LEGACY file specifically) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; #2914 asserted a persisting FRAGMENT was harmless by construction and deliberately exempted the directory; #3078 REVERSED that — fragments do not share a FILE but they DO share a PATH KEY SPACE, so a fully-spent fragment on `next` owns keys it can no longer gate and the next PR growing one of those paths can declare it neither there (spent) nor in its own (duplicate), which is the #2914 wall one level down (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier). A fragment is judged on INERTNESS, not presence: swept once EVERY entry is spent, left alone while PARTIALLY spent — the asymmetry is what keeps the re-arm-by-appending route (#2639, #2993) working, and the `0000` legacy-migration bucket #2923 created for the old shared file's 35 entries was NOT permanent (the issue's own open question resolved to NO) and went with the rest. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next`, which is now BOTH halves — `assertAbsentOnNext` (legacy file, fails on PRESENCE alone, valid or not) and `assertNoAllSpentFragments` (fragments, fails on all-entries-spent vs the copy at the PRE-PUSH TIP of next — CI passes `github.event.before` via `--base-ref`, because the default branch allows REBASE merges so one push can carry N commits and a bare `HEAD^` would flag a fragment the same push introduced; `HEAD^` remains only the local/manual fallback, using the SAME zero-width/whitespace-stripping prose comparison as `isSpent` so an invisible reword cannot fake a re-arm; duplicated across the scripts-ship/tests-do-not line and held by a parity test). The job's checkout REQUIRES `fetch-depth: 2` plus an explicit `git fetch --depth=1 origin $BEFORE` — at depth 1 no base commit exists locally, every fragment reads as brand-new, and the guard passes vacuously, which is exactly how the legacy half went blind after #2914 removed the file it was watching. The gate's `INVISIBLE`/`normalizeAckReason` are EXPORTED from tests/helpers/emitted-diff.cjs for the sole purpose of letting the parity test compare them against the script's duplicate; before #3078 neither was exported, so the \"parity test\" the comments promised was a tautology checking the script against itself. A PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended — so this alerts AFTER the merge by design and never stops the offending PR. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`" + "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other on the FILE; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins — #3078 made that error name its two resolutions (git rm an already-merged, spent owner; APPEND prose to a still-live one, which re-arms it), because the guard runs post-merge and cannot stop the colliding PR. `tests/emitted-drift-ack.json` (the LEGACY file specifically) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; #2914 asserted a persisting FRAGMENT was harmless by construction and deliberately exempted the directory; #3078 REVERSED that — fragments do not share a FILE but they DO share a PATH KEY SPACE, so a fully-spent fragment on `next` owns keys it can no longer gate and the next PR growing one of those paths can declare it neither there (spent) nor in its own (duplicate), which is the #2914 wall one level down (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier). A fragment is judged on INERTNESS, not presence: swept once EVERY entry is spent, left alone while PARTIALLY spent — the asymmetry is what keeps the re-arm-by-appending route (#2639, #2993) working, and the `0000` legacy-migration bucket #2923 created for the old shared file's 35 entries was NOT permanent (the issue's own open question resolved to NO) and went with the rest. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next`, which is now BOTH halves — `assertAbsentOnNext` (legacy file, fails on PRESENCE alone, valid or not) and `assertNoAllSpentFragments` (fragments, fails on all-entries-spent vs the copy at the PRE-PUSH TIP of next — CI passes `github.event.before` via `--base-ref`, because the default branch allows REBASE merges so one push can carry N commits and a bare `HEAD^` would flag a fragment the same push introduced; `HEAD^` remains only the local/manual fallback, using the SAME zero-width/whitespace-stripping prose comparison as `isSpent` so an invisible reword cannot fake a re-arm; duplicated across the scripts-ship/tests-do-not line and held by a parity test). The job's checkout REQUIRES `fetch-depth: 2` plus an explicit `git fetch --depth=1 origin $BEFORE` — at depth 1 no base commit exists locally, every fragment reads as brand-new, and the guard passes vacuously, which is exactly how the legacy half went blind after #2914 removed the file it was watching. The gate's `INVISIBLE`/`normalizeAckReason` are EXPORTED from tests/helpers/emitted-diff.cjs for the sole purpose of letting the parity test compare them against the script's duplicate; before #3078 neither was exported, so the \"parity test\" the comments promised was a tautology checking the script against itself. A PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended — so this alerts AFTER the merge by design and never stops the offending PR. #3875 automated the REMEDY that alert asks for, because detection without an executable remedy is what actually failed: #3823 shipped the guard together with a static 45-fragment sweep computed at its own branch point, #3809's fragment merged to `next` while it was in flight, and the guard reddened on its own merge commit and stayed red for 24 consecutive pushes over two days — the sweep condition is computed DYNAMICALLY at merge time while a hand-authored `git rm` is fixed at BRANCH time, so on a moving branch the second can never reliably satisfy the first. `runGuardNext` therefore returns the set it reasoned about (`sweepable`, already narrowed by the #3842 hold, plus `legacyPresent` for the legacy document, which is a fixed path rather than a fragment basename and would otherwise be invisible to any sweeper), `--sweep-plan` emits that set as a work list on stdout with the prose diverted to stderr and exit 0 (a non-empty plan is the NORMAL case, and a non-zero exit would fail the step that asked for the list), and `.github/workflows/ack-fragment-sweep.yml` runs it on a timer and opens a reviewable PR rather than pushing to protected `next`. The plan is re-validated against a literal allowlist before any deletion and each path is removed under a `:(literal)` pathspec — `git rm` reads its arguments as PATHSPECS with wildmatch semantics, so a fragment named `*.json` (a legal filename that `listFragmentFiles` admits, since it filters only on the suffix) would otherwise expand to every fragment in the directory, including ones the #3842 hold deliberately withheld. An empty plan is NOT reported as success on its own: the guard is re-run without the hold to separate \"next is clean\" from \"everything is held\", the commonest holder being the sweep PR from the previous run, which touches precisely the fragments it proposed to delete and would otherwise make the automation go silently inert. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`" }, { "id": "RULESET.GENERATIVE-FIX", diff --git a/docs/TESTING-SUITES.md b/docs/TESTING-SUITES.md index be82fd979..fe5f2a0d2 100644 --- a/docs/TESTING-SUITES.md +++ b/docs/TESTING-SUITES.md @@ -153,12 +153,16 @@ The differential attribution check reports the file and the byte delta. To resol ack sources naming the same path is a hard, loudly-reported error. The legacy single `tests/emitted-drift-ack.json` is still read and unioned in for branches that carry it, but new acknowledgments never go there. -3. **Delete your fragment once it has merged (#3078).** A fragment on `next` is - spent by definition — its prose is already at the base, so it can no longer +3. **Your fragment is deleted once it has merged (#3078).** A fragment on `next` + is spent by definition — its prose is already at the base, so it can no longer clear anything — while still owning its path keys, which walls off the next PR that grows one of them. The `guard-no-ack-on-next` job reds `next` and prints the exact `git rm` for every fully-spent fragment. A *partially* spent fragment - is deliberately left alone. + is deliberately left alone. Since #3875 you do not have to run that `git rm`: + the `ack-fragment-sweep` workflow (`.github/workflows/ack-fragment-sweep.yml`) + asks the guard for its own sweep list every six hours and opens a PR deleting + exactly what it named, holding back any fragment an open PR still touches + (#3842). 4. **Or shrink it instead of acknowledging.** Prefer extraction when the growth is incidental: for a workflow, move per-mode bodies to `workflows//modes/`, templates to `workflows//templates/`, and diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index cd02a3da1..c2350e929 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -28,1567 +28,1567 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 661 + "line": 667 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 645 + "line": 651 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 644 + "line": 650 }, { "id": "CONFIG.LOCATION.SEAM.in-process-scrub", "klass": "CONFIG", "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST", - "line": 679 + "line": 685 }, { "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", "klass": "CONFIG", "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second", - "line": 678 + "line": 684 }, { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", - "line": 676 + "line": 682 }, { "id": "CONFIG.LOCATION.SEAM.two-families", "klass": "CONFIG", "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2", - "line": 677 + "line": 683 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 675 + "line": 681 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 894 + "line": 900 }, { "id": "EXEC.CLASSIFY.cross-runtime", "klass": "EXEC", "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests", - "line": 896 + "line": 902 }, { "id": "EXEC.CLASSIFY.handler", "klass": "EXEC", "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)", - "line": 892 + "line": 898 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 897 + "line": 903 }, { "id": "EXEC.CLASSIFY.proactive-signal-not-usable", "klass": "EXEC", "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)", - "line": 899 + "line": 905 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 898 + "line": 904 }, { "id": "EXEC.CLASSIFY.sentinel-order", "klass": "EXEC", "value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form", - "line": 895 + "line": 901 }, { "id": "EXEC.CLASSIFY.workflow", "klass": "EXEC", "value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)", - "line": 893 + "line": 899 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", "klass": "GSD-RESEARCH", "value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob", - "line": 432 + "line": 438 }, { "id": "GSD-RESEARCH.INTEGRATION.L2-hybrid", "klass": "GSD-RESEARCH", "value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches", - "line": 430 + "line": 436 }, { "id": "GSD-RESEARCH.MODULE.package-legitimacy", "klass": "GSD-RESEARCH", "value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate", - "line": 429 + "line": 435 }, { "id": "GSD-RESEARCH.MODULE.research-provider", "klass": "GSD-RESEARCH", "value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in the docs or web legs)", - "line": 428 + "line": 434 }, { "id": "GSD-RESEARCH.MODULE.research-store", "klass": "GSD-RESEARCH", "value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache", - "line": 427 + "line": 433 }, { "id": "GSD-RESEARCH.PROVIDER.availability", "klass": "GSD-RESEARCH", "value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal", - "line": 431 + "line": 437 }, { "id": "LEARNING.prompt-budget.boundary-gap", "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures", - "line": 592 + "line": 598 }, { "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", "klass": "LIVE-CONFIG", "value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes", - "line": 685 + "line": 691 }, { "id": "LIVE-CONFIG.GUARD.SEAM.module", "klass": "LIVE-CONFIG", "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", - "line": 680 + "line": 686 }, { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", - "line": 682 + "line": 688 }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", - "line": 681 + "line": 687 }, { "id": "LIVE-CONFIG.GUARD.SEAM.severity", "klass": "LIVE-CONFIG", "value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1", - "line": 684 + "line": 690 }, { "id": "LIVE-CONFIG.GUARD.SEAM.truncation", "klass": "LIVE-CONFIG", "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing", - "line": 683 + "line": 689 }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations", - "line": 736 + "line": 742 }, { "id": "META.RULE.brief-no-paraphrase", "klass": "META", "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110", - "line": 737 + "line": 743 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 734 + "line": 740 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 735 + "line": 741 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 670 + "line": 676 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 671 + "line": 677 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 672 + "line": 678 }, { "id": "PR.3267.POSTMORTEM.recovery", "klass": "PR", "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]", - "line": 649 + "line": 655 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 648 + "line": 654 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 740 + "line": 746 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 746 + "line": 752 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 747 + "line": 753 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 742 + "line": 748 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 749 + "line": 755 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 745 + "line": 751 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 748 + "line": 754 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 741 + "line": 747 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 739 + "line": 745 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 743 + "line": 749 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 744 + "line": 750 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 755 + "line": 761 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 753 + "line": 759 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 754 + "line": 760 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 752 + "line": 758 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 751 + "line": 757 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 760 + "line": 766 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 761 + "line": 767 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 758 + "line": 764 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 763 + "line": 769 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 762 + "line": 768 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 759 + "line": 765 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 757 + "line": 763 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 768 + "line": 774 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 767 + "line": 773 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 770 + "line": 776 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 769 + "line": 775 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 766 + "line": 772 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 765 + "line": 771 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 774 + "line": 780 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 776 + "line": 782 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 773 + "line": 779 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 775 + "line": 781 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 772 + "line": 778 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 781 + "line": 787 }, { "id": "PRED.k325.cure", "klass": "PRED", "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/", - "line": 780 + "line": 786 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 782 + "line": 788 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 779 + "line": 785 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 778 + "line": 784 }, { "id": "PRED.k326.cure", "klass": "PRED", "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"", - "line": 786 + "line": 792 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 787 + "line": 793 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 785 + "line": 791 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 784 + "line": 790 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 790 + "line": 796 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 793 + "line": 799 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 794 + "line": 800 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 792 + "line": 798 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 791 + "line": 797 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 789 + "line": 795 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 799 + "line": 805 }, { "id": "PRED.k328.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)", - "line": 797 + "line": 803 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 798 + "line": 804 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 796 + "line": 802 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 805 + "line": 811 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 802 + "line": 808 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 803 + "line": 809 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 804 + "line": 810 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 806 + "line": 812 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 801 + "line": 807 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 810 + "line": 816 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 809 + "line": 815 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 808 + "line": 814 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 815 + "line": 821 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 817 + "line": 823 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 814 + "line": 820 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 816 + "line": 822 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 813 + "line": 819 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 812 + "line": 818 }, { "id": "PROBE.ci.surface", "klass": "PROBE", "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)", - "line": 561 + "line": 567 }, { "id": "PROBE.core.seam", "klass": "PROBE", "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)", - "line": 554 + "line": 560 }, { "id": "PROBE.edge.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 556 + "line": 562 }, { "id": "PROBE.family", "klass": "PROBE", "value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)", - "line": 552 + "line": 558 }, { "id": "PROBE.item.axes", "klass": "PROBE", "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)", - "line": 555 + "line": 561 }, { "id": "PROBE.principle", "klass": "PROBE", "value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md", - "line": 551 + "line": 557 }, { "id": "PROBE.prohib.verification", "klass": "PROBE", "value": "test|judgment", - "line": 557 + "line": 563 }, { "id": "PROBE.protocol", "klass": "PROBE", "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason", - "line": 553 + "line": 559 }, { "id": "PROBE.ui.axis", "klass": "PROBE", "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)", - "line": 559 + "line": 565 }, { "id": "PROBE.ui.seam", "klass": "PROBE", "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)", - "line": 560 + "line": 566 }, { "id": "PROBE.ui.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 558 + "line": 564 }, { "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 821 + "line": 827 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 820 + "line": 826 }, { "id": "PROC.AGENT-DISPATCH.preflight", "klass": "PROC", "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]", - "line": 819 + "line": 825 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 825 + "line": 831 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 826 + "line": 832 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 827 + "line": 833 }, { "id": "PROC.MERGE-WAVE.ordering", "klass": "PROC", "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]", - "line": 823 + "line": 829 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 824 + "line": 830 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.observed", "klass": "PROC", "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened", - "line": 903 + "line": 909 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", "klass": "PROC", "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill", - "line": 901 + "line": 907 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", "klass": "PROC", "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION", - "line": 902 + "line": 908 }, { "id": "PROC.TRIAGE.comment-shape", "klass": "PROC", "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close", - "line": 906 + "line": 912 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 907 + "line": 913 }, { "id": "PROC.TRIAGE.routing-incoming", "klass": "PROC", "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest", - "line": 905 + "line": 911 }, { "id": "PROHIB.canon-referral", "klass": "PROHIB", "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)", - "line": 563 + "line": 569 }, { "id": "PROHIB.descriptor.shape", "klass": "PROHIB", "value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)", - "line": 568 + "line": 574 }, { "id": "PROHIB.enforce.adr", "klass": "PROHIB", "value": "docs/adr/1606-prohibition-enforcement-verify-seam.md (verify-time enforcement seam) + docs/adr/550-spec-phase-probe-contract.md (spec-phase contract)", - "line": 571 + "line": 577 }, { "id": "PROHIB.enforce.causation", "klass": "PROHIB", "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)", - "line": 567 + "line": 573 }, { "id": "PROHIB.enforce.failfirst", "klass": "PROHIB", "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)", - "line": 566 + "line": 572 }, { "id": "PROHIB.enforce.green-rule", "klass": "PROHIB", "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default", - "line": 564 + "line": 570 }, { "id": "PROHIB.enforce.kinds", "klass": "PROHIB", "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)", - "line": 565 + "line": 571 }, { "id": "PROHIB.judgment-tier", "klass": "PROHIB", "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)", - "line": 570 + "line": 576 }, { "id": "PROHIB.rail", "klass": "PROHIB", "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability", - "line": 569 + "line": 575 }, { "id": "PROHIB.recall", "klass": "PROHIB", "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)", - "line": 562 + "line": 568 }, { "id": "RELEASE-NOTES.ANTI-PATTERN", "klass": "RELEASE-NOTES", "value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes", - "line": 716 + "line": 722 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", "klass": "RELEASE-NOTES", "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior", - "line": 717 + "line": 723 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", "klass": "RELEASE-NOTES", "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong", - "line": 718 + "line": 724 }, { "id": "RELEASE-NOTES.DEFAULT-STATE", "klass": "RELEASE-NOTES", "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final", - "line": 692 + "line": 698 }, { "id": "RELEASE-NOTES.EXAMPLE.hotfix", "klass": "RELEASE-NOTES", "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups", - "line": 720 + "line": 726 }, { "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", "klass": "RELEASE-NOTES", "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles", - "line": 722 + "line": 728 }, { "id": "RELEASE-NOTES.EXAMPLE.rc", "klass": "RELEASE-NOTES", "value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy", - "line": 721 + "line": 727 }, { "id": "RELEASE-NOTES.GATE.hotfix", "klass": "RELEASE-NOTES", "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body", - "line": 693 + "line": 699 }, { "id": "RELEASE-NOTES.GATE.minor", "klass": "RELEASE-NOTES", "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix", - "line": 695 + "line": 701 }, { "id": "RELEASE-NOTES.GATE.rc", "klass": "RELEASE-NOTES", "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard", - "line": 694 + "line": 700 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 727 + "line": 733 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 728 + "line": 734 }, { "id": "RELEASE-NOTES.SCOPE", "klass": "RELEASE-NOTES", "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)", - "line": 691 + "line": 697 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 707 + "line": 713 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 706 + "line": 712 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 708 + "line": 714 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 709 + "line": 715 }, { "id": "RELEASE-NOTES.STANDARD.bullet-shape", "klass": "RELEASE-NOTES", "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.", - "line": 699 + "line": 705 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 703 + "line": 709 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 701 + "line": 707 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 702 + "line": 708 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 698 + "line": 704 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 704 + "line": 710 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 700 + "line": 706 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 697 + "line": 703 }, { "id": "RELEASE-NOTES.TEMPLATE.hotfix", "klass": "RELEASE-NOTES", "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ", - "line": 724 + "line": 730 }, { "id": "RELEASE-NOTES.TEMPLATE.rc", "klass": "RELEASE-NOTES", "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 725 + "line": 731 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 711 + "line": 717 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 714 + "line": 720 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 713 + "line": 719 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 712 + "line": 718 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 616 + "line": 622 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 605 + "line": 611 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 612 + "line": 618 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 613 + "line": 619 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 601 + "line": 607 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", "klass": "RULESET", "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.", - "line": 382 + "line": 388 }, { "id": "RULESET.CAPABILITY.off-means-off", "klass": "RULESET", "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.", - "line": 380 + "line": 386 }, { "id": "RULESET.CAPABILITY.precedence-engine-single-owner", "klass": "RULESET", "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.", - "line": 386 + "line": 392 }, { "id": "RULESET.CAPABILITY.step-additive-gate-blocks", "klass": "RULESET", "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.", - "line": 384 + "line": 390 }, { "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 638 + "line": 644 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 639 + "line": 645 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 637 + "line": 643 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 640 + "line": 646 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 641 + "line": 647 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 642 + "line": 648 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)", - "line": 843 + "line": 849 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 631 + "line": 637 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 632 + "line": 638 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 630 + "line": 636 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 629 + "line": 635 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 623 + "line": 629 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", - "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other on the FILE; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins — #3078 made that error name its two resolutions (git rm an already-merged, spent owner; APPEND prose to a still-live one, which re-arms it), because the guard runs post-merge and cannot stop the colliding PR. `tests/emitted-drift-ack.json` (the LEGACY file specifically) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; #2914 asserted a persisting FRAGMENT was harmless by construction and deliberately exempted the directory; #3078 REVERSED that — fragments do not share a FILE but they DO share a PATH KEY SPACE, so a fully-spent fragment on `next` owns keys it can no longer gate and the next PR growing one of those paths can declare it neither there (spent) nor in its own (duplicate), which is the #2914 wall one level down (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier). A fragment is judged on INERTNESS, not presence: swept once EVERY entry is spent, left alone while PARTIALLY spent — the asymmetry is what keeps the re-arm-by-appending route (#2639, #2993) working, and the `0000` legacy-migration bucket #2923 created for the old shared file's 35 entries was NOT permanent (the issue's own open question resolved to NO) and went with the rest. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next`, which is now BOTH halves — `assertAbsentOnNext` (legacy file, fails on PRESENCE alone, valid or not) and `assertNoAllSpentFragments` (fragments, fails on all-entries-spent vs the copy at the PRE-PUSH TIP of next — CI passes `github.event.before` via `--base-ref`, because the default branch allows REBASE merges so one push can carry N commits and a bare `HEAD^` would flag a fragment the same push introduced; `HEAD^` remains only the local/manual fallback, using the SAME zero-width/whitespace-stripping prose comparison as `isSpent` so an invisible reword cannot fake a re-arm; duplicated across the scripts-ship/tests-do-not line and held by a parity test). The job's checkout REQUIRES `fetch-depth: 2` plus an explicit `git fetch --depth=1 origin $BEFORE` — at depth 1 no base commit exists locally, every fragment reads as brand-new, and the guard passes vacuously, which is exactly how the legacy half went blind after #2914 removed the file it was watching. The gate's `INVISIBLE`/`normalizeAckReason` are EXPORTED from tests/helpers/emitted-diff.cjs for the sole purpose of letting the parity test compare them against the script's duplicate; before #3078 neither was exported, so the \"parity test\" the comments promised was a tautology checking the script against itself. A PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended — so this alerts AFTER the merge by design and never stops the offending PR. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", - "line": 606 + "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other on the FILE; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins — #3078 made that error name its two resolutions (git rm an already-merged, spent owner; APPEND prose to a still-live one, which re-arms it), because the guard runs post-merge and cannot stop the colliding PR. `tests/emitted-drift-ack.json` (the LEGACY file specifically) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; #2914 asserted a persisting FRAGMENT was harmless by construction and deliberately exempted the directory; #3078 REVERSED that — fragments do not share a FILE but they DO share a PATH KEY SPACE, so a fully-spent fragment on `next` owns keys it can no longer gate and the next PR growing one of those paths can declare it neither there (spent) nor in its own (duplicate), which is the #2914 wall one level down (measured at the sweep: 45 fragments owning 403 paths, up from 13/272 at triage 19 days earlier). A fragment is judged on INERTNESS, not presence: swept once EVERY entry is spent, left alone while PARTIALLY spent — the asymmetry is what keeps the re-arm-by-appending route (#2639, #2993) working, and the `0000` legacy-migration bucket #2923 created for the old shared file's 35 entries was NOT permanent (the issue's own open question resolved to NO) and went with the rest. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next`, which is now BOTH halves — `assertAbsentOnNext` (legacy file, fails on PRESENCE alone, valid or not) and `assertNoAllSpentFragments` (fragments, fails on all-entries-spent vs the copy at the PRE-PUSH TIP of next — CI passes `github.event.before` via `--base-ref`, because the default branch allows REBASE merges so one push can carry N commits and a bare `HEAD^` would flag a fragment the same push introduced; `HEAD^` remains only the local/manual fallback, using the SAME zero-width/whitespace-stripping prose comparison as `isSpent` so an invisible reword cannot fake a re-arm; duplicated across the scripts-ship/tests-do-not line and held by a parity test). The job's checkout REQUIRES `fetch-depth: 2` plus an explicit `git fetch --depth=1 origin $BEFORE` — at depth 1 no base commit exists locally, every fragment reads as brand-new, and the guard passes vacuously, which is exactly how the legacy half went blind after #2914 removed the file it was watching. The gate's `INVISIBLE`/`normalizeAckReason` are EXPORTED from tests/helpers/emitted-diff.cjs for the sole purpose of letting the parity test compare them against the script's duplicate; before #3078 neither was exported, so the \"parity test\" the comments promised was a tautology checking the script against itself. A PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended — so this alerts AFTER the merge by design and never stops the offending PR. #3875 automated the REMEDY that alert asks for, because detection without an executable remedy is what actually failed: #3823 shipped the guard together with a static 45-fragment sweep computed at its own branch point, #3809's fragment merged to `next` while it was in flight, and the guard reddened on its own merge commit and stayed red for 24 consecutive pushes over two days — the sweep condition is computed DYNAMICALLY at merge time while a hand-authored `git rm` is fixed at BRANCH time, so on a moving branch the second can never reliably satisfy the first. `runGuardNext` therefore returns the set it reasoned about (`sweepable`, already narrowed by the #3842 hold, plus `legacyPresent` for the legacy document, which is a fixed path rather than a fragment basename and would otherwise be invisible to any sweeper), `--sweep-plan` emits that set as a work list on stdout with the prose diverted to stderr and exit 0 (a non-empty plan is the NORMAL case, and a non-zero exit would fail the step that asked for the list), and `.github/workflows/ack-fragment-sweep.yml` runs it on a timer and opens a reviewable PR rather than pushing to protected `next`. The plan is re-validated against a literal allowlist before any deletion and each path is removed under a `:(literal)` pathspec — `git rm` reads its arguments as PATHSPECS with wildmatch semantics, so a fragment named `*.json` (a legal filename that `listFragmentFiles` admits, since it filters only on the suffix) would otherwise expand to every fragment in the directory, including ones the #3842 hold deliberately withheld. An empty plan is NOT reported as success on its own: the guard is re-run without the hold to separate \"next is clean\" from \"everything is held\", the commonest holder being the sweep PR from the previous run, which touches precisely the fragments it proposed to delete and would otherwise make the automation go silently inert. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", + "line": 612 }, { "id": "RULESET.GENERATIVE-FIX", "klass": "RULESET", "value": "parallel implementations diverge silently when no parity test enforces equality at the test layer; for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge; exemplar: tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher)", - "line": 841 + "line": 847 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 636 + "line": 642 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM", - "line": 882 + "line": 888 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib; #3762 added the ROSTER half — tests/inventory-manifest-sync.test.cjs now also asserts every manifest entry has a hand-written row in docs/INVENTORY.md, via the pure matcher in tests/helpers/inventory-roster.cjs. Scope is the SIX FLAT families only, each searched inside its own `## ` section; workflow_steps/workflow_modes are DELIBERATELY exempt because docs/INVENTORY.md §\"Workflow Sub-Files\" is a shipped decision that they carry no hand-written per-file rows. Matching is whole-CELL-exact (never substring — the rostered host-integration-adapters/imperative-hook-bus.cjs must not satisfy the separate top-level hook-bus.cjs) and section-scoped (smart-entry.md and smart-entry.cjs are different families), EXCEPT commands, which match on the row's Source-column link to ../commands/gsd/.md because the six ns-* namespace routers deliberately RENDER a name that is not their file stem (/gsd-workflow ← ns-workflow.md) — DEFECT.DISPLAY-VALUE-AS-IDENTITY. Landing the gate required backfilling 32 pre-existing unrostered surfaces on next", - "line": 617 + "line": 623 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — \"we don't set a timer we actively watch and record results in real time as possible\". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:\"passed\" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.", - "line": 884 + "line": 890 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 886 + "line": 892 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 619 + "line": 625 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 614 + "line": 620 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 643 + "line": 649 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 588 + "line": 594 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 591 + "line": 597 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 590 + "line": 596 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 595 + "line": 601 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 586 + "line": 592 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 598 + "line": 604 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 587 + "line": 593 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 583 + "line": 589 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); all three test-rigor rules now ship at error in tests/**/*.test.cjs scope: local/no-source-grep and local/no-magic-sleep-in-tests promoted by #3313, local/no-elapsed-assertion promoted by #3331 once #3314 delivered its ADR-456 §(a) precondition (epic #1885 was subsumed into epic #3053 and closed stale before this promotion landed)", - "line": 599 + "line": 605 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 589 + "line": 595 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 585 + "line": 591 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 597 + "line": 603 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 584 + "line": 590 }, { "id": "RULESET.TESTS.no-duplicate-fold-marker", "klass": "RULESET", "value": "local/no-duplicate-fold-marker ESLint AST rule (eslint-rules/no-duplicate-fold-marker.cjs, #3271) reports the 2nd and every later __foldDescribe(\"folded: ...\") call carrying a marker already seen in the SAME file, naming the first occurrence's line; error in tests/**/*.cjs. The key is the WHITESPACE-delimited token after folded:, NOT a [a-z0-9-]* slice — a slice truncates at \".\" and collides feat-443-effort-fast-mode.integration with feat-443-effort-fast-mode (two distinct suites coexisting in tests/model-resolver.test.cjs), and NOT the whole title, so a re-fold under a different batch label (\"B1 #1970\" vs \"B5 #1975\") is still caught. Deliberately silent on: a __foldDescribe title with no folded: prefix (the alias is reused for one ordinary describe in tests/review-default-reviewers-workflow.test.cjs), a plain describe(), a non-literal title, and the same marker in two DIFFERENT files (the defect class is intra-file).", - "line": 580 + "line": 586 }, { "id": "RULESET.TESTS.no-duplicate-fold-marker.why", "klass": "RULESET", "value": "consolidation epic #1969 folds are self-contained blocks, so a second verbatim copy parses, registers and PASSES twice — nothing reports it; #3271 found 25 such copies (~5,800 lines) in tests/install.test.cjs (18), tests/install-minimal-hooks.test.cjs (5) and tests/install-write-confinement.test.cjs (2), all from one stale-base re-application in 6d072435d (#1975 re-applying #1970's hunks, 2026-07-03). Ref DEFECT.GENERATIVE-FIX: the two copies drift apart silently when a contributor fixes one and leaves the other asserting the old behavior, with the suite still green.", - "line": 581 + "line": 587 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 577 + "line": 583 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 578 + "line": 584 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 579 + "line": 585 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, error (promoted by #3331 once #3314 delivered the ADR-456 §(a) reachability rule + deterministic backfill precondition); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 594 + "line": 600 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 596 + "line": 602 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 621 + "line": 627 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 610 + "line": 616 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 609 + "line": 615 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 608 + "line": 614 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 607 + "line": 613 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 603 + "line": 609 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 604 + "line": 610 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 872 + "line": 878 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 873 + "line": 879 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 874 + "line": 880 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 875 + "line": 881 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 876 + "line": 882 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 877 + "line": 883 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 878 + "line": 884 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]", - "line": 879 + "line": 885 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]", - "line": 880 + "line": 886 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 834 + "line": 840 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 831 + "line": 837 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 832 + "line": 838 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 835 + "line": 841 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 833 + "line": 839 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 657 + "line": 663 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 658 + "line": 664 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 673 + "line": 679 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 674 + "line": 680 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 659 + "line": 665 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 667 + "line": 673 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 651 + "line": 657 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 655 + "line": 661 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 654 + "line": 660 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 652 + "line": 658 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 653 + "line": 659 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 665 + "line": 671 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 666 + "line": 672 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 669 + "line": 675 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety.test.cjs", - "line": 668 + "line": 674 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 664 + "line": 670 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 663 + "line": 669 } ], "duplicates": [] diff --git a/scripts/lint-emitted-drift-ack.cjs b/scripts/lint-emitted-drift-ack.cjs index 91d44ba51..0962c8b5a 100644 --- a/scripts/lint-emitted-drift-ack.cjs +++ b/scripts/lint-emitted-drift-ack.cjs @@ -732,11 +732,24 @@ function fetchOpenPrTouchedAckPaths({ cwd = REPO_ROOT, execGh = execGhDefault, l * @param {() => Set} [opts.fetchOpenPrPaths] defaults to `fetchOpenPrTouchedAckPaths`. * Injectable so a test can supply canned open-PR data (or a throwing stub, to exercise * the fail-closed "hold everything" path) without shelling out to a real `gh`. - * @returns {{ ok: boolean, lines: string[] }} + * `sweepable` is the fragment basenames the guard would have the caller `git rm` — the + * same set the prose names, already narrowed by the #3842 open-PR hold, so a held + * fragment never appears in it. Surfaced as DATA rather than left to be scraped back out + * of `lines`, because the sweeper (#3875) must act on exactly the set the guard reasoned + * about: a sweeper that re-derives the list, or greps it out of the message text, can + * drift from the guard and delete a fragment the hold was protecting. + * `legacyPresent` is reported separately from `sweepable` because the two are different + * KINDS of cruft with the same remedy: `sweepable` holds fragment basenames under + * `ACK_DIR_REPO_PATH`, whereas the legacy document is one fixed path. Folding it into + * `sweepable` would make a consumer prefix it with the fragment directory and try to + * delete a path that does not exist. Without it the sweeper would be blind to exactly + * one of the two ways this guard can red `next`. + * @returns {{ ok: boolean, lines: string[], sweepable: string[], legacyPresent: boolean }} */ function runGuardNext({ argv = process.argv, cwd = REPO_ROOT, fetchOpenPrPaths = fetchOpenPrTouchedAckPaths } = {}) { const legacyFile = path.join(cwd, ...ACK_REPO_PATH.split('/')); - const legacy = assertAbsentOnNext(fs.existsSync(legacyFile)); + const legacyPresent = fs.existsSync(legacyFile); + const legacy = assertAbsentOnNext(legacyPresent); const lines = [legacy.message]; // The fragment half (#3078). CI always passes `--base-ref` (the pre-push tip of `next`, @@ -776,20 +789,58 @@ function runGuardNext({ argv = process.argv, cwd = REPO_ROOT, fetchOpenPrPaths = const sweep = assertNoAllSpentFragments(fragments, { openPrTouchedPaths }); lines.push(sweep.message); - return { ok: legacy.ok && sweep.ok, lines }; + return { ok: legacy.ok && sweep.ok, lines, sweepable: sweep.sweepable, legacyPresent }; } -function main() { - if (process.argv.includes('--guard-next')) { - const result = runGuardNext(); - for (const line of result.lines) console.log(line); +/** + * CLI entry. Dependencies are injectable so the `--guard-next` / `--sweep-plan` argv + * routing is testable in-process: `main()` otherwise reads `process.argv` and writes + * through `console`, and the only way to observe it would be a subprocess run against + * the REAL repository — which cannot exhibit an arbitrary sweep set on demand, so the + * interesting cases would go uncovered. + * + * `cwd`, `out` and `err` are honoured by BOTH lanes, not just the guard lane. A seam + * that is injected halfway is worse than one that is not injected at all: a test + * passing `cwd` to the validation lane would silently read the real repository and + * report on whatever happens to be checked in, which is a pass-always test wearing the + * costume of a real one. + */ +function main({ + argv = process.argv, + cwd = REPO_ROOT, + out = console.log, + err = console.error, + guard = runGuardNext, +} = {}) { + if (argv.includes('--guard-next')) { + const result = guard({ argv }); + + // `--sweep-plan` (#3875) turns the guard from a VERDICT into a WORK LIST. The + // sweeper workflow needs the exact set the guard reasoned about, so the plan goes + // to stdout alone and the prose is diverted to stderr — a caller doing + // `xargs git rm` on stdout must never receive an explanatory sentence as a + // filename. Plan mode also exits 0 even when the verdict is a failure: a + // non-empty plan is the NORMAL case it exists to report, and a non-zero exit + // would fail the workflow step before it could act on the very list it asked for. + if (argv.includes('--sweep-plan')) { + for (const line of result.lines) err(line); + // The legacy document leads the plan: it is a fixed path rather than a name + // under the fragment directory, and `assertAbsentOnNext` reds `next` on its + // PRESENCE alone. Omitting it would leave the sweeper able to fix only one of + // the two conditions that make this guard fail. + if (result.legacyPresent) out(ACK_REPO_PATH); + for (const name of result.sweepable) out(`${ACK_DIR_REPO_PATH}/${name}`); + return; + } + + for (const line of result.lines) out(line); if (!result.ok) process.exitCode = 1; return; } - const legacyFile = path.join(REPO_ROOT, ...ACK_REPO_PATH.split('/')); + const legacyFile = path.join(cwd, ...ACK_REPO_PATH.split('/')); - const fragmentsDir = path.join(REPO_ROOT, ...ACK_DIR_REPO_PATH.split('/')); + const fragmentsDir = path.join(cwd, ...ACK_DIR_REPO_PATH.split('/')); const sources = [ { label: ACK_REPO_PATH, raw: readIfPresent(legacyFile) }, ...listFragmentFiles(fragmentsDir).map((name) => ({ @@ -835,9 +886,9 @@ function main() { } if (problems.length) { - console.error(`lint-emitted-drift-ack: ${problems.length} problem(s)\n`); - for (const e of problems) console.error(` - ${e}`); - console.error( + err(`lint-emitted-drift-ack: ${problems.length} problem(s)\n`); + for (const e of problems) err(` - ${e}`); + err( '\nThis blocks the merge on purpose. The base-side reader fails loudly on a document ' + 'it cannot parse, so a broken one on the base branch reds every PR that carries an ' + 'acknowledgment, and a duplicate across two sources is exactly the silent-drift class ' @@ -847,7 +898,7 @@ function main() { return; } - console.log( + out( anyPresent ? 'ok lint-emitted-drift-ack: all acknowledgment sources are well-formed' : 'ok lint-emitted-drift-ack: no acknowledgment sources present (the healthy steady state)', @@ -881,4 +932,5 @@ module.exports = { GITHUB_MAX_PR_FILES, GH_TIMEOUT_MS, runGuardNext, + main, }; diff --git a/tests/emitted-attribution.test.cjs b/tests/emitted-attribution.test.cjs index 26a58c8e8..4d473691d 100644 --- a/tests/emitted-attribution.test.cjs +++ b/tests/emitted-attribution.test.cjs @@ -91,6 +91,7 @@ const { MAX_PR_FILES, GITHUB_MAX_PR_FILES, runGuardNext, + main, } = require('../scripts/lint-emitted-drift-ack.cjs'); const { ACK_VERSION, @@ -4720,3 +4721,322 @@ describe('#3842: runGuardNext wires --defer-to-open-prs end to end against a rea } }); }); + +// ── #3875: the sweep set as DATA, and the --sweep-plan work-list mode ─────── +// +// `next` sat red for 24 consecutive pushes (2026-08-24 → 2026-08-26) on two +// all-spent fragments nobody swept. The guard computed the right answer every +// time; what was missing was a way for anything except a human reading CI prose +// to ACT on it. #3823 shipped the guard already-failing for the same reason: its +// own sweep was a static list of deletions fixed at branch time, and #3809's +// fragment merged while it was in flight, so the guard reds on its own merge +// commit. A sweeper has to read the set the guard actually reasoned about — not +// re-derive it, and not scrape it back out of the message text. + +describe('#3875: runGuardNext surfaces the sweepable set as data', () => { + test('sweepable names the spent fragment the prose names', () => { + const repo = makeGuardNextRepo(); + try { + repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); + const c2 = repo.commit('add fragment'); + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); + repo.commit('unrelated change'); + + const result = runGuardNext({ + argv: ['node', 'script', '--guard-next', '--base-ref', c2], + cwd: repo.dir, + }); + assert.ok(!result.ok, 'the fully-spent fragment must fail the guard'); + assert.deepEqual(result.sweepable, ['a.json']); + } finally { + cleanup(repo.dir); + } + }); + + test('a fragment held by the #3842 open-PR deferral never appears in sweepable', () => { + const repo = makeGuardNextRepo(); + try { + repo.writeFrag('a.json', { version: ACK_VERSION, paths: { 'x.md': { reason: 'why' } } }); + const c2 = repo.commit('add fragment'); + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); + repo.commit('unrelated change'); + + const result = runGuardNext({ + argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], + cwd: repo.dir, + fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/a.json`]), + }); + assert.ok(result.ok, 'a held fragment must not fail the guard'); + assert.deepEqual( + result.sweepable, [], + 'a sweeper acting on this list must never delete a fragment the hold was protecting', + ); + } finally { + cleanup(repo.dir); + } + }); + + test('a clean tree yields an empty sweepable set', () => { + const repo = makeGuardNextRepo(); + try { + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); + const c1 = repo.commit('root'); + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'again\n'); + repo.commit('second'); + + const result = runGuardNext({ + argv: ['node', 'script', '--guard-next', '--base-ref', c1], + cwd: repo.dir, + }); + assert.ok(result.ok); + assert.deepEqual(result.sweepable, []); + } finally { + cleanup(repo.dir); + } + }); +}); + +describe('#3875: --sweep-plan emits a work list, not a verdict', () => { + // `main()` reads process.argv and writes through console, so its argv routing is + // observable in-process only via the injected seams. A subprocess run against the + // real repository cannot exhibit an arbitrary sweep set on demand, which is exactly + // the interesting case. + const runMain = (argv, guardResult) => { + const out = []; + const err = []; + const saved = process.exitCode; + process.exitCode = undefined; + try { + main({ + argv, + out: (line) => out.push(String(line)), + err: (line) => err.push(String(line)), + guard: () => guardResult, + }); + return { out, err, exitCode: process.exitCode }; + } finally { + process.exitCode = saved; + } + }; + + const spent = { + ok: false, + lines: ['guard prose line'], + sweepable: ['a.json', 'b.json'], + legacyPresent: false, + }; + + test('the plan is repo-relative fragment paths on stdout, one per line', () => { + const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent); + assert.deepEqual(r.out, [ + `${ACK_DIR_REPO_PATH}/a.json`, + `${ACK_DIR_REPO_PATH}/b.json`, + ]); + }); + + test('the guard prose is diverted to stderr so stdout is safe to pipe', () => { + const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent); + assert.deepEqual( + r.err, ['guard prose line'], + 'a caller doing `xargs git rm` on stdout must never receive a sentence as a filename', + ); + }); + + test('a non-empty plan still exits 0 — a work list is not a failure', () => { + const r = runMain(['node', 'script', '--guard-next', '--sweep-plan'], spent); + // `equal(..., undefined)`, not `notEqual(..., 1)`: the weaker form passes for + // ANY value that is not 1, so an implementation setting exitCode to 2 — or to + // anything at all — would survive it. + assert.equal( + r.exitCode, undefined, + 'plan mode must leave the exit code untouched; a non-zero exit would fail the sweeper step before it could act on the list it asked for', + ); + }); + + test('a clean tree emits an empty plan, still reports the prose, and exits 0', () => { + const r = runMain( + ['node', 'script', '--guard-next', '--sweep-plan'], + { ok: true, lines: ['all clear'], sweepable: [], legacyPresent: false }, + ); + assert.deepEqual(r.out, []); + // Asserting only the empty stdout would be vacuous — it holds for an + // implementation that emits nothing anywhere. The prose must still reach + // stderr, or a silent run is indistinguishable from a broken one. + assert.deepEqual(r.err, ['all clear']); + assert.equal(r.exitCode, undefined); + }); + + test('without --sweep-plan the guard lane is unchanged: prose on stdout, exit 1', () => { + const r = runMain(['node', 'script', '--guard-next'], spent); + assert.deepEqual(r.out, ['guard prose line']); + assert.deepEqual(r.err, []); + assert.equal(r.exitCode, 1, 'the verdict lane must keep failing on a surviving spent fragment'); + }); + + test('the legacy ack document leads the plan when present', () => { + const r = runMain( + ['node', 'script', '--guard-next', '--sweep-plan'], + { ok: false, lines: ['prose'], sweepable: ['a.json'], legacyPresent: true }, + ); + assert.deepEqual(r.out, [ACK_REPO_PATH, `${ACK_DIR_REPO_PATH}/a.json`]); + }); + + test('the legacy ack document is absent from the plan when it is not on the tree', () => { + const r = runMain( + ['node', 'script', '--guard-next', '--sweep-plan'], + { ok: false, lines: ['prose'], sweepable: ['a.json'], legacyPresent: false }, + ); + assert.deepEqual(r.out, [`${ACK_DIR_REPO_PATH}/a.json`]); + }); + + test('--sweep-plan without --guard-next falls through to the validation lane', () => { + const repo = makeGuardNextRepo(); + try { + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); + repo.commit('root'); + const out = []; + const err = []; + main({ + argv: ['node', 'script', '--sweep-plan'], + cwd: repo.dir, + out: (line) => out.push(String(line)), + err: (line) => err.push(String(line)), + }); + // `--sweep-plan` is meaningful only alongside `--guard-next`. Pinned so the + // routing cannot quietly change into "plan mode implies guard mode", which + // would make a bare --sweep-plan print a work list computed against a base + // ref nobody asked for. + assert.equal(err.length, 0); + assert.equal(out.length, 1); + assert.ok(out[0].startsWith('ok lint-emitted-drift-ack:'), `got: ${out[0]}`); + } finally { + cleanup(repo.dir); + } + }); +}); + +describe('#3875: the legacy document and the fragment directory are separate sweep inputs', () => { + test('runGuardNext reports a revived legacy ack document as present', () => { + const repo = makeGuardNextRepo(); + try { + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); + const c1 = repo.commit('root'); + const legacy = path.join(repo.dir, ...ACK_REPO_PATH.split('/')); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.writeFileSync(legacy, JSON.stringify({ version: ACK_VERSION, paths: {} })); + repo.commit('revive the legacy file'); + + const result = runGuardNext({ + argv: ['node', 'script', '--guard-next', '--base-ref', c1], + cwd: repo.dir, + }); + assert.equal(result.ok, false, 'a legacy ack document on next must fail the guard'); + assert.equal( + result.legacyPresent, true, + 'without this the sweeper is blind to one of the two ways this guard reds next', + ); + } finally { + cleanup(repo.dir); + } + }); + + test('runGuardNext reports legacyPresent false on a tree that has none', () => { + const repo = makeGuardNextRepo(); + try { + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); + const c1 = repo.commit('root'); + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'again\n'); + repo.commit('second'); + + const result = runGuardNext({ + argv: ['node', 'script', '--guard-next', '--base-ref', c1], + cwd: repo.dir, + }); + assert.equal(result.legacyPresent, false); + } finally { + cleanup(repo.dir); + } + }); + + // The live shape on `next` when #3875 was written: four all-spent fragments, two + // of them held by an open PR. An implementation that always returned an empty + // `sweepable` would pass the all-held and clean-tree cases; only a mixed one + // proves the partition is real. + test('a mixed tree partitions into swept and held, and reports both', () => { + const repo = makeGuardNextRepo(); + try { + repo.writeFrag('held.json', { version: ACK_VERSION, paths: { 'h.md': { reason: 'held' } } }); + repo.writeFrag('sweep.json', { version: ACK_VERSION, paths: { 's.md': { reason: 'sweep' } } }); + const c2 = repo.commit('add two fragments'); + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'unrelated\n'); + repo.commit('unrelated change'); + + const result = runGuardNext({ + argv: ['node', 'script', '--guard-next', '--base-ref', c2, '--defer-to-open-prs'], + cwd: repo.dir, + fetchOpenPrPaths: () => new Set([`${ACK_DIR_REPO_PATH}/held.json`]), + }); + assert.deepEqual(result.sweepable, ['sweep.json'], 'only the unheld fragment is sweepable'); + assert.ok(!result.ok, 'an unheld all-spent fragment still fails the guard'); + const prose = result.lines.join('\n'); + assert.ok(prose.includes('held.json') && prose.includes('held'), 'the held fragment must still be reported, not silently dropped'); + assert.ok(prose.includes('git rm') && prose.includes('sweep.json'), 'the sweepable fragment must name its remedy'); + } finally { + cleanup(repo.dir); + } + }); + + test('main() honours injected cwd and out in the validation lane, not just the guard lane', () => { + const repo = makeGuardNextRepo(); + try { + fs.writeFileSync(path.join(repo.dir, 'README.md'), 'root\n'); + repo.commit('root'); + const out = []; + const err = []; + main({ + argv: ['node', 'script'], + cwd: repo.dir, + out: (line) => out.push(String(line)), + err: (line) => err.push(String(line)), + }); + // A half-injected seam is worse than none: with `cwd` ignored this reads the + // REAL repository and reports on whatever is checked in, which passes for + // reasons entirely unrelated to the fixture. + assert.equal(err.length, 0, 'a clean fixture tree has no problems to report'); + assert.equal(out.length, 1, 'exactly one summary line, and on the injected sink'); + assert.ok(out[0].includes('no acknowledgment sources present'), `got: ${out[0]}`); + } finally { + cleanup(repo.dir); + } + }); + + test('main() reports a malformed fragment through the injected err sink', () => { + const repo = makeGuardNextRepo(); + try { + repo.writeFrag('bad.json', {}); + fs.writeFileSync( + path.join(repo.dir, ...ACK_DIR_REPO_PATH.split('/'), 'bad.json'), + 'not json at all', + ); + repo.commit('add a malformed fragment'); + const out = []; + const err = []; + const saved = process.exitCode; + process.exitCode = undefined; + try { + main({ + argv: ['node', 'script'], + cwd: repo.dir, + out: (line) => out.push(String(line)), + err: (line) => err.push(String(line)), + }); + assert.ok(err.length > 0, 'the malformed fragment must reach the injected err sink'); + assert.ok(err.join('\n').includes('bad.json'), 'the report must name the offending fragment'); + } finally { + process.exitCode = saved; + } + } finally { + cleanup(repo.dir); + } + }); +}); diff --git a/tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json b/tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json deleted file mode 100644 index a7c42e26c..000000000 --- a/tests/emitted-drift-acks/3809-gsd-run-launcher-normalization.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$comment": "Growth ack (#2914 fragment). Reason: #3809 routes every command-position shim reference in runtime-loaded markdown through the canonical gsd_run launcher. The substitution SHRANK 19 emitted files; this is the only one that grew. Its line 65 is a descriptive comment inside a fenced block, and rewriting it to name a command at all would place a gsd_run token ahead of the file's canonical preamble at line 158, which runtime-launcher-parity's (B-agents) arm correctly rejects. The comment therefore names no command and explains where the config is actually loaded instead, costing 3 bytes. gsd-research-synthesizer.md 13847 -> 13850 LF bytes (+3).", - "version": 1, - "paths": { - "gsd-research-synthesizer.md": { - "reason": "descriptive comment reworded to name no command, so the file's first gsd_run token stays behind its canonical preamble (runtime-launcher-parity B-agents); +3 bytes" - } - } -} diff --git a/tests/emitted-drift-acks/3866-verify-pre-dispatch-arms.json b/tests/emitted-drift-acks/3866-verify-pre-dispatch-arms.json deleted file mode 100644 index 67f5bd5a9..000000000 --- a/tests/emitted-drift-acks/3866-verify-pre-dispatch-arms.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$comment": "Growth ack (#2914 fragment). Reason: #3866 opens the verify lane to capabilities. verify-work.md's verify_pre_hooks step dispatched `kind == \"gate\"` only, so getWiredKinds reported verify:pre -> {gate} and gen-capability-registry's validateHooksWired rejected any capability declaring a step or contribution there — a capability could refuse to let UAT start but never contribute to what UAT covers. The growth is the two new dispatch arms (contribution + step, deferring to gsd-core/references/loop-hook-dispatch.md and carrying its ref.command in-context validation guard) plus the additive extract_tests consumption seam for the produces[] artefact names those steps declare. Prose is the product here: the arms ARE the dispatch contract an executing agent reads, so there is no smaller form. Includes the in-context allowlist for manifest-supplied produces names (isolated security review) and the artefact-shape contract (spec review), both of which a capability author must be able to read at the point of use. verify-work.md 35973 -> 39212 LF bytes (+3239).", - "version": 1, - "paths": { - "verify-work.md": { - "reason": "#3866: verify:pre gains contribution + step dispatch arms and extract_tests gains the produces[] consumption seam with its in-context name allowlist and artefact-shape contract; the dispatch contract is executable prose, so the arms cannot be expressed shorter; +3239 bytes" - } - } -} diff --git a/tests/emitted-drift-acks/README.md b/tests/emitted-drift-acks/README.md index 94833de49..d928b3eb8 100644 --- a/tests/emitted-drift-acks/README.md +++ b/tests/emitted-drift-acks/README.md @@ -21,11 +21,24 @@ survives the sweep that empties it. unattributable **hash** ripple is keyed on the emitted path (`skills/gsd-add-tests/SKILL.md`); **growth** is keyed on the bare filename as it appears under `gsd-core/workflows/` or `agents/` (`explore.md`). -3. **Delete the fragment once it has merged (#3078).** Every entry is scoped to - the diff that introduced it, so the moment it lands on `next` its prose is +3. **The fragment is deleted once it has merged (#3078).** Every entry is scoped + to the diff that introduced it, so the moment it lands on `next` its prose is already at the base — it is spent and can no longer clear anything, while still owning its path keys. The `guard-no-ack-on-next` job reds `next` and - prints the exact `git rm`. Run it. + prints the exact `git rm` for every fully-spent fragment. + + You no longer have to run that `git rm` yourself (#3875). The + `ack-fragment-sweep` workflow runs every six hours, asks the guard for its own + sweep list (`--sweep-plan`), and opens a PR deleting exactly what the guard + named. Deleting the fragment in a follow-up PR by hand still works and is + still welcome — it is simply no longer the only thing standing between a + merged fragment and a red `next`. + + The sweep is automated because the manual remedy could not keep up. The guard + evaluates at MERGE time; a hand-authored `git rm` is fixed at BRANCH time, so + any ack-carrying PR that merges in between invalidates it. #3823 lost exactly + that race to #3809 on its own merge commit and left `next` red for 24 + consecutive pushes. ## Why the sweep exists