@holmes-lab/holmes-kit 0.25.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +10 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.js +7 -26
  5. package/dist/holmes/cli/doctor.js +53 -10
  6. package/dist/holmes/cli/index.js +8 -1
  7. package/dist/holmes/cli/release-docs.d.ts +27 -0
  8. package/dist/holmes/cli/release-docs.js +45 -0
  9. package/dist/holmes/config/config.d.ts +11 -0
  10. package/dist/holmes/config/config.js +11 -1
  11. package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
  12. package/dist/holmes/cpg/cycle-observation.js +25 -2
  13. package/dist/holmes/cpg/cycle-report.d.ts +35 -0
  14. package/dist/holmes/cpg/cycle-report.js +74 -0
  15. package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
  16. package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
  17. package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
  18. package/dist/holmes/cpg/forbidden-edges.js +135 -2
  19. package/dist/holmes/cpg/import-resolver.d.ts +12 -0
  20. package/dist/holmes/cpg/import-resolver.js +215 -0
  21. package/dist/holmes/cpg/language-capability.d.ts +14 -0
  22. package/dist/holmes/cpg/language-capability.js +37 -13
  23. package/dist/holmes/cpg/proposed-content.d.ts +7 -1
  24. package/dist/holmes/cpg/proposed-content.js +7 -0
  25. package/dist/holmes/governance/approval-queue.d.ts +33 -0
  26. package/dist/holmes/governance/approval-queue.js +85 -0
  27. package/dist/holmes/hooks/pre-tool-use.js +8 -1
  28. package/dist/holmes/hooks/session-start.js +39 -0
  29. package/dist/holmes/hooks/stop.d.ts +38 -2
  30. package/dist/holmes/hooks/stop.js +169 -50
  31. package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
  32. package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
  33. package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
  34. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +7 -5
  35. package/dist/holmes/mcp/handlers/spec-lifecycle.js +15 -2
  36. package/dist/holmes/mcp/handlers.d.ts +7 -5
  37. package/dist/holmes/project/report-briefing.d.ts +48 -0
  38. package/dist/holmes/project/report-briefing.js +70 -0
  39. package/dist/holmes/project/resolved-reports.d.ts +20 -0
  40. package/dist/holmes/project/resolved-reports.js +7 -0
  41. package/dist/holmes/project/root.js +11 -1
  42. package/dist/holmes/rtm/rtm-builder.js +6 -141
  43. package/dist/holmes/spec/id-collision.d.ts +98 -0
  44. package/dist/holmes/spec/id-collision.js +149 -1
  45. package/dist/holmes/spec/remote-spec-refs.d.ts +16 -0
  46. package/dist/holmes/spec/remote-spec-refs.js +148 -0
  47. package/dist/holmes/spec/renumber.d.ts +12 -2
  48. package/dist/holmes/spec/renumber.js +25 -6
  49. package/package.json +1 -1
  50. package/playbooks/publish/PLAYBOOK.md +18 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,139 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.26.1] - 2026-09-20
