@am_shork/attest 0.2.2 → 0.3.0

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 (79) hide show
  1. package/CHANGELOG.md +593 -61
  2. package/README.md +121 -18
  3. package/dist/cli/index.js +91 -21
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/json.d.ts +52 -5
  6. package/dist/cli/json.d.ts.map +1 -1
  7. package/dist/cli/json.js +50 -2
  8. package/dist/cli/json.js.map +1 -1
  9. package/dist/cli/report.d.ts +11 -1
  10. package/dist/cli/report.d.ts.map +1 -1
  11. package/dist/cli/report.js +52 -0
  12. package/dist/cli/report.js.map +1 -1
  13. package/dist/core/apply.d.ts +8 -0
  14. package/dist/core/apply.d.ts.map +1 -1
  15. package/dist/core/apply.js +10 -0
  16. package/dist/core/apply.js.map +1 -1
  17. package/dist/core/gate.d.ts +27 -1
  18. package/dist/core/gate.d.ts.map +1 -1
  19. package/dist/core/gate.js +48 -1
  20. package/dist/core/gate.js.map +1 -1
  21. package/dist/core/loader.d.ts +8 -0
  22. package/dist/core/loader.d.ts.map +1 -1
  23. package/dist/core/loader.js +1 -0
  24. package/dist/core/loader.js.map +1 -1
  25. package/dist/core/locate.d.ts +4 -0
  26. package/dist/core/locate.d.ts.map +1 -1
  27. package/dist/core/locate.js +8 -3
  28. package/dist/core/locate.js.map +1 -1
  29. package/dist/core/paths.d.ts +15 -0
  30. package/dist/core/paths.d.ts.map +1 -0
  31. package/dist/core/paths.js +36 -0
  32. package/dist/core/paths.js.map +1 -0
  33. package/dist/core/pipeline.d.ts +63 -2
  34. package/dist/core/pipeline.d.ts.map +1 -1
  35. package/dist/core/pipeline.js +190 -34
  36. package/dist/core/pipeline.js.map +1 -1
  37. package/dist/core/red-record.d.ts +50 -0
  38. package/dist/core/red-record.d.ts.map +1 -0
  39. package/dist/core/red-record.js +148 -0
  40. package/dist/core/red-record.js.map +1 -0
  41. package/dist/core/render.d.ts +1 -1
  42. package/dist/core/render.d.ts.map +1 -1
  43. package/dist/core/render.js +16 -2
  44. package/dist/core/render.js.map +1 -1
  45. package/dist/core/runner.d.ts.map +1 -1
  46. package/dist/core/runner.js +16 -6
  47. package/dist/core/runner.js.map +1 -1
  48. package/dist/core/skill.d.ts +23 -0
  49. package/dist/core/skill.d.ts.map +1 -0
  50. package/dist/core/skill.js +276 -0
  51. package/dist/core/skill.js.map +1 -0
  52. package/dist/core/static-registry.d.ts +28 -0
  53. package/dist/core/static-registry.d.ts.map +1 -1
  54. package/dist/core/static-registry.js +86 -25
  55. package/dist/core/static-registry.js.map +1 -1
  56. package/dist/core/status.d.ts +39 -0
  57. package/dist/core/status.d.ts.map +1 -0
  58. package/dist/core/status.js +64 -0
  59. package/dist/core/status.js.map +1 -0
  60. package/dist/core/targets.d.ts +52 -0
  61. package/dist/core/targets.d.ts.map +1 -0
  62. package/dist/core/targets.js +141 -0
  63. package/dist/core/targets.js.map +1 -0
  64. package/dist/core/types.d.ts +13 -0
  65. package/dist/core/types.d.ts.map +1 -1
  66. package/dist/core/types.js.map +1 -1
  67. package/dist/core/validator.d.ts +11 -0
  68. package/dist/core/validator.d.ts.map +1 -1
  69. package/dist/core/validator.js +28 -12
  70. package/dist/core/validator.js.map +1 -1
  71. package/dist/core/write.d.ts +2 -0
  72. package/dist/core/write.d.ts.map +1 -0
  73. package/dist/core/write.js +21 -0
  74. package/dist/core/write.js.map +1 -0
  75. package/dist/runtime.d.ts +9 -1
  76. package/dist/runtime.d.ts.map +1 -1
  77. package/dist/runtime.js +10 -2
  78. package/dist/runtime.js.map +1 -1
  79. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -15,6 +15,441 @@ not API.
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
+ ## [0.3.0] - 2026-07-27
19
+
20
+ The workflow half now reaches the agent that will read it, the archive gate can
21
+ tell a test that ever discriminated from one that never could, and a change in
22
+ flight has a reportable state between "not started" and "done". Underneath all
23
+ three: every path Attest derives from a project root now has one spelling on
24
+ every platform, which is what makes them work on Windows at all.
25
+
26
+ ### Added
27
+
28
+ - **The workflow reached one agent, and every other agent got the engine without
29
+ it.** `attest init` wrote `.claude/skills/attest/SKILL.md` and nothing else —
30
+ the gap `init` exists to close, still open for every tool but one. It now takes
31
+ `--target`, repeatable, and writes the same document for Claude Code (the
32
+ default), Cursor (`.cursor/rules/attest.mdc`) and GitHub Copilot
33
+ (`.github/instructions/attest.instructions.md`).
34
+ **Two rules decide what may be a target, and the first was already permanent.**
35
+ *Attest owns the path*: each file above exists because Attest exists, is
36
+ written whole, and reads nothing of the user's — which is why an `AGENTS.md` or
37
+ `CLAUDE.md` marked block stays rejected outright (see "Considered and
38
+ rejected"), and why the answer for an agent that reads only such a file is a
39
+ one-line pointer the user writes themselves. *The instructions load on demand*
40
+ is the second, and it was the open question when this was queued: a skill body
41
+ is fetched when its description matches, a Cursor rule when the agent asks for
42
+ it (`alwaysApply: false`, and deliberately no `globs`, which is the auto-attach
43
+ trigger and a different thing), a Copilot instructions file when the open path
44
+ matches its `applyTo`. A carrier that is unconditionally resident charges every
45
+ unrelated request in the repository for a workflow it will not use.
46
+ **One document, three frontmatters, and that split is a requirement (ATX-34).**
47
+ Each target carries the same body byte for byte; only the frontmatter differs,
48
+ because that is the part the tool parses rather than the part the agent
49
+ follows. A copy per tool would be two agents told different things about one
50
+ engine — the drift this framework exists to report, occurring where nothing
51
+ reports it, since no command reads these files back. The requirement is written
52
+ over *every* target rather than over a list of them, so adding one cannot
53
+ satisfy it by being left out. What it costs falls on the targets with no
54
+ description-triggered mode: Copilot's trigger is the path, and `applyTo` names
55
+ the registries, the specs and `changes/`, so the instructions arrive when the
56
+ agent is editing intent and not when it is editing an implementation to make a
57
+ scenario pass — which is exactly when "do not edit the scenario instead" would
58
+ be worth reading. Widening that glob to `**` buys the missing case at the price
59
+ rule 2 declines to pay.
60
+ **Naming no target writes what it wrote before; an unknown name writes nothing
61
+ at all (ATX-35).** Writing every target by default would scatter configuration
62
+ for editors nobody in the project uses. Refusing the whole set on an
63
+ unrecognised name is the ATX-13 argument under a different name: a name already
64
+ known to be unusable must not cause a single write, or a failing run leaves a
65
+ repository carrying instructions for some agents and no statement of which.
66
+ Two requirements rather than one, for the ATX-23/ATX-24 reason — an
67
+ implementation that writes every known target on every run carries one document
68
+ faithfully, and one that gets the requested set right can still fail on an
69
+ unknown name after writing half of it. `unknown-target` is a new `code` on the
70
+ existing `Issue` shape.
71
+ **Codex was evaluated and is not a target**, because the tool offers no path
72
+ Attest can own: `AGENTS.md`, or prompts under `~/.codex/prompts`, outside the
73
+ project root entirely. Recorded under "Considered and rejected", where it stays
74
+ findable once this release is history.
75
+ **It shipped without the evidence the queued candidate demanded.** That
76
+ candidate named one line in one adoption report naming a tool as its trigger,
77
+ and no such line arrived: the three targets are a judgement about which tools
78
+ adopters use, and should be read as one. What limits the cost of being wrong is
79
+ that the guess is now priced per row — the document, the writer and the CLI are
80
+ general over the table, so a target is a path, a frontmatter and a scenario —
81
+ and that the two rules above are what keep the table from growing into a
82
+ distribution tail. The measurement behind that phrase, kept because it is the
83
+ argument for where the next effort goes: OpenSpec 1.6.0 publishes 25,733 lines
84
+ of `dist/` against this package's 2,513 lines of `src/`, roughly four fifths of
85
+ it distribution rather than engine (29 per-tool adapters, 1,169 lines of
86
+ `init.js` and `update.js`, three shell-completion installers) — while its engine
87
+ is the weaker half on every axis this project was built to win: a `verify` that
88
+ asks the agent for "reasonable inference" where §8's gate takes a set
89
+ difference against the Vitest task tree, a requirement primary key that is the
90
+ Markdown header text, and a Markdown reader carrying a visible repair history
91
+ that an AST does not pay. The gap was never engine quality; it was that
92
+ `openspec init` is one command and this was a paste. Twenty-nine adapters
93
+ against three targets is what remains of that comparison, and widening it needs
94
+ a named tool with a reader blocked on it, not a count of tools that exist.
95
+ *Additive:* `attest init` with no flags writes the same bytes to the same path
96
+ as before. `--json` gains `outFiles` — every file written, since a run can now
97
+ write several — and keeps `outFile` whenever exactly one was, which the default
98
+ invocation still is, so `schemaVersion` stays `1`. The only new exit-1 path is
99
+ reached through a flag that did not exist before this release.
100
+ Two measurements moved. `divergent-param` collected a **seventh** member on
101
+ `code` (ATX-35), still with zero true positives. And `compound-requirement`'s
102
+ reach passed its blind spot for the first time: the suite asserted
103
+ `missed.length >= correct.length`, and ATX-35 falsified it by arriving, like
104
+ ATX-33, with both obligations declared under two keywords. `missed` lost no
105
+ member — the rule sees no further, and what moved is how carefully the corpus
106
+ was written. Rewriting that assertion to fit would have been the measurement
107
+ bending to the corpus, so it now asserts what actually blocks the rule (the
108
+ blind spot never empties), with the movement in the inline snapshot.
109
+
110
+ - **A change had one touchpoint, and it only ever answered "not yet".**
111
+ `attest archive <change>` decides whether a change is done; between "not
112
+ started" and "archivable" there was no reportable state at all, so asking what
113
+ remained meant reading the delta, cross-referencing the specs and inferring —
114
+ work an agent redoes on every turn and a person does badly. `attest status
115
+ <change>` reports each requirement the change ADDs as one of three states:
116
+ `no-scenario`, `unproven` (a scenario exists but has never been seen failing)
117
+ or `proven`. It runs nothing, reading the delta, the specs and
118
+ `changes/<name>/first-run.json`.
119
+ **Its blocker was that there was nothing to report, and mechanism 2 removed
120
+ it.** This was queued under "Under consideration" with exactly that objection:
121
+ Attest has no artifact graph and should not grow one, so the only progress
122
+ signal worth printing is per-id — which added requirements have a scenario, and
123
+ whether that scenario has been red — and the red half did not exist. Shipping
124
+ mechanism 2 in this same release made `first-run.json` precisely that per-id
125
+ state, which reduced the entry to a presentation question. What decided it is
126
+ that the fork the entry named is now cheap on one side: a `status` that runs the
127
+ suite is `verify` with a filter, and the one that does not is a small file read
128
+ plus a static plan.
129
+ **It is a projection of the gate, not a second gate.** Each state is named
130
+ after the issue `archive` will raise — `no-scenario` is
131
+ `uncovered-requirement`, `unproven` is `never-red` — and the decision runs
132
+ through the gate's own predicate, extracted as `hasRecordedRed`, for the reason
133
+ `declaredNotRunIssues` and `uncoveredIssues` were extracted before it:
134
+ agreement between two verdicts should be structural, not clerical. The plan and
135
+ the added-id set come from the same two functions `archive` uses, so a progress
136
+ report cannot be computed over a different spec set than the gate's.
137
+ **Progress never reaches `ok`, and that is a requirement rather than a
138
+ detail.** The cheapest implementation is to push the existing `uncovered-` and
139
+ `never-red` issues into `issues[]` — which satisfies the reporting obligation
140
+ completely while putting them in `summary.error`, where the shared `hasError`
141
+ verdict turns them into `ok: false` and exit 1. That is the normal state of
142
+ every change in flight, so a CI step or an agent loop wrapping `status` would
143
+ read "failing" for the change's whole life. Split across two self-requirements
144
+ (ATX-32, ATX-33) for the same reason ATX-23/ATX-24 were: the wrong
145
+ implementation passes the first and needs a scenario, not a clause, to catch.
146
+ The one thing that does make it fail is intent it could not read — a missing or
147
+ unreadable delta — because a command that answered ok over an empty table would
148
+ be the vacuous green ATX-18 and ATX-26 each closed elsewhere.
149
+ **What it cannot answer is `tests-red`**, and the human rendering closes by
150
+ saying so (`Not a verdict: run attest archive <name>`). A column of green ticks
151
+ here means the readable obligations are met, and a report that can be mistaken
152
+ for a verdict is worse than no report.
153
+ **It reads the delta from source, so `status` joins ATX-16's static commands.**
154
+ That requirement's `staticCommands` param grows to four and its statement now
155
+ says "the intent they report on, whether a registry or a change delta" — the
156
+ registry extractor was generalised over the authoring function's name rather
157
+ than copied, so `delta({ … })` goes through the same one walker that decides
158
+ whether a value is fixed by the source text. A delta that is not a literal is
159
+ `registry-not-static` and a file that exports no delta is `change-not-found`:
160
+ both are codes `archive` already emits, so no consumer learns new vocabulary.
161
+ `--eval` is the same named way back it is everywhere else. The exposure this
162
+ closes is worse than the registry's, not better — `changes/` holds a proposal
163
+ still under review, and a reporting command is the last thing that should
164
+ execute it.
165
+ The design document carries it in the four places that were describing the old
166
+ set rather than only in §9: §2 and §5.1 name the reading commands, §7 states
167
+ for the first time that a delta is a literal on the same terms as a registry —
168
+ an authoring rule that existed the moment `status` read one — and §8 says which
169
+ of its own four checks the projection covers, and which one only the suite can
170
+ answer. §11's "Attest's own 29 requirements" was the kind of live count that
171
+ goes stale in silence, and is now phrased so it cannot. The README carried the
172
+ same three-command list in two places and an "all three commands" that had
173
+ become four, both now corrected, and it states the delta rule where it states
174
+ the registry one.
175
+ *Additive only:* a new `command` value and the new optional `progress` /
176
+ `added` fields, so `schemaVersion` stays `1`. No existing command's output
177
+ changes. The skill `attest init` writes gains the command, which re-running
178
+ `init` adopts.
179
+ One measurement moved with this change, recorded because it is evidence about a
180
+ *rejected* rule: `divergent-param` collected a **sixth** member on `code`
181
+ (ATX-33 joining ATX-18, ATX-21, ATX-25, ATX-26 and ATX-31), still with zero
182
+ true positives, and `compound-requirement`'s reach reached parity with its
183
+ blind spot for the first time (5 correct, 5 missed) — not because the rule
184
+ improved, since `missed` lost no member, but because ATX-33 was written with
185
+ both of its obligations declared. A rule whose score rises when the corpus is
186
+ written more carefully is measuring the authors, which is the objection to
187
+ shipping it, now stated by the measurement rather than in prose.
188
+
189
+ - **The archive gate certified that a change's tests pass, never that they ever
190
+ discriminated.** §6 describes three anti-drift mechanisms; mechanism 2 —
191
+ red/green expectation tracking — was written as shipped behaviour and had
192
+ never been built (0.2.0 corrected the document and named the gap). The gate
193
+ required green at archive time and nothing else, so a scenario that asserts
194
+ nothing has been green since the commit that added it and cleared the gate
195
+ exactly like one that drove an implementation. It ships now, and the anti-drift
196
+ budget is three mechanisms rather than two.
197
+ What made it buildable is that **the observation already existed and was being
198
+ thrown away**. The workflow has stage 1 run `attest archive <name>` and *expect*
199
+ `tests-red` — the skill `attest init` writes says so, and then says "nothing in
200
+ the engine will catch that later. This paragraph is the mechanism." A paragraph
201
+ asking a reader to be diligent is the thing this framework exists to replace,
202
+ and the engine had seen every one of those reds. `archive` now records how each
203
+ of a change's scenarios ended on its first run, into
204
+ `changes/<name>/first-run.json`, and blocks with a `never-red` ERROR on any
205
+ requirement the delta ADDs whose scenarios have no recorded failing run.
206
+ **A committed generated file, which this project otherwise treats as a
207
+ liability** — the argument against `init --check`, the reason `render` takes no
208
+ formatting options. The difference is lifetime: `changes/<name>/` is temporary
209
+ by construction and goes away when the change is archived, so this file has
210
+ exactly the change's lifetime and never becomes a permanent asset that can
211
+ drift. It is committed rather than kept in a scratch directory because the gate
212
+ has to reach the same verdict on a CI runner's fresh checkout as on the
213
+ author's machine — and because it is evidence, which belongs in the diff a
214
+ reviewer reads.
215
+ **The record is monotonic toward failure, and both halves of that are
216
+ load-bearing.** A recorded failure is permanent: by the time a change is
217
+ archivable everything is green, so a record that kept the latest outcome would
218
+ say "green" about a scenario that spent the whole change red. A recorded *pass*
219
+ is deliberately not permanent — plain first-write-wins was the first design
220
+ here, and it makes the defect this mechanism exists to catch unrecoverable,
221
+ since the fix for a scenario that asserts nothing is to give it a real
222
+ assertion and watch it fail, an observation that could then never be recorded.
223
+ The only way out would have been deleting the evidence by hand, which would
224
+ equally delete a genuine red. A later failure replacing an earlier pass cannot
225
+ be abused, because producing one requires the scenario to actually fail.
226
+ **Scoped to ADDED requirements only, and §11 now records what that leaves.** A
227
+ requirement written straight into the base registry describes behaviour that
228
+ already exists — the brownfield adoption, and most of this repo — so there was
229
+ never a moment when its scenario should have failed; blocking those would make
230
+ the mechanism fire hardest on the honest case. The guarantee is therefore a
231
+ property of the `changes/` workflow, not of the registry as a whole.
232
+ *Behaviour change, for a project mid-change:* a change whose scenarios were
233
+ never observed failing flips from archivable to blocked, which is the point,
234
+ since nothing had shown its tests could fail. The remedy is one gate run before
235
+ implementing. `never-red` is a new `code` on the existing `Issue` shape, and
236
+ `--json` gains no field, so `schemaVersion` stays `1`. Two self-requirements
237
+ (ATX-30, ATX-31) rather than one: a recorder that writes a perfect history and
238
+ never blocks satisfies the first completely while leaving the gate exactly as
239
+ weak as it was, and a gate can equally block on an absent record without
240
+ recording anything.
241
+ The rejected `divergent-param` rule gained a **fifth** member on `code` and,
242
+ for the first time, a collision on a **second** param name (`file`, ATX-27's
243
+ skill path against ATX-30's record path) — see "Considered and rejected", where
244
+ that second name is what closes off "pick a less generic name" as a workaround.
245
+
246
+ - **`attest init` — the workflow half of the framework had no entry point.**
247
+ Everything the engine does has been reachable from the CLI since 0.1.0. The
248
+ workflow — agree on intent, write the delta, drive the scenarios red, implement
249
+ to green — was a document (`docs/*/using-attest.prompt.md`) the user had to
250
+ find and paste into their agent themselves. Everything downstream of that paste
251
+ worked; nothing led to it. `attest init` writes one file,
252
+ `.claude/skills/attest/SKILL.md`, carrying that workflow as a **skill** — its
253
+ description sits in the agent's context, so the agent loads it when the work
254
+ starts rather than waiting to be handed something. That is the difference that
255
+ closes the gap: a file the user must remember to invoke would only replace
256
+ "find the document" with "know what to type". `init` is idempotent — re-run it
257
+ after upgrading, which is the entire update mechanism.
258
+
259
+ **The freshness contract, which the queued entry said had to be designed rather
260
+ than assumed: there isn't one, and that is the design.** A committed
261
+ instruction file is a function of the installed version, so it does go stale on
262
+ upgrade — but staleness only *costs* anything if the tool also ships a gate to
263
+ turn red about it, and `init --check` is rejected on its own terms under
264
+ "Considered and rejected", together with the exposure that leaves and the
265
+ backstop that keeps it small. What belongs here is what the absence of a gate
266
+ bought: with none to serve there is no reason to keep the prose out of the file
267
+ either, so it lives in the skill, where an agent reads it with no extra call,
268
+ and a skill body is loaded on demand — a long document costs context only when
269
+ it is actually needed.
270
+
271
+ **`init` deliberately scaffolds nothing else:** no `requirements/*.reqs.ts`, no
272
+ `package.json` scripts, no `changes/` skeleton. A generated registry can only
273
+ be empty — which since 0.2.0 is an `empty-spec` ERROR handed to the user on day
274
+ one — or invented, and invented intent is worse than none in the one layer
275
+ whose value is that it is real. What belongs there is the agent's judgement,
276
+ with the project in front of it, which is what the skill describes.
277
+ **This is the one thing Attest does not dogfood, and saying otherwise would
278
+ have been the wrong kind of reassurance.** The skill exists for a project
279
+ *adopting* Attest — an agent meeting a registry it did not write, needing to be
280
+ told what a delta is and why its scenarios must be red. This repo is the
281
+ framework, not an adopter: its requirements describe behaviour that already
282
+ exists and go straight into the base registry, so it does not run the
283
+ `changes/` workflow the skill teaches, and a committed copy here would be a
284
+ file nothing reads. What guards the text instead is `tests/skill.spec.ts`,
285
+ which asserts the shape an agent depends on — that it parses as a skill, that
286
+ the description names `*.reqs.ts` so it loads at the right moment, and that the
287
+ body still teaches the codes a reader will actually hit. That is a weaker
288
+ guarantee than dogfooding and is named as such: nothing here proves the
289
+ workflow is followable, only that the file describing it is well-formed.
290
+ **One correction to the queued entry:** it predicted a `files` allowlist change
291
+ and therefore a `pnpm run test:consumer` obligation. There is none — the text
292
+ is a TypeScript module compiled into `dist/`, not a template file, so the
293
+ published surface is unchanged. `--json` gains `init` as a `command` value and
294
+ widens the existing `outFile` field to it; both are additive, so
295
+ `schemaVersion` stays `1`.
296
+
297
+ - **`scenario` accepts a timeout.** `scenario(name, fn, timeoutMs)`, passed
298
+ straight through to Vitest's `it`. `verify` runs the suite isolated (design
299
+ §5.4) — no `vitest.config.ts` unless `--vitest-config` names one — so every
300
+ scenario got Vitest's 5-second default and a scenario that drives a
301
+ subprocess, a container or a build had no way to say otherwise. Attest's own
302
+ suite is the demonstration: two `self/` scenarios each start four child Vitest
303
+ runs, and they fit inside 5 seconds on Linux and not on Windows. The
304
+ alternatives are both worse — raising the default for everyone hides a
305
+ genuinely hung test, and a config file sets one number for what is a property
306
+ of one scenario — the same shape of objection that rules a CLI flag out as
307
+ `divergent-param`'s escape hatch under "Considered and rejected", where a
308
+ per-invocation switch is asked to carry a permanent property of the source.
309
+ Additive: existing two-argument calls are unchanged. Deliberately **no
310
+ self-requirement**: the contract here is Vitest's
311
+ own signature and this is one argument forwarded to it, and the only scenario
312
+ that would truly discriminate — one that outlives the default — would spend
313
+ five seconds of every run proving a number Attest does not own. Its absence is
314
+ observable anyway, as the two `self/` scenarios above.
315
+
316
+ ### Changed
317
+
318
+ - **BREAKING: `cover` reported a registry it could not read as a registry with
319
+ nothing in it — and nothing is fully covered.** It was the one reader that
320
+ discarded the issues from loading the registry (`runCheck` and `runRender`
321
+ both keep them), so a `registry-not-static` file, a renamed `requirements/`
322
+ folder, or a moved root left an empty registry behind, and an empty registry
323
+ has no uncovered requirement in it. The command answered `✓`, `ok: true`,
324
+ `0 requirements, 0 uncovered`, exit `0` — on precisely the repo whose intent
325
+ layer had stopped being readable, and in the command a pipeline is most likely
326
+ to run first, because it neither runs the suite nor executes project code.
327
+ `attest check` on the same project reported the ERROR correctly, which is what
328
+ made the gap survivable and also what made it invisible: the two commands
329
+ disagreed about whether the project was in a reportable state at all.
330
+ `cover` now carries the load issues, so the verdict follows `hasError` like
331
+ every other command, and it emits **no rows at all** when the registry failed
332
+ to load. The second half is deliberate and is the reason this is one change
333
+ rather than a one-line fix: reporting the error while still printing a table
334
+ built from whichever registry files did parse would let a partial read pass
335
+ for the whole. `render` already refuses to emit a document under the same
336
+ condition, for the same reason — a report computed from a half-loaded registry
337
+ is a lie, and a lie shaped like a pass is the worst form it can take.
338
+ *Breaking:* a project whose registry does not read flips from exit `0` to exit
339
+ `1` — which is the point, since it was never coverable. The human output for
340
+ that project changes from `(the registry contains no requirements)` to the
341
+ load error itself; every project whose registry does read is byte-identical.
342
+ `--json` gains no field (`issues` and `summary` already exist), so
343
+ `schemaVersion` stays `1`.
344
+
345
+ ### Removed
346
+
347
+ - **`docs/en/using-attest.prompt.md` and `docs/zh/using-attest.prompt.md`.** The
348
+ workflow half of these is what `attest init` now writes into the project; the
349
+ adoption half is the README's Getting started, which npm publishes regardless
350
+ of the `files` allowlist and so reaches a reader who has installed nothing.
351
+ Deleted rather than left as a page pointing at either, because a stub is still
352
+ a file two languages have to keep in sync, and the delivery problem was never
353
+ the prompt's wording — it was that the prompt lived in the docs directory of a
354
+ repository the reader may never have cloned. Nothing in the package linked to
355
+ them. Documentation only: no package surface, and no `files` change (they were
356
+ never in the tarball).
357
+
358
+ ### Fixed
359
+
360
+ - **A spec file below the project root never ran on Windows.** `verify` and
361
+ `archive` derive the child run's `include` globs from the plan's file paths
362
+ (0.2.0), and those paths came from `path.relative` — so on Windows they were
363
+ spelled `deep\er\x.spec.ts`. A backslash is an **escape character** in glob
364
+ syntax, so the pattern matched nothing and the file was dropped from the run.
365
+ The scenarios in it then surfaced as `declared-not-run`, which is at least
366
+ loud rather than a false green, but the practical effect was that only a flat
367
+ layout worked at all — and a flat layout is not what a real project has.
368
+ The reason it survived this long is a sampling accident: every fixture in this
369
+ repo keeps its specs at the project root, and so does `self/`, so the relative
370
+ paths had no separator in them. The single exception —
371
+ `fixtures/e2e-archive/changes/add-totp/specs/totp.spec.ts` — was the one
372
+ failing test, which is why this read as "`archive` is broken on Windows"
373
+ rather than as what it was.
374
+ Every path derived from the project root now has one spelling (`/`) on every
375
+ platform, normalised once where it is produced rather than where it is
376
+ consumed, because the same relative path is also what a report carries: a
377
+ diagnostic that differs by operating system makes two CI runs impossible to
378
+ diff. The conversion is conditional on the host separator, never
379
+ unconditional — a backslash is a legal filename character on POSIX, and
380
+ rewriting it there would rename a file that exists.
381
+ *For anyone parsing `--json` on Windows:* `issues[].file` changes from
382
+ `deep\er\x.spec.ts` to `deep/er/x.spec.ts`. No exit code changes except the
383
+ ones that were wrong; nothing previously valid is rejected; no field is
384
+ removed. `schemaVersion` stays `1`. New fixture `fixtures/nested-spec/`, whose
385
+ only spec is two directories down, is the end-to-end guard (ATX-28).
386
+ - **`render --check` was permanently red on a Windows checkout.** `render`
387
+ writes `\n`; Git hands the file back as `\r\n` wherever `core.autocrlf` is
388
+ enabled, which is the installer default on Windows. The freshness check
389
+ compared bytes, so a fresh clone reported `stale-spec-doc` against a registry
390
+ nobody had touched, and the fix hint named a command that regenerates a file
391
+ which is already correct. Any adopter committing a rendering hit this the
392
+ first time a second machine — or a CI runner — checked the repo out.
393
+ The check now compares the *document*: a difference of line endings alone is
394
+ not staleness. This is the same standard 0.1.6 set when it gave `render` no
395
+ formatting options at all — a gate that can be wrong about staleness is worse
396
+ than no gate — and it costs nothing, because no carriage return in that file
397
+ ever came from the registry. Content drift is still reported, CRLF or not
398
+ (ATX-29). This repo also gained a `.gitattributes` pinning the working tree to
399
+ LF, so a regeneration is not a whole-file diff on one platform and no diff on
400
+ the other.
401
+ - **Three of this repo's own tests could not pass on Windows, and one could not
402
+ fail.** `tests/locate.spec.ts` took basenames with `split('/')`, which returns
403
+ the whole path when the separator is a backslash.
404
+ `tests/docs-consistency.spec.ts` filtered source files with
405
+ `path.startsWith(`${root}src/`)`, splicing a forward slash onto a native root
406
+ — it matched nothing, so the check that every `§n` citation resolves was
407
+ passing on an empty set. `self/registry.spec.ts` compared a native path
408
+ against `ts.Diagnostic.file.fileName`, which TypeScript normalises to forward
409
+ slashes, so the type-level assertion measured zero diagnostics. Same class of
410
+ bug as the two above, in the tests rather than the engine.
411
+ `tests/loader.spec.ts` was separately fragile on every platform: it counted
412
+ `attest-loader-*` directories in the OS temp dir, making its verdict a
413
+ function of every other loader alive at that moment. It now asserts against
414
+ the loader's own `scratchDir`, and a third case pins that concurrent loaders
415
+ do not share one.
416
+
417
+ - **`cover --json` could answer `ok: false` with an empty `issues` array.** Three
418
+ places state one rule for machine consumers — `src/cli/json.ts`'s header, the
419
+ README, and 0.1.2's entry below — and all three say the same thing: branch on
420
+ `ok` and `issues[].code`, never on `message`. `cover` was the one command that
421
+ broke it. Its verdict was computed from the coverage rows
422
+ (`covered === rows.length`) while its envelope carried no issue at all, so a
423
+ consumer that followed the documented rule got a failing verdict, a summary of
424
+ three zeroes, and nothing saying what to fix; recovering the reason meant
425
+ knowing to read `coverage[]` instead — the out-of-band knowledge the contract
426
+ exists to make unnecessary. An uncovered requirement now reaches the report
427
+ twice, as a row and as an `uncovered-requirement` ERROR.
428
+ **The issue comes from the function `check` already uses.** `uncoveredIssues`
429
+ is extracted from `validateStructure` and called by both, so the two commands
430
+ cannot state one fact in two shapes — the same reason `declaredNotRunIssues`
431
+ was extracted in 0.1.7, and the same argument: agreement between two verdicts
432
+ should be structural, not clerical. `cover`'s `ok` now derives from the shared
433
+ `hasError` predicate like every other command, rather than from a second rule
434
+ that happened to agree with it.
435
+ *Behaviour change, for anyone already parsing `cover --json`:* a report on a
436
+ registry with uncovered requirements gains `issues[]` entries and a non-zero
437
+ `summary.error` where both were empty. `ok` and the exit code are unchanged for
438
+ every input — an uncovered requirement is the only issue this command produces,
439
+ so the new predicate returns the boolean the old one did. The human output is
440
+ also unchanged: the coverage table already renders each uncovered requirement
441
+ as a red `✗` row, and printing the same fact again underneath it would be noise
442
+ for the one reader who can see it. No shape change either — `issues` and
443
+ `summary` are existing envelope fields — so `schemaVersion` stays `1`.
444
+ The rejected `divergent-param` rule grew a **fourth** member on this change
445
+ (`code`, shared at that point by ATX-17, ATX-18, ATX-21 and ATX-25), which is
446
+ the measurement its entry under "Considered and rejected" predicted: a generic
447
+ param name collides more often as the registry grows, so the rule's
448
+ false-positive rate rises with adoption. Still zero true positives. ATX-26,
449
+ later in this same release, then pushed ATX-17 *out* of the set by naming the
450
+ same diagnostic — the sharper version of the same point, recorded in full
451
+ under "Considered and rejected" rather than restated here.
452
+
18
453
  ## [0.2.2] - 2026-07-26
19
454
 
20
455
  A wall of identical diagnostics now says once that it is one cause, and the
@@ -361,66 +796,6 @@ that identifies itself as attest output must carry the current `version` and
361
796
  `schemaVersion`. Neither can catch a section that describes something unbuilt —
362
797
  that is what the inline marker is for.
363
798
 
364
- ## Considered and rejected
365
-
366
- Decisions **not** to build something, kept where they can be found before the
367
- same candidate is proposed again. Nothing here shipped, so nothing here belongs
368
- to a release — this section is deliberately outside the version history and does
369
- not move when one is cut.
370
-
371
- - **Two gaps in the intent layer: nothing resists a bloated requirement, and
372
- nothing resists a duplicated one.** The gaps themselves are described in design
373
- §11, which is where a permanent property of the design belongs. What is kept
374
- here is the list of mechanisms tried and what killed each, so the same three
375
- are not proposed again without new evidence. All three were scored against
376
- every registry this repo ships *before* any was written into the engine — the
377
- order `divergent-param` was originally designed in the wrong way round. The
378
- measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
379
- when a requirement is added without a hand-labelled obligation count, because
380
- the reach figure once quoted here went stale exactly that way.
381
- *`compound-requirement` (WARNING) — rejected.* More than one RFC-2119 keyword
382
- in one `statement` is objectively more than one obligation. Measured: of the 7
383
- requirements in the corpus carrying more than one, it flags 2 (`ATX-10`,
384
- `ATX-21`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17` — every
385
- one that packs clauses under a single keyword, which is the form the coverage
386
- incentive actually rewards. A wider variant (keyword plus a coordinating
387
- `and`/`while`/`;`) reaches 6 of 7 with one wrong hit, so reach is not the
388
- deciding argument.
389
- This is: both variants are silenced by deleting the word that triggered them,
390
- and neither can tell that deletion from a real split. `SHALL do A and MUST do
391
- B` clears the warning as `SHALL do A and do B` — same two obligations, one
392
- fewer normative keyword. A rule whose cheapest fix degrades the artifact it
393
- protects does not ship.
394
- *`divergent-param` (WARNING) — rejected.* One param name declared with
395
- different values in two requirements sharing an id prefix. Measured: it fires
396
- twice on this repo and both firings are wrong — `idleTimeoutMin` (`15` in
397
- `ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
398
- (`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
399
- `rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
400
- agree. The second arrived on its own when `ATX-18` was added and grew a third
401
- member on its own again when `ATX-21` was, which is the more damning half: a
402
- generic param name collides more often as a registry grows, so the
403
- false-positive rate rises with adoption — the opposite of what a shippable rule
404
- does. Still zero true positives across 25 requirements. The escape hatch it was blocked on has since been designed
405
- and does not rescue it: with no config file the only workable shape is an
406
- additive `independentParams?: string[]` on the requirement (a CLI flag is
407
- per-invocation for what is a permanent property of two requirements; changing
408
- the `params` shape rejects every existing registry). Viable, but it is
409
- permanent schema surface for a rule with no demonstrated true positive, and in
410
- practice it would be written *after* the warning fires — a one-line silencer.
411
- *Countable obligations (breaking) — rejected.* An `obligations: string[]` per
412
- requirement, with coverage counted per obligation, removing the lumping
413
- incentive at the source instead of detecting it afterwards. Rejected because
414
- the **id is already that unit**: splitting into two ids costs two scenarios and
415
- buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
416
- split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
417
- and the `--json` schema, and it relocates the incentive rather than removing
418
- it — nothing can force the array to be complete.
419
- *Not considered:* similarity matching between statements (token overlap,
420
- embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
421
- real semantic duplication needs judgement and belongs to human review at the
422
- propose stage.
423
-
424
799
  ## [0.1.7] - 2026-07-26
425
800
 
426
801
  A change name, a prototype key and a collation quirk each got to decide a
@@ -741,7 +1116,164 @@ Initial release.
741
1116
  (MIT), whose four-stage engine and diff-first change model Attest's
742
1117
  architecture is adapted from (re-implemented from scratch, no source copied).
743
1118
 
744
- [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.2.2...main
1119
+ ## Considered and rejected
1120
+
1121
+ Decisions **not** to build something, kept where they can be found before the
1122
+ same candidate is proposed again. Nothing here shipped, so nothing here belongs
1123
+ to a release — this section is deliberately outside the version history and does
1124
+ not move when one is cut. It lives at the **foot** of the file for that reason:
1125
+ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
1126
+ sinking one version deeper each time a release was cut above it, and a rejection
1127
+ filed under a version reads as belonging to it.
1128
+
1129
+ - **Two gaps in the intent layer: nothing resists a bloated requirement, and
1130
+ nothing resists a duplicated one.** The gaps themselves are described in design
1131
+ §11, which is where a permanent property of the design belongs. What is kept
1132
+ here is the list of mechanisms tried and what killed each, so the same three
1133
+ are not proposed again without new evidence. All three were scored against
1134
+ every registry this repo ships *before* any was written into the engine — the
1135
+ order `divergent-param` was originally designed in the wrong way round. The
1136
+ measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
1137
+ when a requirement is added without a hand-labelled obligation count, because
1138
+ the reach figure once quoted here went stale exactly that way. The guard makes
1139
+ the *suite* re-count; it cannot make this file re-read, so every figure below
1140
+ is a quotation of that snapshot and is only ever as fresh as the last edit
1141
+ here — read the snapshot when the number is what matters.
1142
+ *`compound-requirement` (WARNING) — rejected.* More than one RFC-2119 keyword
1143
+ in one `statement` is objectively more than one obligation. Measured (figures
1144
+ re-read from the suite, which is the only place they are not prose): of the 8
1145
+ requirements in the corpus carrying more than one, it flags 3 (`ATX-10`,
1146
+ `ATX-21`, `ATX-26`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17`
1147
+ — every one that packs clauses under a single keyword, which is the form the
1148
+ coverage incentive actually rewards. A wider variant (keyword plus a
1149
+ coordinating `and`/`while`/`;`) reaches 7 of 8, at 3 wrong hits (`ATX-6`,
1150
+ `ATX-22`, `ATX-23`) rather than the 1 recorded when it was first scored — so
1151
+ reach is not the deciding argument, and the precision it trades away grows
1152
+ with the registry.
1153
+ This is: both variants are silenced by deleting the word that triggered them,
1154
+ and neither can tell that deletion from a real split. `SHALL do A and MUST do
1155
+ B` clears the warning as `SHALL do A and do B` — same two obligations, one
1156
+ fewer normative keyword. A rule whose cheapest fix degrades the artifact it
1157
+ protects does not ship.
1158
+ *`divergent-param` (WARNING) — rejected.* One param name declared with
1159
+ different values in two requirements sharing an id prefix. Measured: it fires
1160
+ twice on this repo and both firings are wrong — `idleTimeoutMin` (`15` in
1161
+ `ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
1162
+ (`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
1163
+ `rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
1164
+ agree. The second arrived on its own when `ATX-18` was added and grew a third
1165
+ member on its own again when `ATX-21` was, then `ATX-25`, then `ATX-26`, which
1166
+ is the more damning half: a generic param name collides more often as a
1167
+ registry grows, so the false-positive rate rises with adoption — the opposite
1168
+ of what a shippable rule does. `ATX-26` added the sharper version of the same
1169
+ point: it names the *same* diagnostic as `ATX-17`, so the two now agree by
1170
+ coincidence and `ATX-17` drops out of the set the rule reports — a rule that
1171
+ reads agreement as safety falls silent on a pair it had been flagging, for a
1172
+ reason that has nothing to do with either requirement.
1173
+ **A second param name now collides, and that is what closes the last defence
1174
+ of the rule.** `file` names a path Attest writes — the agent skill in `ATX-27`,
1175
+ a change's first-run record in `ATX-30` — two unrelated constants, a third
1176
+ wrong firing. Until it appeared, every false positive had been on the single
1177
+ name `code`, which left "pick a less generic param name" open as a repo-side
1178
+ workaround and therefore left the rule arguably salvageable. It is not the
1179
+ word: two requirements owning unrelated constants of the same *kind* is the
1180
+ structure, and any registry large enough to be worth linting has it.
1181
+ Still zero true positives, now across a 34-entry corpus (the `corpus` figure in
1182
+ the suite's snapshot, which counts a fixture id once per registry file that
1183
+ declares it).
1184
+ The escape hatch it was blocked on has since been designed
1185
+ and does not rescue it: with no config file the only workable shape is an
1186
+ additive `independentParams?: string[]` on the requirement (a CLI flag is
1187
+ per-invocation for what is a permanent property of two requirements; changing
1188
+ the `params` shape rejects every existing registry). Viable, but it is
1189
+ permanent schema surface for a rule with no demonstrated true positive, and in
1190
+ practice it would be written *after* the warning fires — a one-line silencer.
1191
+ *Countable obligations (breaking) — rejected.* An `obligations: string[]` per
1192
+ requirement, with coverage counted per obligation, removing the lumping
1193
+ incentive at the source instead of detecting it afterwards. Rejected because
1194
+ the **id is already that unit**: splitting into two ids costs two scenarios and
1195
+ buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
1196
+ split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
1197
+ and the `--json` schema, and it relocates the incentive rather than removing
1198
+ it — nothing can force the array to be complete.
1199
+ *Not considered:* similarity matching between statements (token overlap,
1200
+ embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
1201
+ real semantic duplication needs judgement and belongs to human review at the
1202
+ propose stage.
1203
+
1204
+ - **A freshness gate on the file `attest init` writes (`init --check`).**
1205
+ `render --check` is the obvious precedent and is the opposite case. A rendering
1206
+ is a function of the *user's registry*, so its staleness means the user moved
1207
+ intent without regenerating — a real signal, with the fix in their hands. The
1208
+ skill is a function of the *installed version*, so a gate on it turns red on a
1209
+ change the user did not make, and is cleared by a commit containing no
1210
+ decision. That is a chore wearing a diagnostic's clothes, and a gate that
1211
+ cannot be about anything the reader did is worse than no gate — the same
1212
+ standard 0.1.6 set when it gave `render` no formatting options at all.
1213
+ An earlier draft of `attest init` spent a second command, this gate and a
1214
+ second self-requirement on it. What that would have bought is immunity to one
1215
+ honest and small exposure: an old file describes an old workflow to an agent
1216
+ that trusts it. The backstop is that every mistake it can cause is already a
1217
+ diagnostic with a fix hint — a registry written the old way is
1218
+ `registry-not-static` — so the agent corrects itself from the report, and
1219
+ `init` is idempotent, which makes re-running it the entire update mechanism.
1220
+ Recorded here rather than left in the 0.3.0 entry that shipped `init`, because
1221
+ the queue above depends on it: a gate is the first thing proposed each time a
1222
+ new agent target is considered, and the answer must not need re-deciding per
1223
+ target.
1224
+
1225
+ - **A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`.** The cross-tool
1226
+ convention Codex, Cursor and Copilot read, and the reason it looks like the
1227
+ obvious next target after the skill is that it reaches the most agents per unit
1228
+ of work. Rejected on three counts, none of which is about effort.
1229
+ **It is the only file `init` would write that Attest does not own.**
1230
+ `.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
1231
+ exists because the *project* does, and what is in it is prose its maintainer
1232
+ wrote and curated. Writing a block into it means an idempotent
1233
+ read-modify-write on someone else's document — find the markers, preserve
1234
+ everything outside them, and then handle the markers being absent, duplicated,
1235
+ hand-edited, moved, the file missing, the file being `CLAUDE.md` instead, or
1236
+ both existing. That is a merge tool, and each of its failure modes is
1237
+ destructive on a file the user cannot regenerate.
1238
+ **It would remove the property that makes `init` safe to automate.**
1239
+ `skillContent()` takes no argument — the content is a pure function of nothing
1240
+ at all — so `init` is one atomic write and its idempotence holds *by
1241
+ construction*, which is what lets "re-run it after upgrading" be the whole
1242
+ update mechanism and be safe in a script. A marked block makes the output a
1243
+ function of the file already there, demoting idempotence from a property of the
1244
+ design to a behaviour that needs test coverage, on the one path where being
1245
+ wrong destroys input.
1246
+ **It is a worse carrier for the same text.** An `AGENTS.md` block is
1247
+ unconditionally resident in the agent's context, where a skill body is loaded
1248
+ when its description matches — so every unrelated request in that repo pays for
1249
+ a workflow it will not use. It was never a port of the skill; it is a second,
1250
+ weaker capability wearing the same words.
1251
+ The interaction with the entry above is what settles it: a user-edited block
1252
+ cannot be replaced on upgrade without discarding the edit, so this shape needs
1253
+ either the freshness gate rejected above or a three-way merge. *What replaces
1254
+ it*, for an adopter whose agent reads `AGENTS.md`: one line they add
1255
+ themselves, pointing at the skill file. A pointer is cheap enough to be
1256
+ resident, it is their edit in their file, and it cannot go stale — the file it
1257
+ names is the one `init` keeps current.
1258
+
1259
+ - **A Codex target for `attest init`.** Evaluated when `init` grew `--target` and
1260
+ shipped for Claude Code, Cursor and Copilot; rejected because the tool offers
1261
+ nothing to write, not because of what writing it would cost. Codex's
1262
+ project-scoped instruction surface is `AGENTS.md`, which the entry above
1263
+ rejects permanently, and its custom prompts live in `~/.codex/prompts` — per
1264
+ machine, outside the project root `init` is pointed at, and not a thing a
1265
+ project can commit or a CI checkout can carry. That leaves no path that is both
1266
+ **Attest's own** and **read by the tool**, so the two rules a target has to
1267
+ satisfy are not merely unmet here, they are unmeetable with what exists: any
1268
+ Codex-shaped implementation would be either the rejected merge or a file
1269
+ nothing reads, and the second is worse, because it looks like coverage.
1270
+ Filed here rather than queued as a candidate, since nothing about the effort
1271
+ or the evidence is what is holding it — the pointer line above is the whole
1272
+ answer for a Codex user, and this reopens only if Codex grows a project-scoped
1273
+ path of its own.
1274
+
1275
+ [Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.3.0...main
1276
+ [0.3.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.3.0
745
1277
  [0.2.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.2
746
1278
  [0.2.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.1
747
1279
  [0.2.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.0