@am_shork/attest 0.2.2 → 0.4.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.
- package/CHANGELOG.md +876 -62
- package/README.md +157 -19
- package/dist/cli/index.js +91 -21
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/json.d.ts +52 -5
- package/dist/cli/json.d.ts.map +1 -1
- package/dist/cli/json.js +50 -2
- package/dist/cli/json.js.map +1 -1
- package/dist/cli/report.d.ts +11 -1
- package/dist/cli/report.d.ts.map +1 -1
- package/dist/cli/report.js +114 -3
- package/dist/cli/report.js.map +1 -1
- package/dist/core/apply.d.ts +8 -0
- package/dist/core/apply.d.ts.map +1 -1
- package/dist/core/apply.js +10 -0
- package/dist/core/apply.js.map +1 -1
- package/dist/core/gate.d.ts +27 -1
- package/dist/core/gate.d.ts.map +1 -1
- package/dist/core/gate.js +48 -1
- package/dist/core/gate.js.map +1 -1
- package/dist/core/loader.d.ts +8 -0
- package/dist/core/loader.d.ts.map +1 -1
- package/dist/core/loader.js +19 -1
- package/dist/core/loader.js.map +1 -1
- package/dist/core/locate.d.ts +22 -1
- package/dist/core/locate.d.ts.map +1 -1
- package/dist/core/locate.js +76 -7
- package/dist/core/locate.js.map +1 -1
- package/dist/core/paths.d.ts +15 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +36 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/pipeline.d.ts +75 -5
- package/dist/core/pipeline.d.ts.map +1 -1
- package/dist/core/pipeline.js +212 -35
- package/dist/core/pipeline.js.map +1 -1
- package/dist/core/red-record.d.ts +56 -0
- package/dist/core/red-record.d.ts.map +1 -0
- package/dist/core/red-record.js +177 -0
- package/dist/core/red-record.js.map +1 -0
- package/dist/core/render.d.ts +1 -1
- package/dist/core/render.d.ts.map +1 -1
- package/dist/core/render.js +16 -2
- package/dist/core/render.js.map +1 -1
- package/dist/core/runner.d.ts +1 -1
- package/dist/core/runner.d.ts.map +1 -1
- package/dist/core/runner.js +36 -11
- package/dist/core/runner.js.map +1 -1
- package/dist/core/skill.d.ts +23 -0
- package/dist/core/skill.d.ts.map +1 -0
- package/dist/core/skill.js +276 -0
- package/dist/core/skill.js.map +1 -0
- package/dist/core/static-registry.d.ts +28 -0
- package/dist/core/static-registry.d.ts.map +1 -1
- package/dist/core/static-registry.js +91 -25
- package/dist/core/static-registry.js.map +1 -1
- package/dist/core/status.d.ts +39 -0
- package/dist/core/status.d.ts.map +1 -0
- package/dist/core/status.js +64 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/targets.d.ts +52 -0
- package/dist/core/targets.d.ts.map +1 -0
- package/dist/core/targets.js +141 -0
- package/dist/core/targets.js.map +1 -0
- package/dist/core/types.d.ts +13 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/validator.d.ts +11 -0
- package/dist/core/validator.d.ts.map +1 -1
- package/dist/core/validator.js +28 -12
- package/dist/core/validator.js.map +1 -1
- package/dist/core/write.d.ts +2 -0
- package/dist/core/write.d.ts.map +1 -0
- package/dist/core/write.js +21 -0
- package/dist/core/write.js.map +1 -0
- package/dist/runtime.d.ts +9 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +10 -2
- package/dist/runtime.js.map +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -13,7 +13,645 @@ input, removes/renames a public API or `--json` field, or changes a default
|
|
|
13
13
|
runtime behavior an existing invocation relies on — diagnostic message text is
|
|
14
14
|
not API.
|
|
15
15
|
|
|
16
|
-
## [
|
|
16
|
+
## [0.4.0] - 2026-07-28
|
|
17
|
+
|
|
18
|
+
Reading a registry no longer opens a port or lets the repository under test write
|
|
19
|
+
to the reader's terminal, the id prefix finally has something that notices two
|
|
20
|
+
files claiming it, and the peer range now says what CI actually proves rather
|
|
21
|
+
than what the tool was hoped to work on.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Two registry files claiming one id prefix is now an ERROR.** The id grammar
|
|
26
|
+
is one flat space, `^[A-Z]+-\d+$`, and the prefix inside it was habit rather
|
|
27
|
+
than scope: nothing allocated it, reserved it, or noticed that two files had
|
|
28
|
+
picked the same one (design §11). Every cross-file check there was keys on the
|
|
29
|
+
whole id, so `AUTH-3` in `auth.reqs.ts` and `AUTH-9` in `session.reqs.ts` was
|
|
30
|
+
a clean `check` — two owners of one space, and the first anyone would hear of
|
|
31
|
+
it is the `duplicate-requirement` on the day the counters happen to meet, by
|
|
32
|
+
which point both files have a history. `loadRegistry` (`locate.ts`) already
|
|
33
|
+
held the `(file, id)` pair in the fold where `duplicate-requirement` is
|
|
34
|
+
raised, so the whole mechanism is a prefix→file map in that same loop, and
|
|
35
|
+
because it is that fold, every command that reads intent gets the diagnostic:
|
|
36
|
+
`check`, `cover`, `render`, `status`, `verify`, `archive`.
|
|
37
|
+
**What the issue carries.** `file` is the file that arrived *second* in sorted
|
|
38
|
+
order, matching `duplicate-requirement`, which also files against the file
|
|
39
|
+
being folded — and the file that claimed the prefix first is named in the
|
|
40
|
+
message, because a collision is a fact about a pair, an `Issue` carries one
|
|
41
|
+
path, and an error naming only one end leaves its reader grepping the
|
|
42
|
+
repository to find the other. There is **no `reqId`**: the ids involved are
|
|
43
|
+
individually valid and it is the space above them that has two owners, so
|
|
44
|
+
there is no single requirement the issue is about. It is raised **once per
|
|
45
|
+
colliding prefix per file**, not once per requirement — a registry with forty
|
|
46
|
+
ids under a taken prefix reports one sentence, not forty. Where a duplicate id
|
|
47
|
+
is also present both fire, which is not a restatement: sharing `A-1` implies
|
|
48
|
+
sharing `A-`, and the converse — the case this exists for — does not hold.
|
|
49
|
+
Now a requirement (ATX-39).
|
|
50
|
+
**Not a behaviour change for anything that passes today**, and that was
|
|
51
|
+
checked rather than assumed: all six `*.reqs.ts` in this repo sit in six
|
|
52
|
+
separate project roots holding one registry file each, so no root has two
|
|
53
|
+
files to collide. It is still a new ERROR on input that used to pass, so an
|
|
54
|
+
adopter whose registries share a prefix goes red on upgrade with nothing else
|
|
55
|
+
changing — deliberately, since that is the state the rule exists to report,
|
|
56
|
+
and the fix is a prefix rather than a migration. `SCHEMA_VERSION` stays 1: a
|
|
57
|
+
new `code` value is additive, and neither the envelope nor the `Issue`
|
|
58
|
+
interface moved.
|
|
59
|
+
The other half of the rule this was cut from — that a prefix must match its
|
|
60
|
+
file's name — was **rejected**; see "Considered and rejected".
|
|
61
|
+
|
|
62
|
+
### Security
|
|
63
|
+
|
|
64
|
+
- **Reading a registry opened a network port.** The loader borrows Vite to
|
|
65
|
+
transform and evaluate a `*.reqs.ts`, and `middlewareMode` suppresses Vite's
|
|
66
|
+
HTTP server but not its HMR WebSocket server — so `verify`, `archive`, and any
|
|
67
|
+
read command under `--eval` bound port 24678 on *every* interface for the
|
|
68
|
+
length of the run, on developer machines and CI runners alike, for a channel
|
|
69
|
+
nothing here subscribes to. `server.ws` is now `false`, which is the option
|
|
70
|
+
that actually closes it: `hmr: false` does not, because the socket is created
|
|
71
|
+
before the HMR option is consulted. The visible symptom was two concurrent
|
|
72
|
+
runs colliding and printing `WebSocket server error: Port is already in use`
|
|
73
|
+
into the middle of a report, through a log level that was supposed to have
|
|
74
|
+
silenced the loader. Now a requirement (ATX-36), asserted as a count of
|
|
75
|
+
listening handles rather than a port number, because the port is Vite's to
|
|
76
|
+
change and the property Attest needs is that reading intent is not a network
|
|
77
|
+
operation.
|
|
78
|
+
- **The human report let the repository under test write to the reader's
|
|
79
|
+
terminal.** Scenario names and file paths are quoted verbatim into
|
|
80
|
+
`orphan-test`, `declared-not-run` and `never-red`, and the parser hands back
|
|
81
|
+
the *cooked* string — so an escape written in a spec file arrived at the
|
|
82
|
+
terminal as a real control byte. On a fork merge request the author of that
|
|
83
|
+
string is not the person reading the report, and the payload erases lines,
|
|
84
|
+
repaints a red verdict green, or retitles the window. Every control character
|
|
85
|
+
except the newline is now replaced with a space, in the message, the file
|
|
86
|
+
path, the code, and the ids and change name a `status` report prints. The
|
|
87
|
+
newline survives, because `registry-invalid` legitimately carries a list of
|
|
88
|
+
field errors — but the continuation is indented, so injected text cannot
|
|
89
|
+
occupy the column a genuine header does. `--json` was never affected
|
|
90
|
+
(serialising escapes the same characters), which is exactly why this had to be
|
|
91
|
+
fixed on the side people read. Now a requirement (ATX-37).
|
|
92
|
+
- **Dependency advisories, and a gate so the next ones are not found by hand.**
|
|
93
|
+
The dev toolchain moved to Vite 8 and Vitest 4, clearing a high-severity
|
|
94
|
+
`server.fs.deny` bypass on Windows alternate paths, an NTLMv2 hash disclosure
|
|
95
|
+
via UNC path handling, two moderate path-traversal/dev-server advisories, and
|
|
96
|
+
a critical Vitest UI advisory that this tool never reached but shipped in its
|
|
97
|
+
lockfile regardless. `brace-expansion` is pinned past its DoS advisory through
|
|
98
|
+
a `pnpm.overrides` entry, since it arrives only through ESLint. A new `audit`
|
|
99
|
+
CI job runs `pnpm audit --audit-level=high`, not `allow_failure`: it is the
|
|
100
|
+
one job here that can go red on a commit that changed nothing, which is the
|
|
101
|
+
point — a dependency becomes vulnerable on the day it is disclosed, not on the
|
|
102
|
+
day someone next edits `package.json`. This also closes a real coverage gap:
|
|
103
|
+
`peerDependencies` has claimed Vite 8 and Vitest 4 since 0.3.0 while CI only
|
|
104
|
+
ever ran Vite 5 and Vitest 2.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **`--eval` crashed on a registry the reading path diagnoses properly.** The
|
|
109
|
+
evaluating reader called a module's default export a registry because it was
|
|
110
|
+
an object — an assertion standing in for a check, on the one path where the
|
|
111
|
+
value has not already been through `defineRequirements`. A file that
|
|
112
|
+
default-exports a plain literal instead of calling it therefore arrived at the
|
|
113
|
+
validator unchecked, where reading `.statement` off a number threw: `--eval`
|
|
114
|
+
answered with an `internal-error` carrying a raw TypeError, for a file the
|
|
115
|
+
static reader reports with a code, a line and a fix hint — and that fix hint
|
|
116
|
+
is "pass `--eval`", so the documented way out of one diagnostic led into a
|
|
117
|
+
crash. The evaluating path now hands what it loaded to the same
|
|
118
|
+
`RegistrySchema`, so both readers refuse the same file. Behaviour change for
|
|
119
|
+
anyone branching on the code: this input used to produce `internal-error` and
|
|
120
|
+
now produces `registry-invalid`; both are ERRORs, so no exit code moves. It is
|
|
121
|
+
also a stricter read than before — a registry that `--eval` accepted only
|
|
122
|
+
because nothing looked at it is now rejected, and unknown fields are stripped
|
|
123
|
+
by the schema exactly as `defineRequirements` already strips them. Now a
|
|
124
|
+
requirement (ATX-38), kept apart from ATX-17: that one promises the escape
|
|
125
|
+
hatch exists and is honest about its cost, and a reader can satisfy it
|
|
126
|
+
completely while validating nothing it evaluates — which is the state this
|
|
127
|
+
was in.
|
|
128
|
+
- **A corrupt first-run record leaked a value that is not an outcome into the
|
|
129
|
+
report.** Reading `changes/<name>/first-run.json` asserted its contents rather
|
|
130
|
+
than checking them: the guard tested that `firstRun` was an object and was
|
|
131
|
+
*declared* to return the record type, so a hand-edited or half-written file
|
|
132
|
+
put an arbitrary string where `pass`/`fail` is the whole domain. It travelled
|
|
133
|
+
— out through `status`'s `progress[].scenarios[].firstRun`, whose declared
|
|
134
|
+
type is `Outcome | null`, into the `--json` contract, and into a human row
|
|
135
|
+
whose hint matched neither "passed first" nor "never run" and so printed
|
|
136
|
+
blank. The archive gate was never at risk, because `hasRecordedRed` compares
|
|
137
|
+
against `fail` and anything unrecognised blocks exactly like a missing record;
|
|
138
|
+
this closes the report, not the gate. A record that does not validate is now
|
|
139
|
+
discarded whole, which is the existing "no more permissive than a missing
|
|
140
|
+
file" rule one step further: the cost is a stage-1 run to re-observe, and
|
|
141
|
+
keeping the entries that happened to parse would present a partial history as
|
|
142
|
+
if it were the whole one. Behaviour change confined to corrupt files: that
|
|
143
|
+
`--json` field now reads `null` where it used to echo whatever was on disk.
|
|
144
|
+
- **The test verdict could fail open.** `runAndCollect` read the failure count
|
|
145
|
+
through `getCountOfFailedTests?.() ?? 0`, but the method is required on
|
|
146
|
+
Vitest's state — so the guard was dead at the type level, and had it ever
|
|
147
|
+
become live it defaulted the wrong way, reading a missing API as zero failures
|
|
148
|
+
and turning a red suite green. Latent rather than observed: no released
|
|
149
|
+
version reported a wrong verdict because of it. This is the one boolean in the
|
|
150
|
+
engine that may never fail open.
|
|
151
|
+
- **`archive` answered `internal-error` for a file `status` diagnoses
|
|
152
|
+
properly.** A `requirements.delta.ts` that loads but default-exports nothing
|
|
153
|
+
reached `applyDelta` as `undefined`, where reading `.renamed` off it threw a
|
|
154
|
+
TypeError — so the gate crashed out through the `--json` envelope's crash path
|
|
155
|
+
instead of reporting `change-not-found`, and the fix hint went with it.
|
|
156
|
+
`archive` now makes the same check `status` already made. Behaviour change for
|
|
157
|
+
anyone branching on the code: this input used to produce `internal-error` and
|
|
158
|
+
now produces `change-not-found`. Both are ERRORs, so no exit code moves.
|
|
159
|
+
- **A sibling change directory containing a backslash silently widened the
|
|
160
|
+
archive gate.** `escapeGlob` escaped every glob metacharacter except
|
|
161
|
+
glob's *own* escape character, so a name like `a\b` — legal on POSIX — did not
|
|
162
|
+
merely go unescaped: the backslash escaped the character after it, and
|
|
163
|
+
`**/changes/a\b/**` matched `ab` and never the directory. That proposal's
|
|
164
|
+
specs then joined the run of the one check that decides whether a change is
|
|
165
|
+
done. Covered by ATX-13, which already stated the property.
|
|
166
|
+
- **A self scenario that compiled a TypeScript program had no explicit
|
|
167
|
+
timeout**, so it fit inside Vitest's 5-second isolated-run default until the
|
|
168
|
+
toolchain moved and then failed as `tests-red` with no assertion behind it —
|
|
169
|
+
the failure mode the repo's other long scenarios already carry a timeout to
|
|
170
|
+
avoid.
|
|
171
|
+
|
|
172
|
+
### Changed
|
|
173
|
+
|
|
174
|
+
- **BREAKING: the peer range is now `vite ^8` and `vitest ^4`, down from six
|
|
175
|
+
majors.** `^5 || ^6 || ^7 || ^8` and `^2 || ^3 || ^4` was a claim about six
|
|
176
|
+
combinations that no job ever ran end to end. What it looked like from inside
|
|
177
|
+
the repo — the security entry above says as much — was that the *top* was
|
|
178
|
+
untested; the fixture told a different story. `fixtures/consumer` pinned
|
|
179
|
+
`vitest ^2.0.0` / `vite ^5.4.0`, and the consumer job is the **only** thing in
|
|
180
|
+
this pipeline that resolves peers at all, so the two ends were each proven by
|
|
181
|
+
one job apiece and `vite ^6`/`^7` and `vitest ^3` were proven by nothing.
|
|
182
|
+
Advertising a version nobody runs is the same defect the `audit` job was added
|
|
183
|
+
to close, one layer out: the failure surfaces in an adopter's repository, not
|
|
184
|
+
here.
|
|
185
|
+
**Two of them had already stopped being merely untested.** `verify` reads
|
|
186
|
+
`startVitest` as `Promise<Vitest>` — the dead null guard removed above was
|
|
187
|
+
there for the older signature — and ATX-36 asserts a listening-handle count
|
|
188
|
+
against whichever Vite is installed, so on any other major the socket
|
|
189
|
+
guarantee is a claim rather than a measurement. Neither is a defect that would
|
|
190
|
+
announce itself; both fail in the direction of looking fine.
|
|
191
|
+
`fixtures/consumer` moves to `vitest ^4` / `vite ^8` with the range, because
|
|
192
|
+
npm refuses an unmet peer and the fixture exists to install the way a user
|
|
193
|
+
does. That does cost something real and it is worth naming: the consumer job
|
|
194
|
+
no longer exercises a second Vite/Vitest pair, so this trades a spot-check of
|
|
195
|
+
one old combination for a claim that matches the tested one. A matrix is what
|
|
196
|
+
buys the breadth back, and nothing here pretends this is that.
|
|
197
|
+
**Who this breaks:** anyone on Vite 5–7 or Vitest 2–3 fails to install on
|
|
198
|
+
upgrade, with no deprecation window. Under this file's Versioning rule that is
|
|
199
|
+
a minor, which is what 0.4.0 is. The engine may well still work on some of
|
|
200
|
+
them — that is precisely the claim there is no evidence for, and `0.3.x`
|
|
201
|
+
remains the version whose manifest makes it.
|
|
202
|
+
|
|
203
|
+
- **Type-aware linting, and `exactOptionalPropertyTypes`.** The three defects
|
|
204
|
+
above are one shape — an assertion standing in for a check — and the untyped
|
|
205
|
+
ESLint preset cannot see any of them, because deciding whether an `as` or a
|
|
206
|
+
`?.` is doing work needs the checker. `recommendedTypeChecked` now runs over
|
|
207
|
+
`src`, `tests` and `self`, with `no-unnecessary-condition` promoted out of the
|
|
208
|
+
strict preset; the rest of that preset is deliberately not adopted, since its
|
|
209
|
+
bulk is `no-non-null-assertion`, and here `!` is the counterpart of
|
|
210
|
+
`noUncheckedIndexedAccess` — banning it would trade a compiler-enforced
|
|
211
|
+
guarantee for a lint-enforced style. `fixtures/**` stays on the untyped preset
|
|
212
|
+
because several fixtures are invalid on purpose. Turning the rules on removed
|
|
213
|
+
a double cast that hid Vitest's task-tree shape from the compiler, a redundant
|
|
214
|
+
assertion in the delta reader that made an unvalidated value look checked, and
|
|
215
|
+
a dead `startVitest` null guard left over from an older Vitest signature.
|
|
216
|
+
`exactOptionalPropertyTypes` is now on as well: the codebase already followed
|
|
217
|
+
it by hand — the `...(x === undefined ? {} : { key: x })` idiom appears in
|
|
218
|
+
every Issue builder — so this makes an existing discipline the compiler's job
|
|
219
|
+
rather than review's. No `--json` shape changed, so `SCHEMA_VERSION` stays 1.
|
|
220
|
+
|
|
221
|
+
## [0.3.0] - 2026-07-27
|
|
222
|
+
|
|
223
|
+
The workflow half now reaches the agent that will read it, the archive gate can
|
|
224
|
+
tell a test that ever discriminated from one that never could, and a change in
|
|
225
|
+
flight has a reportable state between "not started" and "done". Underneath all
|
|
226
|
+
three: every path Attest derives from a project root now has one spelling on
|
|
227
|
+
every platform, which is what makes them work on Windows at all.
|
|
228
|
+
|
|
229
|
+
### Added
|
|
230
|
+
|
|
231
|
+
- **The workflow reached one agent, and every other agent got the engine without
|
|
232
|
+
it.** `attest init` wrote `.claude/skills/attest/SKILL.md` and nothing else —
|
|
233
|
+
the gap `init` exists to close, still open for every tool but one. It now takes
|
|
234
|
+
`--target`, repeatable, and writes the same document for Claude Code (the
|
|
235
|
+
default), Cursor (`.cursor/rules/attest.mdc`) and GitHub Copilot
|
|
236
|
+
(`.github/instructions/attest.instructions.md`).
|
|
237
|
+
**Two rules decide what may be a target, and the first was already permanent.**
|
|
238
|
+
*Attest owns the path*: each file above exists because Attest exists, is
|
|
239
|
+
written whole, and reads nothing of the user's — which is why an `AGENTS.md` or
|
|
240
|
+
`CLAUDE.md` marked block stays rejected outright (see "Considered and
|
|
241
|
+
rejected"), and why the answer for an agent that reads only such a file is a
|
|
242
|
+
one-line pointer the user writes themselves. *The instructions load on demand*
|
|
243
|
+
is the second, and it was the open question when this was queued: a skill body
|
|
244
|
+
is fetched when its description matches, a Cursor rule when the agent asks for
|
|
245
|
+
it (`alwaysApply: false`, and deliberately no `globs`, which is the auto-attach
|
|
246
|
+
trigger and a different thing), a Copilot instructions file when the open path
|
|
247
|
+
matches its `applyTo`. A carrier that is unconditionally resident charges every
|
|
248
|
+
unrelated request in the repository for a workflow it will not use.
|
|
249
|
+
**One document, three frontmatters, and that split is a requirement (ATX-34).**
|
|
250
|
+
Each target carries the same body byte for byte; only the frontmatter differs,
|
|
251
|
+
because that is the part the tool parses rather than the part the agent
|
|
252
|
+
follows. A copy per tool would be two agents told different things about one
|
|
253
|
+
engine — the drift this framework exists to report, occurring where nothing
|
|
254
|
+
reports it, since no command reads these files back. The requirement is written
|
|
255
|
+
over *every* target rather than over a list of them, so adding one cannot
|
|
256
|
+
satisfy it by being left out. What it costs falls on the targets with no
|
|
257
|
+
description-triggered mode: Copilot's trigger is the path, and `applyTo` names
|
|
258
|
+
the registries, the specs and `changes/`, so the instructions arrive when the
|
|
259
|
+
agent is editing intent and not when it is editing an implementation to make a
|
|
260
|
+
scenario pass — which is exactly when "do not edit the scenario instead" would
|
|
261
|
+
be worth reading. Widening that glob to `**` buys the missing case at the price
|
|
262
|
+
rule 2 declines to pay.
|
|
263
|
+
**Naming no target writes what it wrote before; an unknown name writes nothing
|
|
264
|
+
at all (ATX-35).** Writing every target by default would scatter configuration
|
|
265
|
+
for editors nobody in the project uses. Refusing the whole set on an
|
|
266
|
+
unrecognised name is the ATX-13 argument under a different name: a name already
|
|
267
|
+
known to be unusable must not cause a single write, or a failing run leaves a
|
|
268
|
+
repository carrying instructions for some agents and no statement of which.
|
|
269
|
+
Two requirements rather than one, for the ATX-23/ATX-24 reason — an
|
|
270
|
+
implementation that writes every known target on every run carries one document
|
|
271
|
+
faithfully, and one that gets the requested set right can still fail on an
|
|
272
|
+
unknown name after writing half of it. `unknown-target` is a new `code` on the
|
|
273
|
+
existing `Issue` shape.
|
|
274
|
+
**Codex was evaluated and is not a target**, because the tool offers no path
|
|
275
|
+
Attest can own: `AGENTS.md`, or prompts under `~/.codex/prompts`, outside the
|
|
276
|
+
project root entirely. Recorded under "Considered and rejected", where it stays
|
|
277
|
+
findable once this release is history.
|
|
278
|
+
**It shipped without the evidence the queued candidate demanded.** That
|
|
279
|
+
candidate named one line in one adoption report naming a tool as its trigger,
|
|
280
|
+
and no such line arrived: the three targets are a judgement about which tools
|
|
281
|
+
adopters use, and should be read as one. What limits the cost of being wrong is
|
|
282
|
+
that the guess is now priced per row — the document, the writer and the CLI are
|
|
283
|
+
general over the table, so a target is a path, a frontmatter and a scenario —
|
|
284
|
+
and that the two rules above are what keep the table from growing into a
|
|
285
|
+
distribution tail. The measurement behind that phrase, kept because it is the
|
|
286
|
+
argument for where the next effort goes: OpenSpec 1.6.0 publishes 25,733 lines
|
|
287
|
+
of `dist/` against this package's 2,513 lines of `src/`, roughly four fifths of
|
|
288
|
+
it distribution rather than engine (29 per-tool adapters, 1,169 lines of
|
|
289
|
+
`init.js` and `update.js`, three shell-completion installers) — while its engine
|
|
290
|
+
is the weaker half on every axis this project was built to win: a `verify` that
|
|
291
|
+
asks the agent for "reasonable inference" where §8's gate takes a set
|
|
292
|
+
difference against the Vitest task tree, a requirement primary key that is the
|
|
293
|
+
Markdown header text, and a Markdown reader carrying a visible repair history
|
|
294
|
+
that an AST does not pay. The gap was never engine quality; it was that
|
|
295
|
+
`openspec init` is one command and this was a paste. Twenty-nine adapters
|
|
296
|
+
against three targets is what remains of that comparison, and widening it needs
|
|
297
|
+
a named tool with a reader blocked on it, not a count of tools that exist.
|
|
298
|
+
*Additive:* `attest init` with no flags writes the same bytes to the same path
|
|
299
|
+
as before. `--json` gains `outFiles` — every file written, since a run can now
|
|
300
|
+
write several — and keeps `outFile` whenever exactly one was, which the default
|
|
301
|
+
invocation still is, so `schemaVersion` stays `1`. The only new exit-1 path is
|
|
302
|
+
reached through a flag that did not exist before this release.
|
|
303
|
+
Two measurements moved. `divergent-param` collected a **seventh** member on
|
|
304
|
+
`code` (ATX-35), still with zero true positives. And `compound-requirement`'s
|
|
305
|
+
reach passed its blind spot for the first time: the suite asserted
|
|
306
|
+
`missed.length >= correct.length`, and ATX-35 falsified it by arriving, like
|
|
307
|
+
ATX-33, with both obligations declared under two keywords. `missed` lost no
|
|
308
|
+
member — the rule sees no further, and what moved is how carefully the corpus
|
|
309
|
+
was written. Rewriting that assertion to fit would have been the measurement
|
|
310
|
+
bending to the corpus, so it now asserts what actually blocks the rule (the
|
|
311
|
+
blind spot never empties), with the movement in the inline snapshot.
|
|
312
|
+
|
|
313
|
+
- **A change had one touchpoint, and it only ever answered "not yet".**
|
|
314
|
+
`attest archive <change>` decides whether a change is done; between "not
|
|
315
|
+
started" and "archivable" there was no reportable state at all, so asking what
|
|
316
|
+
remained meant reading the delta, cross-referencing the specs and inferring —
|
|
317
|
+
work an agent redoes on every turn and a person does badly. `attest status
|
|
318
|
+
<change>` reports each requirement the change ADDs as one of three states:
|
|
319
|
+
`no-scenario`, `unproven` (a scenario exists but has never been seen failing)
|
|
320
|
+
or `proven`. It runs nothing, reading the delta, the specs and
|
|
321
|
+
`changes/<name>/first-run.json`.
|
|
322
|
+
**Its blocker was that there was nothing to report, and mechanism 2 removed
|
|
323
|
+
it.** This was queued under "Under consideration" with exactly that objection:
|
|
324
|
+
Attest has no artifact graph and should not grow one, so the only progress
|
|
325
|
+
signal worth printing is per-id — which added requirements have a scenario, and
|
|
326
|
+
whether that scenario has been red — and the red half did not exist. Shipping
|
|
327
|
+
mechanism 2 in this same release made `first-run.json` precisely that per-id
|
|
328
|
+
state, which reduced the entry to a presentation question. What decided it is
|
|
329
|
+
that the fork the entry named is now cheap on one side: a `status` that runs the
|
|
330
|
+
suite is `verify` with a filter, and the one that does not is a small file read
|
|
331
|
+
plus a static plan.
|
|
332
|
+
**It is a projection of the gate, not a second gate.** Each state is named
|
|
333
|
+
after the issue `archive` will raise — `no-scenario` is
|
|
334
|
+
`uncovered-requirement`, `unproven` is `never-red` — and the decision runs
|
|
335
|
+
through the gate's own predicate, extracted as `hasRecordedRed`, for the reason
|
|
336
|
+
`declaredNotRunIssues` and `uncoveredIssues` were extracted before it:
|
|
337
|
+
agreement between two verdicts should be structural, not clerical. The plan and
|
|
338
|
+
the added-id set come from the same two functions `archive` uses, so a progress
|
|
339
|
+
report cannot be computed over a different spec set than the gate's.
|
|
340
|
+
**Progress never reaches `ok`, and that is a requirement rather than a
|
|
341
|
+
detail.** The cheapest implementation is to push the existing `uncovered-` and
|
|
342
|
+
`never-red` issues into `issues[]` — which satisfies the reporting obligation
|
|
343
|
+
completely while putting them in `summary.error`, where the shared `hasError`
|
|
344
|
+
verdict turns them into `ok: false` and exit 1. That is the normal state of
|
|
345
|
+
every change in flight, so a CI step or an agent loop wrapping `status` would
|
|
346
|
+
read "failing" for the change's whole life. Split across two self-requirements
|
|
347
|
+
(ATX-32, ATX-33) for the same reason ATX-23/ATX-24 were: the wrong
|
|
348
|
+
implementation passes the first and needs a scenario, not a clause, to catch.
|
|
349
|
+
The one thing that does make it fail is intent it could not read — a missing or
|
|
350
|
+
unreadable delta — because a command that answered ok over an empty table would
|
|
351
|
+
be the vacuous green ATX-18 and ATX-26 each closed elsewhere.
|
|
352
|
+
**What it cannot answer is `tests-red`**, and the human rendering closes by
|
|
353
|
+
saying so (`Not a verdict: run attest archive <name>`). A column of green ticks
|
|
354
|
+
here means the readable obligations are met, and a report that can be mistaken
|
|
355
|
+
for a verdict is worse than no report.
|
|
356
|
+
**It reads the delta from source, so `status` joins ATX-16's static commands.**
|
|
357
|
+
That requirement's `staticCommands` param grows to four and its statement now
|
|
358
|
+
says "the intent they report on, whether a registry or a change delta" — the
|
|
359
|
+
registry extractor was generalised over the authoring function's name rather
|
|
360
|
+
than copied, so `delta({ … })` goes through the same one walker that decides
|
|
361
|
+
whether a value is fixed by the source text. A delta that is not a literal is
|
|
362
|
+
`registry-not-static` and a file that exports no delta is `change-not-found`:
|
|
363
|
+
both are codes `archive` already emits, so no consumer learns new vocabulary.
|
|
364
|
+
`--eval` is the same named way back it is everywhere else. The exposure this
|
|
365
|
+
closes is worse than the registry's, not better — `changes/` holds a proposal
|
|
366
|
+
still under review, and a reporting command is the last thing that should
|
|
367
|
+
execute it.
|
|
368
|
+
The design document carries it in the four places that were describing the old
|
|
369
|
+
set rather than only in §9: §2 and §5.1 name the reading commands, §7 states
|
|
370
|
+
for the first time that a delta is a literal on the same terms as a registry —
|
|
371
|
+
an authoring rule that existed the moment `status` read one — and §8 says which
|
|
372
|
+
of its own four checks the projection covers, and which one only the suite can
|
|
373
|
+
answer. §11's "Attest's own 29 requirements" was the kind of live count that
|
|
374
|
+
goes stale in silence, and is now phrased so it cannot. The README carried the
|
|
375
|
+
same three-command list in two places and an "all three commands" that had
|
|
376
|
+
become four, both now corrected, and it states the delta rule where it states
|
|
377
|
+
the registry one.
|
|
378
|
+
*Additive only:* a new `command` value and the new optional `progress` /
|
|
379
|
+
`added` fields, so `schemaVersion` stays `1`. No existing command's output
|
|
380
|
+
changes. The skill `attest init` writes gains the command, which re-running
|
|
381
|
+
`init` adopts.
|
|
382
|
+
One measurement moved with this change, recorded because it is evidence about a
|
|
383
|
+
*rejected* rule: `divergent-param` collected a **sixth** member on `code`
|
|
384
|
+
(ATX-33 joining ATX-18, ATX-21, ATX-25, ATX-26 and ATX-31), still with zero
|
|
385
|
+
true positives, and `compound-requirement`'s reach reached parity with its
|
|
386
|
+
blind spot for the first time (5 correct, 5 missed) — not because the rule
|
|
387
|
+
improved, since `missed` lost no member, but because ATX-33 was written with
|
|
388
|
+
both of its obligations declared. A rule whose score rises when the corpus is
|
|
389
|
+
written more carefully is measuring the authors, which is the objection to
|
|
390
|
+
shipping it, now stated by the measurement rather than in prose.
|
|
391
|
+
|
|
392
|
+
- **The archive gate certified that a change's tests pass, never that they ever
|
|
393
|
+
discriminated.** §6 describes three anti-drift mechanisms; mechanism 2 —
|
|
394
|
+
red/green expectation tracking — was written as shipped behaviour and had
|
|
395
|
+
never been built (0.2.0 corrected the document and named the gap). The gate
|
|
396
|
+
required green at archive time and nothing else, so a scenario that asserts
|
|
397
|
+
nothing has been green since the commit that added it and cleared the gate
|
|
398
|
+
exactly like one that drove an implementation. It ships now, and the anti-drift
|
|
399
|
+
budget is three mechanisms rather than two.
|
|
400
|
+
What made it buildable is that **the observation already existed and was being
|
|
401
|
+
thrown away**. The workflow has stage 1 run `attest archive <name>` and *expect*
|
|
402
|
+
`tests-red` — the skill `attest init` writes says so, and then says "nothing in
|
|
403
|
+
the engine will catch that later. This paragraph is the mechanism." A paragraph
|
|
404
|
+
asking a reader to be diligent is the thing this framework exists to replace,
|
|
405
|
+
and the engine had seen every one of those reds. `archive` now records how each
|
|
406
|
+
of a change's scenarios ended on its first run, into
|
|
407
|
+
`changes/<name>/first-run.json`, and blocks with a `never-red` ERROR on any
|
|
408
|
+
requirement the delta ADDs whose scenarios have no recorded failing run.
|
|
409
|
+
**A committed generated file, which this project otherwise treats as a
|
|
410
|
+
liability** — the argument against `init --check`, the reason `render` takes no
|
|
411
|
+
formatting options. The difference is lifetime: `changes/<name>/` is temporary
|
|
412
|
+
by construction and goes away when the change is archived, so this file has
|
|
413
|
+
exactly the change's lifetime and never becomes a permanent asset that can
|
|
414
|
+
drift. It is committed rather than kept in a scratch directory because the gate
|
|
415
|
+
has to reach the same verdict on a CI runner's fresh checkout as on the
|
|
416
|
+
author's machine — and because it is evidence, which belongs in the diff a
|
|
417
|
+
reviewer reads.
|
|
418
|
+
**The record is monotonic toward failure, and both halves of that are
|
|
419
|
+
load-bearing.** A recorded failure is permanent: by the time a change is
|
|
420
|
+
archivable everything is green, so a record that kept the latest outcome would
|
|
421
|
+
say "green" about a scenario that spent the whole change red. A recorded *pass*
|
|
422
|
+
is deliberately not permanent — plain first-write-wins was the first design
|
|
423
|
+
here, and it makes the defect this mechanism exists to catch unrecoverable,
|
|
424
|
+
since the fix for a scenario that asserts nothing is to give it a real
|
|
425
|
+
assertion and watch it fail, an observation that could then never be recorded.
|
|
426
|
+
The only way out would have been deleting the evidence by hand, which would
|
|
427
|
+
equally delete a genuine red. A later failure replacing an earlier pass cannot
|
|
428
|
+
be abused, because producing one requires the scenario to actually fail.
|
|
429
|
+
**Scoped to ADDED requirements only, and §11 now records what that leaves.** A
|
|
430
|
+
requirement written straight into the base registry describes behaviour that
|
|
431
|
+
already exists — the brownfield adoption, and most of this repo — so there was
|
|
432
|
+
never a moment when its scenario should have failed; blocking those would make
|
|
433
|
+
the mechanism fire hardest on the honest case. The guarantee is therefore a
|
|
434
|
+
property of the `changes/` workflow, not of the registry as a whole.
|
|
435
|
+
*Behaviour change, for a project mid-change:* a change whose scenarios were
|
|
436
|
+
never observed failing flips from archivable to blocked, which is the point,
|
|
437
|
+
since nothing had shown its tests could fail. The remedy is one gate run before
|
|
438
|
+
implementing. `never-red` is a new `code` on the existing `Issue` shape, and
|
|
439
|
+
`--json` gains no field, so `schemaVersion` stays `1`. Two self-requirements
|
|
440
|
+
(ATX-30, ATX-31) rather than one: a recorder that writes a perfect history and
|
|
441
|
+
never blocks satisfies the first completely while leaving the gate exactly as
|
|
442
|
+
weak as it was, and a gate can equally block on an absent record without
|
|
443
|
+
recording anything.
|
|
444
|
+
The rejected `divergent-param` rule gained a **fifth** member on `code` and,
|
|
445
|
+
for the first time, a collision on a **second** param name (`file`, ATX-27's
|
|
446
|
+
skill path against ATX-30's record path) — see "Considered and rejected", where
|
|
447
|
+
that second name is what closes off "pick a less generic name" as a workaround.
|
|
448
|
+
|
|
449
|
+
- **`attest init` — the workflow half of the framework had no entry point.**
|
|
450
|
+
Everything the engine does has been reachable from the CLI since 0.1.0. The
|
|
451
|
+
workflow — agree on intent, write the delta, drive the scenarios red, implement
|
|
452
|
+
to green — was a document (`docs/*/using-attest.prompt.md`) the user had to
|
|
453
|
+
find and paste into their agent themselves. Everything downstream of that paste
|
|
454
|
+
worked; nothing led to it. `attest init` writes one file,
|
|
455
|
+
`.claude/skills/attest/SKILL.md`, carrying that workflow as a **skill** — its
|
|
456
|
+
description sits in the agent's context, so the agent loads it when the work
|
|
457
|
+
starts rather than waiting to be handed something. That is the difference that
|
|
458
|
+
closes the gap: a file the user must remember to invoke would only replace
|
|
459
|
+
"find the document" with "know what to type". `init` is idempotent — re-run it
|
|
460
|
+
after upgrading, which is the entire update mechanism.
|
|
461
|
+
|
|
462
|
+
**The freshness contract, which the queued entry said had to be designed rather
|
|
463
|
+
than assumed: there isn't one, and that is the design.** A committed
|
|
464
|
+
instruction file is a function of the installed version, so it does go stale on
|
|
465
|
+
upgrade — but staleness only *costs* anything if the tool also ships a gate to
|
|
466
|
+
turn red about it, and `init --check` is rejected on its own terms under
|
|
467
|
+
"Considered and rejected", together with the exposure that leaves and the
|
|
468
|
+
backstop that keeps it small. What belongs here is what the absence of a gate
|
|
469
|
+
bought: with none to serve there is no reason to keep the prose out of the file
|
|
470
|
+
either, so it lives in the skill, where an agent reads it with no extra call,
|
|
471
|
+
and a skill body is loaded on demand — a long document costs context only when
|
|
472
|
+
it is actually needed.
|
|
473
|
+
|
|
474
|
+
**`init` deliberately scaffolds nothing else:** no `requirements/*.reqs.ts`, no
|
|
475
|
+
`package.json` scripts, no `changes/` skeleton. A generated registry can only
|
|
476
|
+
be empty — which since 0.2.0 is an `empty-spec` ERROR handed to the user on day
|
|
477
|
+
one — or invented, and invented intent is worse than none in the one layer
|
|
478
|
+
whose value is that it is real. What belongs there is the agent's judgement,
|
|
479
|
+
with the project in front of it, which is what the skill describes.
|
|
480
|
+
**This is the one thing Attest does not dogfood, and saying otherwise would
|
|
481
|
+
have been the wrong kind of reassurance.** The skill exists for a project
|
|
482
|
+
*adopting* Attest — an agent meeting a registry it did not write, needing to be
|
|
483
|
+
told what a delta is and why its scenarios must be red. This repo is the
|
|
484
|
+
framework, not an adopter: its requirements describe behaviour that already
|
|
485
|
+
exists and go straight into the base registry, so it does not run the
|
|
486
|
+
`changes/` workflow the skill teaches, and a committed copy here would be a
|
|
487
|
+
file nothing reads. What guards the text instead is `tests/skill.spec.ts`,
|
|
488
|
+
which asserts the shape an agent depends on — that it parses as a skill, that
|
|
489
|
+
the description names `*.reqs.ts` so it loads at the right moment, and that the
|
|
490
|
+
body still teaches the codes a reader will actually hit. That is a weaker
|
|
491
|
+
guarantee than dogfooding and is named as such: nothing here proves the
|
|
492
|
+
workflow is followable, only that the file describing it is well-formed.
|
|
493
|
+
**One correction to the queued entry:** it predicted a `files` allowlist change
|
|
494
|
+
and therefore a `pnpm run test:consumer` obligation. There is none — the text
|
|
495
|
+
is a TypeScript module compiled into `dist/`, not a template file, so the
|
|
496
|
+
published surface is unchanged. `--json` gains `init` as a `command` value and
|
|
497
|
+
widens the existing `outFile` field to it; both are additive, so
|
|
498
|
+
`schemaVersion` stays `1`.
|
|
499
|
+
|
|
500
|
+
- **`scenario` accepts a timeout.** `scenario(name, fn, timeoutMs)`, passed
|
|
501
|
+
straight through to Vitest's `it`. `verify` runs the suite isolated (design
|
|
502
|
+
§5.4) — no `vitest.config.ts` unless `--vitest-config` names one — so every
|
|
503
|
+
scenario got Vitest's 5-second default and a scenario that drives a
|
|
504
|
+
subprocess, a container or a build had no way to say otherwise. Attest's own
|
|
505
|
+
suite is the demonstration: two `self/` scenarios each start four child Vitest
|
|
506
|
+
runs, and they fit inside 5 seconds on Linux and not on Windows. The
|
|
507
|
+
alternatives are both worse — raising the default for everyone hides a
|
|
508
|
+
genuinely hung test, and a config file sets one number for what is a property
|
|
509
|
+
of one scenario — the same shape of objection that rules a CLI flag out as
|
|
510
|
+
`divergent-param`'s escape hatch under "Considered and rejected", where a
|
|
511
|
+
per-invocation switch is asked to carry a permanent property of the source.
|
|
512
|
+
Additive: existing two-argument calls are unchanged. Deliberately **no
|
|
513
|
+
self-requirement**: the contract here is Vitest's
|
|
514
|
+
own signature and this is one argument forwarded to it, and the only scenario
|
|
515
|
+
that would truly discriminate — one that outlives the default — would spend
|
|
516
|
+
five seconds of every run proving a number Attest does not own. Its absence is
|
|
517
|
+
observable anyway, as the two `self/` scenarios above.
|
|
518
|
+
|
|
519
|
+
### Changed
|
|
520
|
+
|
|
521
|
+
- **BREAKING: `cover` reported a registry it could not read as a registry with
|
|
522
|
+
nothing in it — and nothing is fully covered.** It was the one reader that
|
|
523
|
+
discarded the issues from loading the registry (`runCheck` and `runRender`
|
|
524
|
+
both keep them), so a `registry-not-static` file, a renamed `requirements/`
|
|
525
|
+
folder, or a moved root left an empty registry behind, and an empty registry
|
|
526
|
+
has no uncovered requirement in it. The command answered `✓`, `ok: true`,
|
|
527
|
+
`0 requirements, 0 uncovered`, exit `0` — on precisely the repo whose intent
|
|
528
|
+
layer had stopped being readable, and in the command a pipeline is most likely
|
|
529
|
+
to run first, because it neither runs the suite nor executes project code.
|
|
530
|
+
`attest check` on the same project reported the ERROR correctly, which is what
|
|
531
|
+
made the gap survivable and also what made it invisible: the two commands
|
|
532
|
+
disagreed about whether the project was in a reportable state at all.
|
|
533
|
+
`cover` now carries the load issues, so the verdict follows `hasError` like
|
|
534
|
+
every other command, and it emits **no rows at all** when the registry failed
|
|
535
|
+
to load. The second half is deliberate and is the reason this is one change
|
|
536
|
+
rather than a one-line fix: reporting the error while still printing a table
|
|
537
|
+
built from whichever registry files did parse would let a partial read pass
|
|
538
|
+
for the whole. `render` already refuses to emit a document under the same
|
|
539
|
+
condition, for the same reason — a report computed from a half-loaded registry
|
|
540
|
+
is a lie, and a lie shaped like a pass is the worst form it can take.
|
|
541
|
+
*Breaking:* a project whose registry does not read flips from exit `0` to exit
|
|
542
|
+
`1` — which is the point, since it was never coverable. The human output for
|
|
543
|
+
that project changes from `(the registry contains no requirements)` to the
|
|
544
|
+
load error itself; every project whose registry does read is byte-identical.
|
|
545
|
+
`--json` gains no field (`issues` and `summary` already exist), so
|
|
546
|
+
`schemaVersion` stays `1`.
|
|
547
|
+
|
|
548
|
+
### Removed
|
|
549
|
+
|
|
550
|
+
- **`docs/en/using-attest.prompt.md` and `docs/zh/using-attest.prompt.md`.** The
|
|
551
|
+
workflow half of these is what `attest init` now writes into the project; the
|
|
552
|
+
adoption half is the README's Getting started, which npm publishes regardless
|
|
553
|
+
of the `files` allowlist and so reaches a reader who has installed nothing.
|
|
554
|
+
Deleted rather than left as a page pointing at either, because a stub is still
|
|
555
|
+
a file two languages have to keep in sync, and the delivery problem was never
|
|
556
|
+
the prompt's wording — it was that the prompt lived in the docs directory of a
|
|
557
|
+
repository the reader may never have cloned. Nothing in the package linked to
|
|
558
|
+
them. Documentation only: no package surface, and no `files` change (they were
|
|
559
|
+
never in the tarball).
|
|
560
|
+
|
|
561
|
+
### Fixed
|
|
562
|
+
|
|
563
|
+
- **A spec file below the project root never ran on Windows.** `verify` and
|
|
564
|
+
`archive` derive the child run's `include` globs from the plan's file paths
|
|
565
|
+
(0.2.0), and those paths came from `path.relative` — so on Windows they were
|
|
566
|
+
spelled `deep\er\x.spec.ts`. A backslash is an **escape character** in glob
|
|
567
|
+
syntax, so the pattern matched nothing and the file was dropped from the run.
|
|
568
|
+
The scenarios in it then surfaced as `declared-not-run`, which is at least
|
|
569
|
+
loud rather than a false green, but the practical effect was that only a flat
|
|
570
|
+
layout worked at all — and a flat layout is not what a real project has.
|
|
571
|
+
The reason it survived this long is a sampling accident: every fixture in this
|
|
572
|
+
repo keeps its specs at the project root, and so does `self/`, so the relative
|
|
573
|
+
paths had no separator in them. The single exception —
|
|
574
|
+
`fixtures/e2e-archive/changes/add-totp/specs/totp.spec.ts` — was the one
|
|
575
|
+
failing test, which is why this read as "`archive` is broken on Windows"
|
|
576
|
+
rather than as what it was.
|
|
577
|
+
Every path derived from the project root now has one spelling (`/`) on every
|
|
578
|
+
platform, normalised once where it is produced rather than where it is
|
|
579
|
+
consumed, because the same relative path is also what a report carries: a
|
|
580
|
+
diagnostic that differs by operating system makes two CI runs impossible to
|
|
581
|
+
diff. The conversion is conditional on the host separator, never
|
|
582
|
+
unconditional — a backslash is a legal filename character on POSIX, and
|
|
583
|
+
rewriting it there would rename a file that exists.
|
|
584
|
+
*For anyone parsing `--json` on Windows:* `issues[].file` changes from
|
|
585
|
+
`deep\er\x.spec.ts` to `deep/er/x.spec.ts`. No exit code changes except the
|
|
586
|
+
ones that were wrong; nothing previously valid is rejected; no field is
|
|
587
|
+
removed. `schemaVersion` stays `1`. New fixture `fixtures/nested-spec/`, whose
|
|
588
|
+
only spec is two directories down, is the end-to-end guard (ATX-28).
|
|
589
|
+
- **`render --check` was permanently red on a Windows checkout.** `render`
|
|
590
|
+
writes `\n`; Git hands the file back as `\r\n` wherever `core.autocrlf` is
|
|
591
|
+
enabled, which is the installer default on Windows. The freshness check
|
|
592
|
+
compared bytes, so a fresh clone reported `stale-spec-doc` against a registry
|
|
593
|
+
nobody had touched, and the fix hint named a command that regenerates a file
|
|
594
|
+
which is already correct. Any adopter committing a rendering hit this the
|
|
595
|
+
first time a second machine — or a CI runner — checked the repo out.
|
|
596
|
+
The check now compares the *document*: a difference of line endings alone is
|
|
597
|
+
not staleness. This is the same standard 0.1.6 set when it gave `render` no
|
|
598
|
+
formatting options at all — a gate that can be wrong about staleness is worse
|
|
599
|
+
than no gate — and it costs nothing, because no carriage return in that file
|
|
600
|
+
ever came from the registry. Content drift is still reported, CRLF or not
|
|
601
|
+
(ATX-29). This repo also gained a `.gitattributes` pinning the working tree to
|
|
602
|
+
LF, so a regeneration is not a whole-file diff on one platform and no diff on
|
|
603
|
+
the other.
|
|
604
|
+
- **Three of this repo's own tests could not pass on Windows, and one could not
|
|
605
|
+
fail.** `tests/locate.spec.ts` took basenames with `split('/')`, which returns
|
|
606
|
+
the whole path when the separator is a backslash.
|
|
607
|
+
`tests/docs-consistency.spec.ts` filtered source files with
|
|
608
|
+
`path.startsWith(`${root}src/`)`, splicing a forward slash onto a native root
|
|
609
|
+
— it matched nothing, so the check that every `§n` citation resolves was
|
|
610
|
+
passing on an empty set. `self/registry.spec.ts` compared a native path
|
|
611
|
+
against `ts.Diagnostic.file.fileName`, which TypeScript normalises to forward
|
|
612
|
+
slashes, so the type-level assertion measured zero diagnostics. Same class of
|
|
613
|
+
bug as the two above, in the tests rather than the engine.
|
|
614
|
+
`tests/loader.spec.ts` was separately fragile on every platform: it counted
|
|
615
|
+
`attest-loader-*` directories in the OS temp dir, making its verdict a
|
|
616
|
+
function of every other loader alive at that moment. It now asserts against
|
|
617
|
+
the loader's own `scratchDir`, and a third case pins that concurrent loaders
|
|
618
|
+
do not share one.
|
|
619
|
+
|
|
620
|
+
- **`cover --json` could answer `ok: false` with an empty `issues` array.** Three
|
|
621
|
+
places state one rule for machine consumers — `src/cli/json.ts`'s header, the
|
|
622
|
+
README, and 0.1.2's entry below — and all three say the same thing: branch on
|
|
623
|
+
`ok` and `issues[].code`, never on `message`. `cover` was the one command that
|
|
624
|
+
broke it. Its verdict was computed from the coverage rows
|
|
625
|
+
(`covered === rows.length`) while its envelope carried no issue at all, so a
|
|
626
|
+
consumer that followed the documented rule got a failing verdict, a summary of
|
|
627
|
+
three zeroes, and nothing saying what to fix; recovering the reason meant
|
|
628
|
+
knowing to read `coverage[]` instead — the out-of-band knowledge the contract
|
|
629
|
+
exists to make unnecessary. An uncovered requirement now reaches the report
|
|
630
|
+
twice, as a row and as an `uncovered-requirement` ERROR.
|
|
631
|
+
**The issue comes from the function `check` already uses.** `uncoveredIssues`
|
|
632
|
+
is extracted from `validateStructure` and called by both, so the two commands
|
|
633
|
+
cannot state one fact in two shapes — the same reason `declaredNotRunIssues`
|
|
634
|
+
was extracted in 0.1.7, and the same argument: agreement between two verdicts
|
|
635
|
+
should be structural, not clerical. `cover`'s `ok` now derives from the shared
|
|
636
|
+
`hasError` predicate like every other command, rather than from a second rule
|
|
637
|
+
that happened to agree with it.
|
|
638
|
+
*Behaviour change, for anyone already parsing `cover --json`:* a report on a
|
|
639
|
+
registry with uncovered requirements gains `issues[]` entries and a non-zero
|
|
640
|
+
`summary.error` where both were empty. `ok` and the exit code are unchanged for
|
|
641
|
+
every input — an uncovered requirement is the only issue this command produces,
|
|
642
|
+
so the new predicate returns the boolean the old one did. The human output is
|
|
643
|
+
also unchanged: the coverage table already renders each uncovered requirement
|
|
644
|
+
as a red `✗` row, and printing the same fact again underneath it would be noise
|
|
645
|
+
for the one reader who can see it. No shape change either — `issues` and
|
|
646
|
+
`summary` are existing envelope fields — so `schemaVersion` stays `1`.
|
|
647
|
+
The rejected `divergent-param` rule grew a **fourth** member on this change
|
|
648
|
+
(`code`, shared at that point by ATX-17, ATX-18, ATX-21 and ATX-25), which is
|
|
649
|
+
the measurement its entry under "Considered and rejected" predicted: a generic
|
|
650
|
+
param name collides more often as the registry grows, so the rule's
|
|
651
|
+
false-positive rate rises with adoption. Still zero true positives. ATX-26,
|
|
652
|
+
later in this same release, then pushed ATX-17 *out* of the set by naming the
|
|
653
|
+
same diagnostic — the sharper version of the same point, recorded in full
|
|
654
|
+
under "Considered and rejected" rather than restated here.
|
|
17
655
|
|
|
18
656
|
## [0.2.2] - 2026-07-26
|
|
19
657
|
|
|
@@ -361,66 +999,6 @@ that identifies itself as attest output must carry the current `version` and
|
|
|
361
999
|
`schemaVersion`. Neither can catch a section that describes something unbuilt —
|
|
362
1000
|
that is what the inline marker is for.
|
|
363
1001
|
|
|
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
1002
|
## [0.1.7] - 2026-07-26
|
|
425
1003
|
|
|
426
1004
|
A change name, a prototype key and a collation quirk each got to decide a
|
|
@@ -741,7 +1319,243 @@ Initial release.
|
|
|
741
1319
|
(MIT), whose four-stage engine and diff-first change model Attest's
|
|
742
1320
|
architecture is adapted from (re-implemented from scratch, no source copied).
|
|
743
1321
|
|
|
744
|
-
|
|
1322
|
+
## Considered and rejected
|
|
1323
|
+
|
|
1324
|
+
Decisions **not** to build something, kept where they can be found before the
|
|
1325
|
+
same candidate is proposed again. Nothing here shipped, so nothing here belongs
|
|
1326
|
+
to a release — this section is deliberately outside the version history and does
|
|
1327
|
+
not move when one is cut. It lives at the **foot** of the file for that reason:
|
|
1328
|
+
it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
|
|
1329
|
+
sinking one version deeper each time a release was cut above it, and a rejection
|
|
1330
|
+
filed under a version reads as belonging to it.
|
|
1331
|
+
|
|
1332
|
+
- **A requirement's id prefix must match its registry file's name.** The other
|
|
1333
|
+
half of the rule that shipped as `duplicate-prefix` (see `[Unreleased]`), and
|
|
1334
|
+
the half that does not pay for itself. What killed it is that the corpus
|
|
1335
|
+
already conforms: every fixture registry matches (`auth.reqs.ts` → `AUTH-`,
|
|
1336
|
+
`core.reqs.ts` → `CORE-`), so the rule would detect nothing anywhere it was
|
|
1337
|
+
measured except one file — `self/requirements/attest.reqs.ts`, which holds
|
|
1338
|
+
`ATX-*`. Its first act would therefore be to rename this repo's own registry
|
|
1339
|
+
and every `self/*.spec.ts` that imports it, and its first act in an adopter's
|
|
1340
|
+
repository would be the same thing to theirs. That is rejecting input valid
|
|
1341
|
+
today, a minor bump under this file's Versioning rule, in exchange for making
|
|
1342
|
+
the prefix *guessable from the path* — which matters only to a reader already
|
|
1343
|
+
looking at the file, and who could have read the ids in it. The collision rule
|
|
1344
|
+
delivers what the gap is actually about, which is two owners of one space, and
|
|
1345
|
+
it delivers it without a rename because it fires on a relationship between
|
|
1346
|
+
files rather than on a spelling. Reconsider only with evidence that a reader or
|
|
1347
|
+
a tool needs the prefix before opening the file — a naming *convention* stays
|
|
1348
|
+
the right shape for this, and the convention is what `init` and the docs
|
|
1349
|
+
already teach.
|
|
1350
|
+
What is not a reason to revisit it: the objection that stalled the collision
|
|
1351
|
+
rule while both were queued together — that it guards a convention nothing had
|
|
1352
|
+
yet violated — was answered by shipping it, since a rule that costs no
|
|
1353
|
+
migration and no concept is worth having before the first collision rather
|
|
1354
|
+
than after it. That argument does not transfer here, because this rule's cost
|
|
1355
|
+
is a migration by construction.
|
|
1356
|
+
|
|
1357
|
+
- **Two gaps in the intent layer: nothing resists a bloated requirement, and
|
|
1358
|
+
nothing resists a duplicated one.** The gaps themselves are described in design
|
|
1359
|
+
§11, which is where a permanent property of the design belongs. What is kept
|
|
1360
|
+
here is the list of mechanisms tried and what killed each, so the same three
|
|
1361
|
+
are not proposed again without new evidence. All three were scored against
|
|
1362
|
+
every registry this repo ships *before* any was written into the engine — the
|
|
1363
|
+
order `divergent-param` was originally designed in the wrong way round. The
|
|
1364
|
+
measurement is runnable in `tests/intent-rule-candidates.spec.ts` and fails
|
|
1365
|
+
when a requirement is added without a hand-labelled obligation count, because
|
|
1366
|
+
the reach figure once quoted here went stale exactly that way. The guard makes
|
|
1367
|
+
the *suite* re-count; it cannot make this file re-read, so every figure below
|
|
1368
|
+
is a quotation of that snapshot and is only ever as fresh as the last edit
|
|
1369
|
+
here — read the snapshot when the number is what matters.
|
|
1370
|
+
*`compound-requirement` (WARNING) — rejected.* More than one RFC-2119 keyword
|
|
1371
|
+
in one `statement` is objectively more than one obligation. Measured (figures
|
|
1372
|
+
re-read from the suite, which is the only place they are not prose): of the 8
|
|
1373
|
+
requirements in the corpus carrying more than one, it flags 3 (`ATX-10`,
|
|
1374
|
+
`ATX-21`, `ATX-26`) and misses `ATX-5`, `ATX-9`, `ATX-13`, `ATX-14`, `ATX-17`
|
|
1375
|
+
— every one that packs clauses under a single keyword, which is the form the
|
|
1376
|
+
coverage incentive actually rewards. A wider variant (keyword plus a
|
|
1377
|
+
coordinating `and`/`while`/`;`) reaches 7 of 8, at 3 wrong hits (`ATX-6`,
|
|
1378
|
+
`ATX-22`, `ATX-23`) rather than the 1 recorded when it was first scored — so
|
|
1379
|
+
reach is not the deciding argument, and the precision it trades away grows
|
|
1380
|
+
with the registry.
|
|
1381
|
+
This is: both variants are silenced by deleting the word that triggered them,
|
|
1382
|
+
and neither can tell that deletion from a real split. `SHALL do A and MUST do
|
|
1383
|
+
B` clears the warning as `SHALL do A and do B` — same two obligations, one
|
|
1384
|
+
fewer normative keyword. A rule whose cheapest fix degrades the artifact it
|
|
1385
|
+
protects does not ship.
|
|
1386
|
+
*`divergent-param` (WARNING) — rejected.* One param name declared with
|
|
1387
|
+
different values in two requirements sharing an id prefix. Measured: it fires
|
|
1388
|
+
twice on this repo and both firings are wrong — `idleTimeoutMin` (`15` in
|
|
1389
|
+
`ATX-3`, `30` in `ATX-10`), unrelated illustrative fixtures, and `code`
|
|
1390
|
+
(`registry-not-static` in `ATX-17`, `empty-spec` in `ATX-18`,
|
|
1391
|
+
`rationale-placeholder` in `ATX-21`), diagnostics that could not possibly
|
|
1392
|
+
agree. The second arrived on its own when `ATX-18` was added and grew a third
|
|
1393
|
+
member on its own again when `ATX-21` was, then `ATX-25`, then `ATX-26`, which
|
|
1394
|
+
is the more damning half: a generic param name collides more often as a
|
|
1395
|
+
registry grows, so the false-positive rate rises with adoption — the opposite
|
|
1396
|
+
of what a shippable rule does. `ATX-26` added the sharper version of the same
|
|
1397
|
+
point: it names the *same* diagnostic as `ATX-17`, so the two now agree by
|
|
1398
|
+
coincidence and `ATX-17` drops out of the set the rule reports — a rule that
|
|
1399
|
+
reads agreement as safety falls silent on a pair it had been flagging, for a
|
|
1400
|
+
reason that has nothing to do with either requirement.
|
|
1401
|
+
**A second param name now collides, and that is what closes the last defence
|
|
1402
|
+
of the rule.** `file` names a path Attest writes — the agent skill in `ATX-27`,
|
|
1403
|
+
a change's first-run record in `ATX-30` — two unrelated constants, a third
|
|
1404
|
+
wrong firing. Until it appeared, every false positive had been on the single
|
|
1405
|
+
name `code`, which left "pick a less generic param name" open as a repo-side
|
|
1406
|
+
workaround and therefore left the rule arguably salvageable. It is not the
|
|
1407
|
+
word: two requirements owning unrelated constants of the same *kind* is the
|
|
1408
|
+
structure, and any registry large enough to be worth linting has it.
|
|
1409
|
+
Still zero true positives, now across a 34-entry corpus (the `corpus` figure in
|
|
1410
|
+
the suite's snapshot, which counts a fixture id once per registry file that
|
|
1411
|
+
declares it).
|
|
1412
|
+
The escape hatch it was blocked on has since been designed
|
|
1413
|
+
and does not rescue it: with no config file the only workable shape is an
|
|
1414
|
+
additive `independentParams?: string[]` on the requirement (a CLI flag is
|
|
1415
|
+
per-invocation for what is a permanent property of two requirements; changing
|
|
1416
|
+
the `params` shape rejects every existing registry). Viable, but it is
|
|
1417
|
+
permanent schema surface for a rule with no demonstrated true positive, and in
|
|
1418
|
+
practice it would be written *after* the warning fires — a one-line silencer.
|
|
1419
|
+
*Countable obligations (breaking) — rejected.* An `obligations: string[]` per
|
|
1420
|
+
requirement, with coverage counted per obligation, removing the lumping
|
|
1421
|
+
incentive at the source instead of detecting it afterwards. Rejected because
|
|
1422
|
+
the **id is already that unit**: splitting into two ids costs two scenarios and
|
|
1423
|
+
buys the same incentive with no new concept (`ATX-11`/`ATX-12` are exactly that
|
|
1424
|
+
split). It would touch the parser, coverage, `render`, `SPEC.md`, delta apply
|
|
1425
|
+
and the `--json` schema, and it relocates the incentive rather than removing
|
|
1426
|
+
it — nothing can force the array to be complete.
|
|
1427
|
+
*Not considered:* similarity matching between statements (token overlap,
|
|
1428
|
+
embeddings). That reintroduces the fuzzy comparison design §0 exists to remove;
|
|
1429
|
+
real semantic duplication needs judgement and belongs to human review at the
|
|
1430
|
+
propose stage.
|
|
1431
|
+
|
|
1432
|
+
- **A freshness gate on the file `attest init` writes (`init --check`).**
|
|
1433
|
+
`render --check` is the obvious precedent and is the opposite case. A rendering
|
|
1434
|
+
is a function of the *user's registry*, so its staleness means the user moved
|
|
1435
|
+
intent without regenerating — a real signal, with the fix in their hands. The
|
|
1436
|
+
skill is a function of the *installed version*, so a gate on it turns red on a
|
|
1437
|
+
change the user did not make, and is cleared by a commit containing no
|
|
1438
|
+
decision. That is a chore wearing a diagnostic's clothes, and a gate that
|
|
1439
|
+
cannot be about anything the reader did is worse than no gate — the same
|
|
1440
|
+
standard 0.1.6 set when it gave `render` no formatting options at all.
|
|
1441
|
+
An earlier draft of `attest init` spent a second command, this gate and a
|
|
1442
|
+
second self-requirement on it. What that would have bought is immunity to one
|
|
1443
|
+
honest and small exposure: an old file describes an old workflow to an agent
|
|
1444
|
+
that trusts it. The backstop is that every mistake it can cause is already a
|
|
1445
|
+
diagnostic with a fix hint — a registry written the old way is
|
|
1446
|
+
`registry-not-static` — so the agent corrects itself from the report, and
|
|
1447
|
+
`init` is idempotent, which makes re-running it the entire update mechanism.
|
|
1448
|
+
Recorded here rather than left in the 0.3.0 entry that shipped `init`, because
|
|
1449
|
+
the queue above depends on it: a gate is the first thing proposed each time a
|
|
1450
|
+
new agent target is considered, and the answer must not need re-deciding per
|
|
1451
|
+
target.
|
|
1452
|
+
|
|
1453
|
+
- **A marked block in the adopter's `AGENTS.md` / `CLAUDE.md`.** The cross-tool
|
|
1454
|
+
convention Codex, Cursor and Copilot read, and the reason it looks like the
|
|
1455
|
+
obvious next target after the skill is that it reaches the most agents per unit
|
|
1456
|
+
of work. Rejected on three counts, none of which is about effort.
|
|
1457
|
+
**It is the only file `init` would write that Attest does not own.**
|
|
1458
|
+
`.claude/skills/attest/SKILL.md` exists because Attest exists; `AGENTS.md`
|
|
1459
|
+
exists because the *project* does, and what is in it is prose its maintainer
|
|
1460
|
+
wrote and curated. Writing a block into it means an idempotent
|
|
1461
|
+
read-modify-write on someone else's document — find the markers, preserve
|
|
1462
|
+
everything outside them, and then handle the markers being absent, duplicated,
|
|
1463
|
+
hand-edited, moved, the file missing, the file being `CLAUDE.md` instead, or
|
|
1464
|
+
both existing. That is a merge tool, and each of its failure modes is
|
|
1465
|
+
destructive on a file the user cannot regenerate.
|
|
1466
|
+
**It would remove the property that makes `init` safe to automate.**
|
|
1467
|
+
`skillContent()` takes no argument — the content is a pure function of nothing
|
|
1468
|
+
at all — so `init` is one atomic write and its idempotence holds *by
|
|
1469
|
+
construction*, which is what lets "re-run it after upgrading" be the whole
|
|
1470
|
+
update mechanism and be safe in a script. A marked block makes the output a
|
|
1471
|
+
function of the file already there, demoting idempotence from a property of the
|
|
1472
|
+
design to a behaviour that needs test coverage, on the one path where being
|
|
1473
|
+
wrong destroys input.
|
|
1474
|
+
**It is a worse carrier for the same text.** An `AGENTS.md` block is
|
|
1475
|
+
unconditionally resident in the agent's context, where a skill body is loaded
|
|
1476
|
+
when its description matches — so every unrelated request in that repo pays for
|
|
1477
|
+
a workflow it will not use. It was never a port of the skill; it is a second,
|
|
1478
|
+
weaker capability wearing the same words.
|
|
1479
|
+
The interaction with the entry above is what settles it: a user-edited block
|
|
1480
|
+
cannot be replaced on upgrade without discarding the edit, so this shape needs
|
|
1481
|
+
either the freshness gate rejected above or a three-way merge. *What replaces
|
|
1482
|
+
it*, for an adopter whose agent reads `AGENTS.md`: one line they add
|
|
1483
|
+
themselves, pointing at the skill file. A pointer is cheap enough to be
|
|
1484
|
+
resident, it is their edit in their file, and it cannot go stale — the file it
|
|
1485
|
+
names is the one `init` keeps current.
|
|
1486
|
+
|
|
1487
|
+
- **A Codex target for `attest init`.** Evaluated when `init` grew `--target` and
|
|
1488
|
+
shipped for Claude Code, Cursor and Copilot; rejected because the tool offers
|
|
1489
|
+
nothing to write, not because of what writing it would cost. Codex's
|
|
1490
|
+
project-scoped instruction surface is `AGENTS.md`, which the entry above
|
|
1491
|
+
rejects permanently, and its custom prompts live in `~/.codex/prompts` — per
|
|
1492
|
+
machine, outside the project root `init` is pointed at, and not a thing a
|
|
1493
|
+
project can commit or a CI checkout can carry. That leaves no path that is both
|
|
1494
|
+
**Attest's own** and **read by the tool**, so the two rules a target has to
|
|
1495
|
+
satisfy are not merely unmet here, they are unmeetable with what exists: any
|
|
1496
|
+
Codex-shaped implementation would be either the rejected merge or a file
|
|
1497
|
+
nothing reads, and the second is worse, because it looks like coverage.
|
|
1498
|
+
Filed here rather than queued as a candidate, since nothing about the effort
|
|
1499
|
+
or the evidence is what is holding it — the pointer line above is the whole
|
|
1500
|
+
answer for a Codex user, and this reopens only if Codex grows a project-scoped
|
|
1501
|
+
path of its own.
|
|
1502
|
+
|
|
1503
|
+
- **A committed snapshot of the params, to give `check` detection power over a
|
|
1504
|
+
value that moves.** Design §11 records the measurement this answers: editing a
|
|
1505
|
+
param (`'main'` → `'trunk'`, one entry dropped from a nine-element list) leaves
|
|
1506
|
+
`attest check` at `✓ No issues`, because the single source makes a value
|
|
1507
|
+
impossible to *diverge*, which is not the claim that it is flagged when it
|
|
1508
|
+
*moves*. The candidate was the shape `render --check` already proves — a
|
|
1509
|
+
`params.lock` beside the registry, or `check --params-check` — and the
|
|
1510
|
+
mechanism would have worked.
|
|
1511
|
+
*What rejected it: the precedent is the objection.* §9 states when a freshness
|
|
1512
|
+
gate earns its place, and a rendering earns it because it is a function of the
|
|
1513
|
+
*user's registry*, so staleness means the user moved intent without
|
|
1514
|
+
regenerating — a real signal with the fix in their hands. A params snapshot is
|
|
1515
|
+
a function of the registry too, but the population it fires on is different:
|
|
1516
|
+
**every legitimate param edit turns it red, and the fix is a commit containing
|
|
1517
|
+
no decision.** That is precisely the chore §9 declined to ship for `init`, and
|
|
1518
|
+
it is worse here because of frequency — intent is rendered rarely, while params
|
|
1519
|
+
move whenever a value moves, which is the entire reason they are params. The
|
|
1520
|
+
gate would spend its whole life red about correct edits, and a gate that is
|
|
1521
|
+
usually wrong about staleness is the failure mode §9 names as worse than no
|
|
1522
|
+
gate.
|
|
1523
|
+
*What the gap keeps instead.* §6's mechanism 3 already exists to make this
|
|
1524
|
+
exact absence visible: `possible-drift` fires when a requirement owns params
|
|
1525
|
+
that no covering scenario reads, which is the only condition under which a
|
|
1526
|
+
moved param goes unnoticed. The mechanism that guards a param is a scenario
|
|
1527
|
+
reading it, not a file recording it. This reopens only on an adoption report
|
|
1528
|
+
where a param moved, no scenario caught it, and `possible-drift` was **not**
|
|
1529
|
+
already printing — because if it was printing, what failed was reading the
|
|
1530
|
+
report, and a second gate does not fix that.
|
|
1531
|
+
|
|
1532
|
+
- **Grouping `render`'s output by prefix, so a reviewer sees contradicting
|
|
1533
|
+
requirements together.** The *detection* half of this is already rejected above
|
|
1534
|
+
(every automatic candidate either saw almost nothing or was a similarity
|
|
1535
|
+
matcher, which is what §0 exists to remove), leaving human review at propose
|
|
1536
|
+
(§7) as the answer — and the idea was to help that review by putting
|
|
1537
|
+
requirements about one subject side by side in the one document a human reads
|
|
1538
|
+
end to end.
|
|
1539
|
+
*What rejected it: the ordering already ships, and the adjacency it buys is not
|
|
1540
|
+
where the gap is.* `compareIds` (`render.ts`) has ordered the document by
|
|
1541
|
+
prefix first and number second since `render` shipped, so same-prefix
|
|
1542
|
+
requirements are **already** adjacent; the candidate was never an ordering
|
|
1543
|
+
change, only a heading over an ordering that already holds. And §11's own
|
|
1544
|
+
example of the gap is `AUTH-3` expiring a session against `SESS-7` saying
|
|
1545
|
+
sessions never expire — a contradiction **across** prefixes, which prefix
|
|
1546
|
+
grouping pushes further apart rather than closer. What remains is a reader aid
|
|
1547
|
+
for same-prefix contradictions, which are the pairs already adjacent and
|
|
1548
|
+
therefore the ones review is least likely to miss, bought at the price of
|
|
1549
|
+
changing the bytes of every committed rendering and turning `render --check`
|
|
1550
|
+
red across every adopting repo on upgrade — for a change the user did not make.
|
|
1551
|
+
*The gap itself stays open* and stays where it belongs, in design §11: nothing
|
|
1552
|
+
resists a duplicated or contradictory requirement, and human review at propose
|
|
1553
|
+
is still the whole answer. What is rejected is grouping as a way to assist it,
|
|
1554
|
+
and this reopens only on a contradiction that grouping would have caught.
|
|
1555
|
+
|
|
1556
|
+
[Unreleased]: https://gitlab.com/Pseudorca/attest/-/compare/v0.4.0...main
|
|
1557
|
+
[0.4.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.4.0
|
|
1558
|
+
[0.3.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.3.0
|
|
745
1559
|
[0.2.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.2
|
|
746
1560
|
[0.2.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.1
|
|
747
1561
|
[0.2.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.2.0
|