9
+
10
+ Three judgements that were computed and reached nobody now reach a person, and a spec number another
11
+ machine took is seen before the merge instead of after the conflict. Work from two machines: the
12
+ Windows checkout found that the renumber tool had been dead there, and lost a slice to the collision
13
+ that tool exists to repair.
14
+
15
+ ### Added
16
+ - **A spec id another workspace took is seen BEFORE the merge** (A-SPEC-254.2, A-SPEC-700, A-SPEC-700.1).
17
+ `doctor --target` gains `spec id collision with remote refs`. Measured 2026-09-20: two machines
18
+ allocated `REQ-694` thirteen minutes apart, neither could see the other, and it surfaced only as an
19
+ add/add conflict after the push was refused — costing a rebuilt slice and five out-of-band
20
+ approvals. The check reads each remote-tracking ref **as last fetched** (no network; `fetch` stays
21
+ yours) and judges only documents each side **added** since they parted: a spec one side merely
22
+ *edited* is ordinary divergence, not a collision, so this does not cry wolf before every pull. On a
23
+ collision the advice is the plan, not a shrug —
24
+ `Before merging — move 901 → 903: spec_renumber(oldBase=901, newBase=903); then re-seal …`.
25
+ The side that moves is the one **not published yet** (a public number may already be cited), and
26
+ the destination is free on **both** sides. "Published" is judged by equal content, never by path:
27
+ a spec's path is derived from its number, so a colliding number is on the remote by definition.
28
+ No git, or no remote, is a PASS that says so.
29
+ - **`architecture.cycleIgnore` in `ax.config.json`** (A-SPEC-694). The named exception the cycle
30
+ ratchet promised in 0.19 finally has a place to be written: path prefixes whose cycles are not the
31
+ project's to fix (vendored trees). A cycle is excepted only when **every** member lies under one;
32
+ one that straddles project code is still reported. `reference` does not pardon `reference-impl/`,
33
+ a prefix meaning "everything" is dropped, and a broken config excepts nothing. Excepted cycles stay
34
+ in the observation ledger, counted.
35
+ - **The release gate says how far the GitHub README has fallen behind** (A-SPEC-696). Syncing the
36
+ README that npm's `homepage` points at is a playbook step that runs *after* the publish, so no gate
37
+ could see it — and measured 2026-09-20 it had been skipped for seventeen README-changing releases.
38
+ The *next* release's gate now compares the remote README with each release tag's and reports
39
+ `matches v0.19.3 — 17 release(s) with README changes behind`, `diverged`, or `could not check (…)`.
40
+ Report only: nothing in that block can refuse a release.
41
+
42
+ ### Fixed
43
+ - **The cycle ratchet had never spoken to anyone** (A-SPEC-694). Since the commit that introduced it,
44
+ the Stop hook computed the code cycles, wrote them to the observation ledger, and then called the
45
+ verdict **without them** — so the `ART-2` tracked line was dead code in the product, and 0.26.0's
46
+ widening of the ratchet to eight language families landed on the same dead path. Every unit test
47
+ passed because each handed the evidence in directly; the RED for this fix runs the *built hook as a
48
+ process*. The line is now emitted, bounded (five cycles, five members each, the rest **counted**):
49
+ measured here, one vendored cycle has 46 members. Still non-blocking — the hook supplies `track`.
50
+ - **`npm publish --dry-run` could not pass the release gate** (A-SPEC-695). npm exports
51
+ `npm_config_dry_run=true` to `prepublishOnly`; the gate handed it to jest; and the suite's own
52
+ `npx -p <tarball>` then installed nothing, so a green tree was refused as "not publishable". The
53
+ evidence environment now drops that one key — named, not a `npm_config_*` sweep, which would take
54
+ the registry and proxy settings with it.
55
+ - **`spec_renumber` was dead on Windows** (A-SPEC-699). The frontmatter fence and the `depends_on`
56
+ block were LF-only and `core.autocrlf=true` makes every spec CRLF, so the tool read **zero** specs
57
+ and reported "nothing to move" — indistinguishable from an empty store. It now reads them, and its
58
+ refusal carries how many it could not read.
59
+ - doctor's id-collision advice still read "manual until REQ-255" after REQ-255 had shipped; it names
60
+ `spec_renumber` now (A-SPEC-700.1).
61
+
62
+ ### Changed
63
+ - Two Windows reds were the tests' own defects, not the product's (A-SPEC-697, A-SPEC-698): an
64
+ overlap fixture matched a `/`-joined suffix against a `path.join` path, so the refusal it exists to
65
+ assert had never been exercised on win32; and a supervisor teardown now retries the directory
66
+ removal Windows briefly holds after the owning process is gone.
67
+ - A-SPEC-692's `breaking_change` said "tests only" while its own Files to Touch listed three product
68
+ handlers; corrected to `gate-behavior` and re-sealed. A-SPEC-700's chain was corrected to describe
69
+ a planner over the existing detector rather than a second detector.
70
+
71
+ ## [0.26.0] - 2026-09-20
72
+
73
+ Architecture you declare is now judged in every language the graph can follow, and the verdict
74
+ reaches a person. Five of these slices were authored on Windows and merged; their spec numbers
75
+ moved from REQ-674–678 to REQ-688–692 because `main` had already published those numbers.
76
+
77
+ ### Added
78
+ - **`inherits` joins the forbidden edges** (A-SPEC-691). A C-SPEC's `## Forbidden Edges` takes a
79
+ third rule kind beside `import` and `call`: `- inherits src/app -x-> ConcreteBase`. It is an EXACT
80
+ match, for the reason `call` is — a prefix would let one `Base` swallow every `Base*`. Nothing is
81
+ inferred: the rule reads the graph's `inherits` edges and never asks which class is "abstract",
82
+ which is what makes it work in Go, where interface satisfaction cannot be inferred at all. The
83
+ pre-edit gate enforces it like the other two kinds.
84
+ - **A baseline that expires** (A-SPEC-691). A project that already breaks a rule it wants to adopt
85
+ writes `- allow until 2026-09-27 : inherits src/legacy/a.ts -> LegacyBase` in the same section.
86
+ Exact on kind, file and target — never a prefix, or it becomes a way to switch the rule off. Past
87
+ its date the allowance still holds (an expiry that blocked would make the rule a barrier to
88
+ adoption) but it is reported as expired, with `(reconfirmed n since <date>)` carried along so a
89
+ twelfth renewal cannot hide. A malformed baseline line lands in `malformed`, never disappears.
90
+ - **`ARCH` lines at the end of the turn** (A-SPEC-693). The scan-wide judgement existed and nothing
91
+ in the product called it — measured: zero consumers outside the tests — so an expired baseline was
92
+ neither blocked nor seen, and a time-boxed allowance could not be told from a permanent pardon.
93
+ The Stop hook now reports, on the non-blocking `tracked` channel: violations no baseline covers,
94
+ expired baselines with their renewal count, and **which rule kind could not be judged in which
95
+ extension** (`inherits rules NOT judged in: .js, .py, .tsx`). A project that declares no rule hears
96
+ nothing and pays nothing: the judgement shares the one scan the cycle ratchet already makes
97
+ (measured here: scan 6.6 s, judgement 0.14 s).
98
+ - **A reporter hears that their defect was fixed** (A-SPEC-687). A defect filed through
99
+ `holmes-kit report` is matched, locally and once, against the list of resolved reports that ships
100
+ in the package; the session start says so. Nothing is sent anywhere.
101
+
102
+ ### Fixed
103
+ - **The cycle ratchet judged one language family out of eight** (A-SPEC-688). The Stop hook
104
+ re-implemented import resolution beside the graph builder's — relative specifiers only, four
105
+ extensions. Measured on fixtures shaped the way each language requires: 16 import edges, 2
106
+ survived; 9 planted cycles, 1 found. It cost nothing in an all-TypeScript tree (919 edges either
107
+ way), which is how it survived, and everything in a Java, Go, Python or Rust consumer. There is now
108
+ ONE resolver (`cpg/import-resolver`), consumed by both. A cycle's member list is capped per cycle
109
+ with the dropped count declared (`filesOmitted`), and its identity is computed before the cut.
110
+ - **`.cjs` relative `require()` was declared unrecoverable while being recovered** (A-SPEC-689). The
111
+ capability table under-declared, and its pin only tested the bare form, so the drift passed. Both
112
+ forms are pinned now.
113
+ - **The id a refusal printed could not be granted** (A-SPEC-690). `approve --grant <id>` resolved
114
+ references against the tracked queue alone; a shell hard-HITL refusal lives in the per-machine
115
+ refusal log, so the reference failed before the fallback that handles it could run. Id and prefix
116
+ references now resolve across both stores; a number still means only the row the operator was
117
+ shown; a prefix that collides across the stores is refused by name; an unreadable log removes
118
+ candidates rather than granting. The inbox list is unchanged.
119
+ - **The same Windows directory read as a foreign workspace** (A-SPEC-692). Three root comparisons
120
+ put one side through `realpathSync.native` and the other through the JS `realpathSync`, which
121
+ leaves an 8.3 alias (`SUNGNA~1`) spelled as it arrived. Both sides go through one canonicaliser.
122
+ - **A kind-blind `scope`** (A-SPEC-693). With an `import` rule beside an `inherits` rule, an
123
+ extension that showed only imports read as *judged*. Scope is now asked per rule kind
124
+ (`scopeByKind`), over the files a rule of that kind actually judges; the flat lists fold toward the
125
+ honest side and are unchanged when one kind is in play.
126
+ - **Lazy `require` stopped being recorded for plain JavaScript** (A-SPEC-693). Gating the edge
127
+ classifier on "does this language erase types" also switched it off for `.js/.cjs/.mjs/.jsx`.
128
+
129
+ ### Changed
130
+ - Windows test baseline (A-SPEC-692): child-boundary waits ride the measured time factor, repository
131
+ files are read CRLF-tolerantly, and permission-bit assertions run only where the filesystem keeps
132
+ the bit — reported as skipped, with a reason, where it does not (A-SPEC-693).
133
+
134
+ ### Known
135
+ - The cycle ratchet's own `tracked` line has never been emitted: the Stop hook computes the cycles
136
+ and records the observation, but does not hand them to the verdict. Found while wiring the `ARCH`
137
+ lines; not fixed here because in `strict` mode the fix introduces a new block. This repository
138
+ currently has 3 runtime cycles nobody is shown.
139
+ - Two Windows-only reds remain (`entity-renumber-transaction` race, `supervisor-liveness` EPERM).
140
+
8
141
  ## [0.25.1] - 2026-09-19
