* 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 <sim@local>
4.2 KiB
tests/emitted-drift-acks/
Per-PR acknowledgment fragments for the differential attribution check
(tests/emitted-attribution.test.cjs, ADR-2719 / #2789 / #2914).
This directory being empty is the healthy steady state. A fragment appearing
in a diff is the alarm; a fragment sitting here on next is spent cruft.
README.md is not a fragment — every reader filters on .json — and exists so
the directory (which CONTEXT.md and CONTRIBUTING.md both reference by path)
survives the sweep that empties it.
The lifecycle, in three steps
-
The gate names its own remedy. When an emitted-artifact hash moves, or a
gsd-core/workflows/*.md/agents/gsd-*.mdfile grows, and your diff cannot explain it, the failure output tells you which key to use and prints a minimal valid document to paste. Create a NEW fragment named for your issue or PR (<NNNN>-<slug>.json) — never reuse someone else's, and never revive the legacy singletests/emitted-drift-ack.json. -
Note the two key spaces. The message says which one applies. An 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 undergsd-core/workflows/oragents/(explore.md). -
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
nextits prose is already at the base — it is spent and can no longer clear anything, while still owning its path keys. Theguard-no-ack-on-nextjob redsnextand prints the exactgit rmfor every fully-spent fragment.You no longer have to run that
git rmyourself (#3875). Theack-fragment-sweepworkflow 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 rednext.The sweep is automated because the manual remedy could not keep up. The guard evaluates at MERGE time; a hand-authored
git rmis 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 leftnextred for 24 consecutive pushes.
Why the sweep exists
Fragments end the file conflict the single shared document caused. They do not end the key conflict: two ack sources may never name the same path, and that is a hard, loudly-reported error. So a fully-spent fragment left here walls off every path it owns — the next PR to grow one of them can declare it neither in the owning fragment (spent, gates nothing) nor in its own (duplicate). #2914 assumed a persisting fragment was harmless; #3078 measured the cost at 45 fragments owning 403 paths and made the guard sweep them.
A partially spent fragment is deliberately left alone. That asymmetry is what keeps the re-arm route working: appending prose to a live entry re-arms it, and re-arming deliberately costs an actual new sentence — the comparison strips zero-width characters and collapses whitespace precisely so a zero-information edit cannot fake one.
Never pin a fragment in a test
A fragment is deleted the moment it has merged (see step 3 above), so any test
that asserts one exists, or asserts its contents, will fail the instant
guard-no-ack-on-next sweeps it — and that failure has nothing to do with the
behavior the fragment once explained. This has already cost two suites:
tests/emitted-attribution.test.cjs's three #2914 migration pins, and
tests/agent-tracked-source-rule.test.cjs's #3645 growth-ack pin. What a test
may legitimately assert is the BEHAVIOR the ack explains, or the guard's own
verdict (assertNoAllSpentFragments, assertAbsentOnNext) — never the
paperwork.
Do not regenerate anything
There is no baseline file to re-run a generator over; #2724 deleted it. If you find yourself hunting for one, that is the predictable wrong guess.
See CONTRIBUTING.md → "Editing shipped content", docs/TESTING-SUITES.md, and
CONTEXT.md's RULESET.EMITTED_ATTRIBUTION for the full model.