9
142
 
10
143
  Test-only. No product behaviour changed; `dist/` behaves exactly as 0.25.0.
package/README.md CHANGED
@@ -16,18 +16,25 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 🔢 **A spec number another machine took is seen before the merge** *(new in 0.26.1)*: two checkouts allocated `REQ-694` thirteen minutes apart, neither could see the other, and it surfaced only as an add/add conflict after the push was refused — a rebuilt slice and five out-of-band approvals. `doctor --target` now reads each remote-tracking ref **as last fetched** (no network) and judges only the documents each side *added* since they parted, so a spec one side merely edited is divergence, not an alarm. On a collision the advice is the plan: `Before merging — move 901 → 903: spec_renumber(oldBase=901, newBase=903); then re-seal`. The side that moves is the one not published yet, the destination is free on both sides, and "published" means equal content — never path, because a spec's path is its number.
20
+ - 🔁 **The cycle ratchet finally speaks — and takes a named exception** *(new in 0.26.1)*: since the commit that introduced it, the Stop hook computed the code cycles and called the verdict without them, so the `ART-2` line had never been printed; every unit test passed because each handed the evidence in directly. It is emitted now, bounded (five cycles, five members each, the rest counted — one vendored cycle here has 46 members), and `ax.config.json` gains `architecture.cycleIgnore`: prefixes for trees that are not yours to fix. A cycle is excepted only when every member lies under one, `reference` does not pardon `reference-impl/`, a broken config excepts nothing, and excepted cycles stay counted in the ledger.
21
+ - 🧬 **Declare your architecture, in every language the graph can follow** *(new in 0.26.0)*: a C-SPEC's `## Forbidden Edges` takes a third rule kind beside `import` and `call` — `- inherits src/app -x-> ConcreteBase`, an exact match enforced at the pre-edit gate. Nothing is inferred: the rule reads the graph's `inherits` edges and never asks which class is "abstract", which is what makes it hold in Go, where interface satisfaction cannot be inferred at all. A project that already breaks the rule it wants adopts it with a **baseline that expires** — `- allow until 2026-09-27 : inherits src/legacy/a.ts -> LegacyBase`, exact on kind, file and target, never a prefix. Past its date the allowance still holds, because an expiry that blocked would turn the rule into a barrier to adoption; it is **reported** instead, carrying `(reconfirmed n since <date>)` so a twelfth renewal cannot hide.
22
+ - 🏛️ **The architecture verdict reaches the person closing the turn** *(new in 0.26.0)*: the scan-wide judgement existed and nothing in the product called it — measured, zero consumers outside the tests — so an expired baseline was neither blocked nor seen, and a time-boxed allowance could not be told from a permanent pardon. The Stop hook now prints `ARCH` lines on the non-blocking `tracked` channel: violations no baseline covers, expired baselines with their renewal count, and **which rule kind could not be judged in which extension** (`inherits rules NOT judged in: .js, .py, .tsx`) — asked per kind, over the files a rule of that kind actually judges, because a pooled answer called a language *judged* for a rule it could not see. No rule declared, nothing said and nothing paid: the judgement shares the one scan the cycle ratchet already makes (measured here: scan 6.6 s, judgement 0.14 s).
23
+ - ♻️ **The cycle ratchet judges every language it can resolve** *(new in 0.26.0)*: the Stop hook had re-implemented import resolution beside the graph builder's — relative specifiers only, four extensions. Measured on fixtures shaped the way each language requires: 16 import edges, 2 survived; 9 planted cycles, 1 found. It cost nothing in an all-TypeScript tree (919 edges either way), which is how it survived to 0.25, and everything in a Java, Go, Python or Rust consumer. There is now **one** resolver, consumed by both; a language whose specifiers do not resolve stays `unavailable` rather than reading as clean, and a wide Go package cycle is capped per cycle with the dropped members counted.
24
+ - 🔑 **The id a refusal prints can be granted** *(new in 0.26.0)*: `approve --grant <id>` resolved references against the decision inbox alone, and a shell hard-HITL refusal lives in the per-machine refusal log — so the very id the refusal printed failed before the fallback that handles it could run, and the owner had nothing to approve. Id and prefix references now resolve across both stores. A **number** still means only the row you were shown, a prefix that collides across the stores is refused by name, an unreadable log removes candidates rather than granting, and the inbox list is unchanged.
25
+ - 📬 **A reporter hears that their defect was fixed** *(new in 0.26.0)*: a defect filed through `holmes-kit report` is matched — locally, once — against the list of resolved reports that ships in the package, and the session start says so. Nothing is sent anywhere.
19
26
  - 🔦 **A semantic layer that is inert says so** *(new in 0.25.0)*: the tier line said what was CONFIGURED, never whether one lookup would succeed. Measured on this repository: the resolved tier was `cloud` and **0 of 602 scanned files** had a cached document vector under it, so every lookup returned nothing — reranking reordered nothing, alternates emitted nothing, and the output was byte-identical to a layer that examined everything and agreed. A naive check would have counted the 532 vectors sitting in the cache and called it healthy; they keyed on symbol lists that no longer existed. `doctor` now reports coverage against the files scanned NOW, through the runtime's own accessor, in four states — the two that cannot be judged say so rather than passing quietly.
20
27
  - 🔁 **Vectors refresh on the channel that keeps the graph fresh** *(new in 0.25.0)*: the graph self-heals through the Stop hook's detached child; the vectors did not, because warming was reachable only from an explicit `rtm_reindex`. The cache key hashes the file's path plus its symbol names, so every edit that renames a symbol invalidates that file's vector — twelve days of work had taken coverage to zero. The refresh now warms too, and the Stop hook reports the verdict the child recorded rather than scanning (tree-sitter wasm must not ride into a gate process); **a missing verdict reads as "not run", never as a pass**. Warming under the cloud tier is egress, so it is automatic AND observed, with `HOLMES_NO_SEMANTIC_WARM` to switch the transfer off — an owner who switches it off is shown a switch, not a fault. The verdict records the commit it was taken at, so work merged from another machine reports **needs re-measuring** instead of yesterday's numbers as today's truth.
21
28
  - 🎣 **The graph can introduce a candidate your words never named** *(new in 0.25.0)*: `issue_localize`'s semantic rerank could only reorder what lexical matching already found, so a question phrased in intent vocabulary never reached the file that answers it. Measured with the tier live and the vectors warm: asking *who opens a URL in the user's browser* ranked an unrelated file first on the shared prose word "repository", at 3.3× the second score, and never returned `open-url.ts` — which the graph held the whole time. The same need in mechanism vocabulary found its answer at rank 4. `semanticAlternates` now carries the top cached-vector matches among files the emission missed, and that file comes back at cosine 0.754. **Pure addition on its own field**: the ranked hits are untouched in set, order and score, because admitting candidates INTO a ranked set is where this project measured precision being lost.
22
29
  - 🪧 **The semantic ladder reaches you without running `doctor`** *(new in 0.25.0)*: measured here on 305 traceability cases, recall goes 0.486 lexical → 0.667 local → **0.887 cloud**, and on requests lexical search misses entirely, recovery goes 0% → 52% → **92%** — and a consumer never learned any of it, because only `doctor` said so. `init` now names the ladder with those numbers and the command for each, and the session banner says it **once** per workspace on a machine and then never again. `local` is named before `cloud` on purpose: `none` is the default because egress needs consent, not because nobody got to it, and the cloud line states what leaves the machine. A consumer who has already chosen hears nothing.
23
30
  - 📮 **`holmes-kit report` — a defect can reach the maintainers** *(new in 0.24.0)*: until now a consumer's holmes-kit defect had no way back to us; the one we learned about arrived because someone pasted a transcript, and it had been reproducing for every consumer on every slice. The command writes a **redacted** report to `.ax/reports/<fingerprint>.md` and prints a prefilled GitHub issue link — title, assignee, body — plus a search link for the same fingerprint so you can see whether it is already known. `--open` opens it; on a headless box or over SSH the printed link is the whole of it. **No token, no API, nothing sent automatically**: you press Submit on GitHub's own page with the body in front of you and editable. Redaction is an allowlist rather than a scrubber — this project's own remote carries a token before the `@`, its replica ids carry a person's name, and its spec titles are unreleased product intent, so a path, a credential or a machine identifier withholds the field and the report says which. A spec id passes by shape; a spec title does not. And what holmes-kit cannot know, it says: no ledger keeps its own refusal text, so a report with no description states that rather than pretending.
24
31
  - 🧭 **A skipped graph analysis is visible** *(new in 0.24.0)*: `AGENTS.md` asks for `maintenance_analyze` before editing source, and nothing checked. Measured on this repository, the step had been skipped for seventeen consecutive commits — and the run that followed named child-process precedents a name search had missed completely, because the question was "who opens a browser" while the answer lived under "who spawns a child". The Stop hook now reports source changed with no analysis standing open: non-blocking, judged by commit rather than by clock, and silent in a workspace that never adopted the habit.
25
- - 🧱 **A stale build is told, not discovered** *(new in 0.23.3)*: thirty suites in this project load `dist/` while they run, and nothing asserted that it still represented the source — only the release gate compared the build id to HEAD, and only at publish time. A stale build does not go red; it verifies old code and returns green. The Stop hook now reports it on the non-blocking `tracked` channel, judged by the **build id and never by mtime**: measured here, `.build-id` had a newer mtime than every source file while naming a commit nine behind HEAD, with two changed sources missing from `dist` entirely. A workspace that does not build hears nothing, `fresh` says nothing, and the two states that cannot be judged say **that** rather than passing quietly.
26
- - 📐 **Declarations are read as written** *(new in 0.23.2)*: `Files to Touch` is where a spec declares the files it will touch, and three things read it — fulfilment advisories, the declaration census and the approval impact note. The parser took only the **first word of a list item**, so measured over 678 approved specs here, **88 specs and 172 paths were declared and never read**; 24 of them parsed to zero while naming files plainly. Several paths on one line, an indented continuation, a Korean first word, a prose paragraph — all invisible. They are read now, wherever they sit, and a bare `name.ext` keeps its old position rule so a property access like `module.exports` is still not a file. A declared path that does not exist but is the suffix of exactly one repository file is reported as an abbreviation rather than a defect; two candidates stays an ambiguity and nothing is guessed. Cost, measured across the whole corpus: **zero** new `missing` findings.
27
- - 📄 **The publish gate reads the docs** *(new in 0.23.2)*: the publish playbook has demanded "bring README and CHANGELOG up to this release" since 0.16.0 and only prose enforced it, so it failed four times — including 0.21.0, 0.22.0 and 0.23.0, which each shipped with a feature list frozen at 0.20.0. The release gate now refuses two things it can decide: a missing CHANGELOG entry for the version being published, and an entry with `### Added` while `README.md` has not changed since the previous release. Replayed over eight releases it refuses exactly the three that were stale and passes the other five. What needs judgement — is the old wording still true? — stays with the person and is **reported**, never faked; a check that could not run says so instead of reading as a pass.
28
32
 
29
33
  **Earlier releases** — condensed to one line each; every release's full account lives in [CHANGELOG.md](CHANGELOG.md).
30
34
 
35
+ - 🧱 **A stale build is told, not discovered** *(0.23.3)*: the Stop hook reports a `dist/` that no longer represents the source, judged by the **build id and never by mtime** — a stale build does not go red, it verifies old code and returns green; the two states that cannot be judged say that rather than passing quietly.
36
+ - 📐 **Declarations are read as written** *(0.23.2)*: `Files to Touch` is read wherever a path sits — several on one line, an indented continuation, a Korean first word — after 88 specs and 172 declared paths were measured as never read; an unambiguous abbreviation is reported as one, and nothing is guessed.
37
+ - 📄 **The publish gate reads the docs** *(0.23.2)*: the release gate refuses a missing CHANGELOG entry for the version being published, and an `### Added` entry while `README.md` has not changed since the previous release; what needs judgement stays with the person and is reported, never faked.
31
38
  - 🧩 **Your config files survive a re-wire** *(0.23.0)*: `init --agent antigravity|codex` refreshes only the holmes-kit entry instead of replacing `.agents/mcp_config.json`, `hooks.json` and `marketplace.json` whole — a neighbour server, hook, plugin and your own `disabled` flag all survive, and an unreadable JSON file is refused with a reason rather than overwritten.
32
39
  - 🫀 **The MCP supervisor notices a child that died** *(0.23.0)*: a crashed child used to leave the server **permanently deaf**; outstanding requests now get a JSON-RPC error first, then the supervisor resets, respawns and replays the opening exchange — with a restart budget spent only by a child that never answered.
33
40
  - 🧮 **Coverage you can explain** *(0.23.0, corrected in 0.23.2)*: the RTM census says *what each unlinked spec declared* (`scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration`). The "zero" 0.23.0 published here was the Files-to-Touch parser's, not the corpus's — the corrected census reads three, with five real trace gaps behind it.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 6277a6d6-mu7vpg1b
1
+ 256f1396-mu9du216
@@ -100,32 +100,13 @@ const findPending = (root, id) => {
100
100
  // — exactly the "grant is for what the human saw" contract. A decided id never promotes: the
101
101
  // granted/denied event in the queue closes the request, same single-use convention as pending.
102
102
  try {
103
- const { readRefusals } = require('../governance/approval-queue');
104
- let mine = readRefusals(root).filter((r) => r.id === id);
105
- // A decided id closes the request UNLESS the gate refused it again afterwards. The fold has
106
- // the same rule for pending (a later `requested` re-inserts after granted/denied): a human may
107
- // change their mind, and the retry is what re-opens the question.
108
- const decided = state.decisions[id];
109
- if (decided) {
110
- // Clock-free reopening: the decision recorded HOW MANY occurrences it covered, so a retry is
111
- // simply everything past that count — millisecond ties (measured flaky under the full suite)
112
- // cannot lose it. Legacy decisions without the count fall back to the timestamp rule.
113
- if (typeof decided.covered === 'number')
114
- mine = mine.slice(decided.covered);
115
- else
116
- mine = mine.filter((r) => r.ts > decided.ts);
117
- }
118
- if (mine.length > 0) {
119
- const latest = mine[mine.length - 1];
120
- return {
121
- entry: {
122
- id, kind: latest.kind, target: latest.target, why: latest.why,
123
- count: mine.length, firstTs: mine[0].ts, lastTs: latest.ts,
124
- ...(latest.replica ? { replica: latest.replica } : {}),
125
- },
126
- state,
127
- };
128
- }
103
+ // @implements A-SPEC-690 the reopening rule and the queue-shape conversion live in ONE place
104
+ // now (approval-queue). They used to be spelled out here as well, and a second copy of a
105
+ // judgement is how two paths quietly disagree about whether a request is open.
106
+ const { readRefusals, openOccurrences, asPendingRequest } = require('../governance/approval-queue');
107
+ const mine = openOccurrences(readRefusals(root).filter((r) => r.id === id), state.decisions[id]);
108
+ if (mine.length > 0)
109
+ return { entry: asPendingRequest(id, mine), state };
129
110
  }
130
111
  catch { /* a broken refusal log must not break the decision surface */ }
131
112
  return { entry: undefined, state };
@@ -754,10 +754,12 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
754
754
  // (id count) and stays untouched. OWN try/catch (round-1): sharing the block above meant an
755
755
  // unrelated upstream throw silently erased these checks — a failure here must surface as WARN,
756
756
  // never as a vanished line.
757
+ // @implements A-SPEC-254.2 — the local walk's entries, kept for the remote-ref check below. `undefined`
758
+ // means the walk itself failed, and then that check says so rather than judging an empty store.
759
+ let localIdEntries;
757
760
  try {
758
- const { detectIdCollisions } = require('../spec/id-collision');
761
+ const { detectIdCollisions, collisionKeyOf } = require('../spec/id-collision');
759
762
  const { parseSpec } = require('../spec/spec-parser');
760
- const crypto = require('node:crypto');
761
763
  const specsRoot = path.join(target, '.ax', 'specs');
762
764
  const entries = [];
763
765
  let skipped = 0; // would-be specs present but not judged — a PASS must name its reduced scope (round-1)
@@ -769,13 +771,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
769
771
  // (mirrors parseSpec), per-line trailing whitespace and blank lines dropped (formatting is not
770
772
  // substance for an advisory WARN), plus the substantive frontmatter (type/title/dependsOn) via
771
773
  // JSON array serialization — no field aliasing. The SEAL digest is untouched, as before.
772
- const collisionKey = (folded, spec) => {
773
- const fence = /^---\n[\s\S]*?\n---\n?/.exec(folded);
774
- const body = (fence ? folded.slice(fence[0].length) : folded)
775
- .split('\n').map((l) => l.trimEnd()).filter((l) => l !== '').join('\n');
776
- return 'sha256:' + crypto.createHash('sha256')
777
- .update(JSON.stringify([spec.type, spec.title, [...spec.dependsOn].sort(), body])).digest('hex');
778
- };
774
+ // @implements A-SPEC-254.2 that key now lives in `id-collision.ts` as `collisionKeyOf`, because the
775
+ // remote-ref collector below must produce the SAME key for the same document. It was moved, not
776
+ // rewritten: the round-1..3 reasoning above still describes it.
777
+ const collisionKey = collisionKeyOf;
779
778
  // Symlinked directories are FOLLOWED (round-2): `Dirent.isDirectory()` is false for a dir
780
779
  // symlink, so specs behind one silently left the judgment with skipped=0 — an unqualified PASS
781
780
  // over an unexamined store. The realpath visited-set breaks symlink cycles. An UNREADABLE or
@@ -880,6 +879,7 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
880
879
  }
881
880
  };
882
881
  collect(specsRoot);
882
+ localIdEntries = entries;
883
883
  // No cross-reference to `spec readability` (round-3): that walker does not follow dir
884
884
  // symlinks, so the pointer was false for symlink-reached files — doctor contradicting itself.
885
885
  const scopeNote = skipped > 0 ? ` (${skipped} outside the verdict)` : '';
@@ -889,7 +889,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
889
889
  add('spec id preemption collision', collisions.length === 0 ? 'PASS' : 'WARN', collisions.length === 0
890
890
  ? `No id is claimed by two different specs${scopeNote}`
891
891
  : collisions.map((c) => `${c.id}: ${c.files.join(' ↔ ')} (${c.detail})`).join('; ') + scopeNote, collisions.length === 0 ? undefined
892
- : 'Two specs preempted the same number renumber one (manual until REQ-255) and re-seal.');
892
+ // @implements A-SPEC-700.1 this line still told people to renumber BY HAND "until REQ-255" long after
893
+ // REQ-255 shipped, sending people to handwork the tool replaces. No plan is offered HERE:
894
+ // both copies sit in one store, so there is no published/unpublished side to choose by.
895
+ : 'Two specs preempted the same number — move one family with spec_renumber (dry-run first), then re-seal.');
893
896
  add('spec id family coexistence', families.length === 0 ? 'PASS' : 'WARN', families.length === 0
894
897
  ? `No bare id coexists with its dot-suffix family${scopeNote}`
895
898
  : families.map((f) => `${f.id}: ${f.files.join(', ')}`).join('; ') + scopeNote, families.length === 0 ? undefined
@@ -903,6 +906,46 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
903
906
  add('spec id preemption collision', 'WARN', `could not run the check: ${msg}`);
904
907
  add('spec id family coexistence', 'WARN', `could not run the check: ${msg}`);
905
908
  }
909
+ // @implements A-SPEC-254.2 — the MERGE-TIME half of REQ-254. The walk above sees this checkout
910
+ // only, so a number another machine took stayed invisible until git refused the push (measured
911
+ // 2026-09-20: REQ-694 allocated twice, thirteen minutes apart, surfacing as an add/add conflict).
912
+ // Judged: documents each side ADDED since it parted from a remote-tracking ref — an EDITED spec
913
+ // is ordinary divergence, not a collision. Refs are read as they stand (no network; `fetch` is
914
+ // the caller's), the PASS names the refs it looked at so a stale ref cannot read as "no collision
915
+ // with the remote", and no git / no remote is a PASS with its reason (REQ-128). OWN try, for the
916
+ // round-1 reason: a failure here must not erase the two lines above.
917
+ try {
918
+ if (localIdEntries === undefined)
919
+ throw new Error('the local spec walk did not complete');
920
+ const { detectIdCollisions } = require('../spec/id-collision');
921
+ const { collectRemoteAddedSpecs } = require('../spec/remote-spec-refs');
922
+ const remote = collectRemoteAddedSpecs(target);
923
+ if (remote.unavailable !== undefined) {
924
+ add('spec id collision with remote refs', 'PASS', remote.unavailable);
925
+ }
926
+ else {
927
+ const addedLocally = localIdEntries.filter((e) => !remote.baseFiles.has(e.file));
928
+ const found = detectIdCollisions([...addedLocally, ...remote.entries]).filter((i) => i.kind === 'id-collision');
929
+ // @implements A-SPEC-700.1 — the planner's first caller. WHICH number moves (the side not
930
+ // published yet) and WHERE (free on both sides) was decided by hand on 2026-09-20; it is a rule.
931
+ const { planIdReconcile, reconcileInputsFrom, reconcileAdvice } = require('../spec/id-collision');
932
+ const advice = found.length === 0 ? '' : reconcileAdvice(planIdReconcile(reconcileInputsFrom({ issues: found, local: localIdEntries, addedLocally, remote })));
933
+ // Names, bounded: a repository with dozens of stale branches must not turn one check into a
934
+ // paragraph. The count is always exact; the names are the first few, sorted.
935
+ const named = remote.refs.slice(0, 3).join(', ') + (remote.refs.length > 3 ? ` +${remote.refs.length - 3} more` : '');
936
+ const scope = ` (compared with ${remote.refs.length} remote-tracking ref(s) as last fetched: ${named || 'none comparable'}`
937
+ + `${remote.skipped > 0 ? `; ${remote.skipped} unreadable` : ''})`;
938
+ add('spec id collision with remote refs', found.length === 0 ? 'PASS' : 'WARN', found.length === 0
939
+ ? `No id added here was also added on a remote-tracking ref${scope}`
940
+ : found.map((c) => `${c.id}: ${c.files.join(' ↔ ')}`).join('; ') + scope, found.length === 0 ? undefined
941
+ : (advice !== '' ? `Before merging — ${advice}.`
942
+ : 'Another workspace took the same number. Move the side that is NOT published yet with spec_renumber BEFORE merging, then re-seal.'));
943
+ }
944
+ }
945
+ catch (e) {
946
+ const msg = String(e instanceof Error ? e.message : e).split('\n')[0];
947
+ add('spec id collision with remote refs', 'WARN', `could not run the check: ${msg}`);
948
+ }
906
949
  }
907
950
  // @implements A-SPEC-176
908
951
  // `list()` drops any file it cannot parse, which is correct for a gate but leaves the author
@@ -821,8 +821,15 @@ async function main(argv) {
821
821
  // the same filtered list or `--grant 3` would act on a row nobody saw. An ID or prefix is the
822
822
  // operator naming a specific request — including a `shell` refusal they approved out of band
823
823
  // (A-SPEC-563.2), which the inbox filter hides from the list but must never make ungrantable.
824
+ // @implements A-SPEC-690 — `includeAllKinds` only unfilters KINDS inside the tracked queue;
825
+ // a shell refusal is not in that file at all (A-SPEC-563.1 routes it to this machine's
826
+ // refusal log). So the id/prefix path failed here, before approve.ts's refusal-log fallback
827
+ // could ever run — making the very id the refusal printed ungrantable, which the comment
828
+ // above says must never happen. The NUMBER path is untouched: an index must mean the row the
829
+ // operator was shown.
824
830
  const byIndex = /^\d+$/.test(ref.trim());
825
- const r = resolveRequestRef(readQueue(root, byIndex ? undefined : { includeAllKinds: true }).pending, ref);
831
+ const { grantableRequests } = require('../governance/approval-queue');
832
+ const r = resolveRequestRef(byIndex ? readQueue(root).pending : grantableRequests(root), ref);
826
833
  return r.ok ? { id: r.entry.id, subject: (prefix) => decisionSubject(r.entry, prefix), detail: (tail) => decisionDetail(r.entry, tail) } : { refusal: r.reason };
827
834
  };
828
835
  const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
@@ -25,3 +25,30 @@ export interface ProfileDriftInput {
25
25
  * is why the "current document" case is tested — a check that always speaks is not a check.
26
26
  */
27
27
  export declare function profileDriftFindings(input: ProfileDriftInput): string[];
28
+ /**
29
+ * @implements A-SPEC-696
30
+ * Which release is the GitHub README a copy of — and how many README-changing releases has it missed?
31
+ *
32
+ * Step 6 of the publish playbook (sync the README npm's `homepage` points at) is prose, and it runs
33
+ * AFTER the publish, so that release's own gate can never check it. Measured 2026-09-20: the remote
34
+ * README was byte-identical to v0.19.0 and v0.19.3 while six release lines shipped, and nothing
35
+ * said so. The NEXT release's gate can see it: had the last sync happened, the remote would equal
36
+ * the latest tag's README.
37
+ *
38
+ * Pure — the caller does the network and the git. One sentence or `null`; `null` means there is
39
+ * nothing a person needs to do. A remote that could not be read is NEVER `null`: silence would read
40
+ * as "in step", which is exactly the silence six releases passed through.
41
+ *
42
+ * Byte comparison after folding line endings, and nothing cleverer. "Mostly the same" is not judged:
43
+ * a remote that matches no tag is reported as DIVERGED, not as behind, because the natural response
44
+ * to "behind" is an overwrite and someone may have edited the page on GitHub.
45
+ */
46
+ export declare function externalReadmeLag(input: {
47
+ remote: string | null;
48
+ reason?: string;
49
+ /** Release tags with their README, OLDEST FIRST. */
50
+ tagged: ReadonlyArray<{
51
+ tag: string;
52
+ readme: string;
53
+ }>;
54
+ }): string | null;
@@ -11,6 +11,7 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.repoTargetFrom = repoTargetFrom;
13
13
  exports.profileDriftFindings = profileDriftFindings;
14
+ exports.externalReadmeLag = externalReadmeLag;
14
15
  /**
15
16
  * The GitHub target this project publishes to, read from `package.json`.
16
17
  *
@@ -66,3 +67,47 @@ function isShipped(version) {
66
67
  return false;
67
68
  return Number(m[1]) > 0 || Number(m[2]) > 0;
68
69
  }
70
+ /**
71
+ * @implements A-SPEC-696
72
+ * Which release is the GitHub README a copy of — and how many README-changing releases has it missed?
73
+ *
74
+ * Step 6 of the publish playbook (sync the README npm's `homepage` points at) is prose, and it runs
75
+ * AFTER the publish, so that release's own gate can never check it. Measured 2026-09-20: the remote
76
+ * README was byte-identical to v0.19.0 and v0.19.3 while six release lines shipped, and nothing
77
+ * said so. The NEXT release's gate can see it: had the last sync happened, the remote would equal
78
+ * the latest tag's README.
79
+ *
80
+ * Pure — the caller does the network and the git. One sentence or `null`; `null` means there is
81
+ * nothing a person needs to do. A remote that could not be read is NEVER `null`: silence would read
82
+ * as "in step", which is exactly the silence six releases passed through.
83
+ *
84
+ * Byte comparison after folding line endings, and nothing cleverer. "Mostly the same" is not judged:
85
+ * a remote that matches no tag is reported as DIVERGED, not as behind, because the natural response
86
+ * to "behind" is an overwrite and someone may have edited the page on GitHub.
87
+ */
88
+ function externalReadmeLag(input) {
89
+ if (input.remote === null)
90
+ return `could not check the GitHub README (${input.reason ?? 'unknown'})`;
91
+ const tagged = input.tagged ?? [];
92
+ if (tagged.length === 0)
93
+ return null;
94
+ // A CRLF working tree and the LF copy GitHub serves are one document (the lesson A-SPEC-692 left).
95
+ const fold = (text) => String(text).replace(/\r\n/g, '\n');
96
+ const remote = fold(input.remote);
97
+ // The NEWEST matching tag: v0.19.0 and v0.19.3 shared a README, and naming the older one would
98
+ // overstate the lag.
99
+ let at = -1;
100
+ for (let i = 0; i < tagged.length; i++)
101
+ if (fold(tagged[i].readme) === remote)
102
+ at = i;
103
+ if (at === -1)
104
+ return 'GitHub README matches no release tag — diverged, or edited on GitHub';
105
+ if (at === tagged.length - 1)
106
+ return null;
107
+ // Only releases that CHANGED the README count. A test-only patch release moves nothing a reader
108
+ // sees, and counting it would warn when there is nothing to sync.
109
+ const missed = tagged.slice(at + 1).filter((t) => fold(t.readme) !== remote).length;
110
+ if (missed === 0)
111
+ return null;
112
+ return `GitHub README matches ${tagged[at].tag} — ${missed} release(s) with README changes behind`;
113
+ }
@@ -25,6 +25,17 @@ export interface AxConfig {
25
25
  storage: {
26
26
  specStore: string;
27
27
  };
28
+ architecture: {
29
+ /**
30
+ * @implements A-SPEC-694
31
+ * Areas whose code cycles are a NAMED EXCEPTION to the ART-2 ratchet — vendored trees the project
32
+ * cannot fix. A cycle is excepted only when EVERY member lies under one of these prefixes; one
33
+ * that straddles project code is still reported. Excepted cycles stay in the observation ledger,
34
+ * counted. It lives here, in a root file whose change passes a human, because an agent that
35
+ * could pardon its own violations would make the exception worthless.
36
+ */
37
+ cycleIgnore: string[];
38
+ };
28
39
  }
29
40
  export declare const DEFAULT_CONFIG: AxConfig;
30
41
  export declare function loadConfig(root: string): AxConfig;
@@ -50,6 +50,7 @@ exports.DEFAULT_CONFIG = {
50
50
  guardrail: { enforcement: 'block', enforceHighRisk: true, overrideRequiresAdr: true, preEditEvidence: 'warn', redFirstEvidence: 'track' },
51
51
  highRiskDomains: ['@auth', '@payment'],
52
52
  storage: { specStore: 'local-markdown' },
53
+ architecture: { cycleIgnore: [] },
53
54
  };
54
55
  function deepMerge(base, ov) {
55
56
  if (ov == null || typeof ov !== 'object')
@@ -69,7 +70,16 @@ function loadConfig(root) {
69
70
  // Fall back to the strict DEFAULT_CONFIG (enforcement:'block') so a broken file cannot silently
70
71
  // weaken or disable governance — a bad config degrades to the safest posture, not to an exception.
71
72
  try {
72
- return structuredClone(deepMerge(exports.DEFAULT_CONFIG, JSON.parse(fs.readFileSync(f, 'utf8'))));
73
+ const merged = structuredClone(deepMerge(exports.DEFAULT_CONFIG, JSON.parse(fs.readFileSync(f, 'utf8'))));
74
+ // @implements A-SPEC-694 — `deepMerge` copies a non-object override as it arrived, so
75
+ // `"architecture": "x"` or `"cycleIgnore": "reference"` would reach the ratchet as written. An
76
+ // exception list must never fail TOWARD a wider exception: anything that is not an array of
77
+ // strings reads as no exception at all.
78
+ const declared = merged.architecture?.cycleIgnore;
79
+ merged.architecture = {
80
+ cycleIgnore: Array.isArray(declared) ? declared.filter((x) => typeof x === 'string') : [],
81
+ };
82
+ return merged;
73
83
  }
74
84
  catch {
75
85
  return structuredClone(exports.DEFAULT_CONFIG);
@@ -7,6 +7,17 @@ import { type Cycle } from './cycle-detect';
7
7
  * that makes it unreadable. What is dropped is COUNTED, never silently cut.
8
8
  */
9
9
  export declare const CYCLE_LIST_CAP = 50;
10
+ /**
11
+ * @implements A-SPEC-688
12
+ * The same rule applied one level down: a single cycle must not grow with the tree either.
13
+ *
14
+ * `CYCLE_LIST_CAP` bounded how many cycles a record holds; nothing bounded how many FILES one cycle
15
+ * holds. That was harmless while only TypeScript was judged, because a TS cycle is a handful of
16
+ * files. Judging Go activates it — a package import names EVERY file of the package, so one
17
+ * strongly connected component can carry a package's worth of paths into every turn's record.
18
+ * What is dropped is counted here too, per cycle, in `filesOmitted`.
19
+ */
20
+ export declare const CYCLE_FILES_CAP = 25;
10
21
  /**
11
22
  * One turn's observation — paths, integers and enums only.
12
23
  *
@@ -20,6 +31,8 @@ export interface CycleObservationRecord {
20
31
  cycles: Array<{
21
32
  key: string;
22
33
  files: string[];
34
+ /** Members beyond CYCLE_FILES_CAP: counted, so a wide cycle is never mistaken for a narrow one. */
35
+ filesOmitted: number;
23
36
  runtime: boolean;
24
37
  edges: number;
25
38
  }>;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CYCLE_LIST_CAP = void 0;
36
+ exports.CYCLE_FILES_CAP = exports.CYCLE_LIST_CAP = void 0;
37
37
  exports.buildCycleObservation = buildCycleObservation;
38
38
  exports.appendCycleObservation = appendCycleObservation;
39
39
  exports.readCycleObservations = readCycleObservations;
@@ -61,6 +61,17 @@ const cycle_detect_1 = require("./cycle-detect");
61
61
  * that makes it unreadable. What is dropped is COUNTED, never silently cut.
62
62
  */
63
63
  exports.CYCLE_LIST_CAP = 50;
64
+ /**
65
+ * @implements A-SPEC-688
66
+ * The same rule applied one level down: a single cycle must not grow with the tree either.
67
+ *
68
+ * `CYCLE_LIST_CAP` bounded how many cycles a record holds; nothing bounded how many FILES one cycle
69
+ * holds. That was harmless while only TypeScript was judged, because a TS cycle is a handful of
70
+ * files. Judging Go activates it — a package import names EVERY file of the package, so one
71
+ * strongly connected component can carry a package's worth of paths into every turn's record.
72
+ * What is dropped is counted here too, per cycle, in `filesOmitted`.
73
+ */
74
+ exports.CYCLE_FILES_CAP = 25;
64
75
  /**
65
76
  * Build the record. PURE — the clock is an argument, so a test can pin it and two callers cannot
66
77
  * disagree about what "now" was.
@@ -77,7 +88,19 @@ function buildCycleObservation(ev, ts) {
77
88
  return {
78
89
  ts,
79
90
  mode: ev.mode,
80
- cycles: listed.map((c) => ({ key: (0, cycle_detect_1.cycleKey)(c.files), files: [...c.files].sort(), runtime: c.runtime, edges: c.edges.length })),
91
+ cycles: listed.map((c) => {
92
+ const all = [...c.files].sort();
93
+ return {
94
+ // Identity comes from the FULL membership. Keying off the truncated list would make one
95
+ // wide cycle look like a different cycle whenever a member outside the window changed —
96
+ // and the ledger's whole job is to say whether this is the same finding as last turn.
97
+ key: (0, cycle_detect_1.cycleKey)(c.files),
98
+ files: all.slice(0, exports.CYCLE_FILES_CAP),
99
+ filesOmitted: Math.max(0, all.length - exports.CYCLE_FILES_CAP),
100
+ runtime: c.runtime,
101
+ edges: c.edges.length,
102
+ };
103
+ }),
81
104
  cyclesOmitted: Math.max(0, ev.current.length - listed.length),
82
105
  violations,
83
106
  allowed: ev.allowed.length,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The two ends around a judgement that was already right.
3
+ *
4
+ * `cycleRatchetViolations(current, allowed)` has honoured a named exception since A-SPEC-574.4, and
5
+ * the Stop hook has supplied `allowed: []` since the same day — there was nowhere to name one. And
6
+ * what the judgement found had no bounded way to be said: `trackedLines` joins every detail of an
7
+ * article into ONE line, and measured 2026-09-20 this repository carries a 46-member cycle inside a
8
+ * vendored tree. Wiring the evidence without these two would have printed thousands of characters a
9
+ * turn about code nobody here can change — "a rule that blocks adoption is not a rule, it is a
10
+ * barrier" (REQ-574), in its observational form.
11
+ *
12
+ * Pure, and it judges nothing: one function fills the list the judgement reads, the other renders
13
+ * what the judgement returned. The caps bound RENDERING only — no verdict reads them (REQ-569 S5).
14
+ */
15
+ import { type Cycle, type CycleViolation } from './cycle-detect';
16
+ /** How many cycles one ART-2 line names before it counts the rest. */
17
+ export declare const CYCLE_LINE_CAP = 5;
18
+ /** How many members of one cycle are named before the rest are counted. The ledger keeps them all. */
19
+ export declare const CYCLE_LINE_MEMBERS = 5;
20
+ /**
21
+ * The keys of the cycles that lie ENTIRELY inside ignored areas — what the hook supplies as `allowed`.
22
+ *
23
+ * Entirely, because a cycle that straddles project code and a vendored tree is the project's to
24
+ * fix: if one ignored member were enough, the declaration would become a way to hide the project's
25
+ * own cycles behind a neighbour.
26
+ */
27
+ export declare function ignoredCycleKeys(cycles: readonly Cycle[], prefixes: readonly string[]): string[];
28
+ /**
29
+ * The ratchet's violations as `tracked` details of bounded length.
30
+ *
31
+ * The wording is A-SPEC-574.4's (`code import cycle: a -> b -> a`), kept so nothing that reads it
32
+ * has to change. What is folded is COUNTED, per cycle and across cycles: a truncation that does not
33
+ * announce itself reads as the whole list. The full membership is in the observation ledger.
34
+ */
35
+ export declare function cycleStatusDetails(violations: readonly CycleViolation[], scopeSuffix: string): string[];