@am_shork/attest 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,11 @@
4
4
  // until now a human then transcribed it by hand with nothing checking the
5
5
  // transcription. This is that step, and the whole of why it is allowed to exist
6
6
  // where the `AGENTS.md` merge tool was not: the registry is a literal Attest
7
- // defines, so the result of an edit is checkable by re-reading it, and the edit
8
- // itself is a pure insertion (`splice.ts`).
7
+ // defines, so the result of an edit is checkable by re-reading it and this
8
+ // re-reads it (`verifyWritten`), rather than resting on the sentence. The edits
9
+ // are bounded to match: an ADDED requirement is a pure insertion, and a MODIFIED
10
+ // one replaces the span of a single value inside an entry it never rewrites
11
+ // (`splice.ts`).
9
12
  //
10
13
  // **It is re-runnable, not atomic.** No primitive spans one edit, N renames and
11
14
  // a directory move, and a scratch copy of the project root would have to be
@@ -30,10 +33,11 @@
30
33
  // at runtime on the happy path, and a later reordering would look harmless.
31
34
  import { mkdir, readFile, rename, stat } from 'node:fs/promises';
32
35
  import { join, dirname, basename } from 'node:path';
33
- import { repointImport, spliceRequirements, UnwritableValue } from './splice.js';
36
+ import { repointImport, spliceModifications, spliceRequirements, UnwritableValue, } from './splice.js';
37
+ import { readRegistrySource } from './static-registry.js';
34
38
  import { writeAtomic } from './write.js';
35
39
  import { idPrefix } from './locate.js';
36
- import { addedIds } from './apply.js';
40
+ import { addedIds, modifiedIds, sameRequirement } from './apply.js';
37
41
  import { byCodeUnit } from './order.js';
38
42
  import { relativePath } from './paths.js';
39
43
  /** `x.proposed.spec.ts` -> `x.spec.ts`, in place. */
@@ -48,8 +52,8 @@ export function mergedSpecPath(proposed) {
48
52
  * acting on it, and a merge that did the half it understood would put the file
49
53
  * into a state no verdict describes.
50
54
  */
51
- async function refusals(input, stamp) {
52
- const issues = [];
55
+ async function refusals(input, stamp, planned) {
56
+ const issues = [...planned];
53
57
  const { delta, root, changeName } = input;
54
58
  const archive = archivePath(input, stamp);
55
59
  // 0) The destination already exists. Checked here, before anything is
@@ -67,28 +71,34 @@ async function refusals(input, stamp) {
67
71
  });
68
72
  }
69
73
  // 1) Operations this does not perform. The gate applies all four in memory to
70
- // reach its verdict; only writing them back is limited to ADDED, because
71
- // REMOVED cannot say which comments belonged to the entry it deletes and
72
- // MODIFIED is a formatting-preserving edit inside an existing literal. Both
73
- // are the "destructive on a file the user cannot regenerate" shape.
74
+ // reach its verdict; writing them back covers ADDED and MODIFIED, and stops
75
+ // there because REMOVED cannot say which comments belonged to the entry it
76
+ // deletes the "destructive on a file the user cannot regenerate" shape.
77
+ // MODIFIED was refused by this same clause until it was written at the
78
+ // granularity of a *value* rather than an entry, which is where that sentence
79
+ // stops applying and REMOVED's does not: an entry being deleted has no smaller
80
+ // span to fall back to (`splice.ts`).
74
81
  const unsupported = [
75
82
  delta.renamed?.length ? 'renamed' : '',
76
83
  delta.removed?.length ? 'removed' : '',
77
- Object.keys(delta.modified ?? {}).length ? 'modified' : '',
78
84
  ].filter(Boolean);
79
85
  if (unsupported.length > 0) {
80
86
  issues.push({
81
87
  level: 'ERROR',
82
88
  code: 'apply-unsupported-delta',
83
89
  file: relativePath(root, join(root, 'changes', changeName)),
84
- message: `--apply writes back ADDED requirements only, and this change's delta also carries ${unsupported.join(', ')}. ` +
90
+ message: `--apply writes back ADDED and MODIFIED requirements, and this change's delta also carries ${unsupported.join(', ')}. ` +
85
91
  `The gate above still checked all of it — merge the remaining operations into the registry by hand, then run this command again to confirm.`,
86
92
  });
87
93
  }
88
- // 2) An added id whose prefix no registry file claims. Which file it belongs
94
+ // 2) An id whose prefix no registry file claims. Which file it belongs
89
95
  // in — or whether a file should be created for it — is not something the gate
90
- // verified, and guessing would file a requirement somewhere nobody chose.
91
- for (const id of unmergedAdded(input)) {
96
+ // verified, and guessing would file a requirement somewhere nobody chose. Over
97
+ // modified ids as well as added ones: an unowned prefix is why the merge cannot
98
+ // find the file, and reporting it as "no entry to modify" would send the reader
99
+ // to look inside a file this never opened.
100
+ const unowned = new Set([...unmergedAdded(input), ...modifiedIds(input.delta)]);
101
+ for (const id of [...unowned].sort(byCodeUnit)) {
92
102
  if (input.prefixOwners[idPrefix(id)] === undefined) {
93
103
  issues.push({
94
104
  level: 'ERROR',
@@ -146,6 +156,182 @@ function registryTargets(input, reqIds) {
146
156
  function unmergedAdded(input) {
147
157
  return addedIds(input.delta).filter((id) => !Object.hasOwn(input.base, id));
148
158
  }
159
+ /**
160
+ * The requirements this change modifies that the registry on disk does not
161
+ * already hold in its end state.
162
+ *
163
+ * The MODIFIED half of re-runnability, and deliberately the same shape as
164
+ * `unmergedAdded`: derived from the tree as it currently is rather than from a
165
+ * list of what a previous run meant to do. An entry already equal to `applied`
166
+ * is not edited, so a re-run after a crash writes what is left and nothing else
167
+ * — and a delta restating a value it does not change writes nothing at all.
168
+ */
169
+ function unmergedModified(input) {
170
+ const out = [];
171
+ for (const id of modifiedIds(input.delta)) {
172
+ const before = input.base[id];
173
+ const after = input.applied[id];
174
+ // A modified id the base does not have is `modify-missing`, which the gate
175
+ // reports and this never reaches; skipped rather than diagnosed a second
176
+ // time, because two commands answering for one condition is how they come to
177
+ // disagree.
178
+ if (!before || !after || sameRequirement(before, after))
179
+ continue;
180
+ out.push({ id, before, after });
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * What every registry file should end up holding, or why it cannot — computed
186
+ * without writing anything.
187
+ *
188
+ * Split from the write for one reason: a refusal that fires after two files have
189
+ * landed makes "refused whole" a claim with an exception in it, which is the
190
+ * kind of claim nobody can rely on (the note on `refusals` step 0 says the same
191
+ * thing about the archive destination). Both edits to a file are composed here,
192
+ * modification before splice, and the splice re-reads the text the modification
193
+ * produced rather than an offset taken before it — an insertion point is a
194
+ * position in a file, and the modification has just moved bytes in front of it.
195
+ */
196
+ async function planRegistries(input) {
197
+ const { root } = input;
198
+ const issues = [];
199
+ const added = new Map();
200
+ const modified = new Map();
201
+ for (const id of unmergedAdded(input)) {
202
+ const file = input.prefixOwners[idPrefix(id)];
203
+ if (!file)
204
+ continue; // Refused as `apply-no-prefix-owner`; not diagnosed twice.
205
+ const group = added.get(file) ?? {};
206
+ group[id] = input.applied[id];
207
+ added.set(file, group);
208
+ }
209
+ for (const change of unmergedModified(input)) {
210
+ const file = input.prefixOwners[idPrefix(change.id)];
211
+ if (!file)
212
+ continue;
213
+ modified.set(file, [...(modified.get(file) ?? []), change]);
214
+ }
215
+ const files = [];
216
+ for (const file of [...new Set([...added.keys(), ...modified.keys()])].sort(byCodeUnit)) {
217
+ const source = await readFile(file, 'utf8');
218
+ const mods = modified.get(file) ?? [];
219
+ const adds = added.get(file);
220
+ const ids = [...mods.map((m) => m.id), ...Object.keys(adds ?? {})].sort(byCodeUnit);
221
+ const changed = spliceModifications(file, source, mods);
222
+ if (!changed) {
223
+ issues.push(unreadable(root, file));
224
+ continue;
225
+ }
226
+ if (!changed.ok) {
227
+ issues.push(...changed.refusals.map((r) => modifyRefusal(root, file, r)));
228
+ continue;
229
+ }
230
+ let text = changed.source;
231
+ if (adds) {
232
+ try {
233
+ const spliced = spliceRequirements(file, text, adds);
234
+ if (spliced === undefined) {
235
+ issues.push(unreadable(root, file));
236
+ continue;
237
+ }
238
+ text = spliced;
239
+ }
240
+ catch (err) {
241
+ // The emitter refused a value it cannot write as source — today only a
242
+ // `__proto__` param key, which the schema rejects before `--apply` runs.
243
+ // Caught rather than left to the CLI's crash envelope because `--apply`
244
+ // is destructive and a bare stack is the shape a resume cannot read.
245
+ if (!(err instanceof UnwritableValue))
246
+ throw err;
247
+ issues.push({
248
+ level: 'ERROR',
249
+ code: 'internal-error',
250
+ file: relativePath(root, file),
251
+ message: `${relativePath(root, file)} could not be written: ${err.message}.`,
252
+ });
253
+ continue;
254
+ }
255
+ }
256
+ files.push({ file, source, text, ids });
257
+ }
258
+ return { files, issues };
259
+ }
260
+ /**
261
+ * Whether the file just written reads back as the requirements it was written
262
+ * from — the check the whole argument for editing a hand-written registry rests
263
+ * on, performed rather than argued.
264
+ *
265
+ * "The result of an edit is checkable by re-reading it" is the clause that
266
+ * separates this from the merge tool the `AGENTS.md` proposal was rejected for,
267
+ * and until MODIFIED existed nothing did the re-reading: a pure insertion is
268
+ * right by construction, so the sentence was load-bearing without being
269
+ * exercised. A replacement is not, so the re-read is here for both — the added
270
+ * entries included, because a check that covered only the newer half would leave
271
+ * the older claim in exactly the state this is fixing.
272
+ *
273
+ * What it cannot see is the other half, and saying so is the point: a re-read
274
+ * compares *values*, and a comment or a blank line that went missing does not
275
+ * appear in a `Registry` at all. That is why the edit is bounded to the span of
276
+ * one value rather than trusted to this (`splice.ts`).
277
+ */
278
+ async function verifyWritten(input, plan) {
279
+ const { root } = input;
280
+ const back = readRegistrySource(plan.file, await readFile(plan.file, 'utf8'));
281
+ const wrong = back.ok
282
+ ? plan.ids.filter((id) => {
283
+ const written = back.registry[id];
284
+ const proved = input.applied[id];
285
+ return !written || !proved || !sameRequirement(written, proved);
286
+ })
287
+ : plan.ids;
288
+ if (wrong.length === 0)
289
+ return undefined;
290
+ return {
291
+ level: 'ERROR',
292
+ code: 'internal-error',
293
+ file: relativePath(root, plan.file),
294
+ message: `${relativePath(root, plan.file)} was written, but reading it back does not give the requirements the gate proved (${wrong.join(', ')}). ` +
295
+ `Check that file against the change's delta before running anything else — the merge stopped here, so the steps after it have not run.`,
296
+ };
297
+ }
298
+ /** The registry the gate read a moment ago, unreadable to the merge. */
299
+ function unreadable(root, file) {
300
+ // Unreachable through the command, so it is reported as the internal
301
+ // inconsistency it is rather than as a diagnosis about the user's registry.
302
+ return {
303
+ level: 'ERROR',
304
+ code: 'internal-error',
305
+ file: relativePath(root, file),
306
+ message: `${relativePath(root, file)} read as a registry for the gate but not for the merge.`,
307
+ };
308
+ }
309
+ /**
310
+ * One value the merge declined to write over, as a diagnostic.
311
+ *
312
+ * The code is the one an unsupported operation already carries: to a consumer
313
+ * branching on `code`, "this delta has a part `--apply` does not write" is the
314
+ * same fact whichever part it is, and the narrowing that made most MODIFIED
315
+ * deltas writable should not cost anyone a new string to handle. The `reqId` and
316
+ * the field are what changed, and both are in the message where the reader is.
317
+ */
318
+ function modifyRefusal(root, file, refusal) {
319
+ const where = refusal.field ? `${refusal.reqId}.${refusal.field}` : refusal.reqId;
320
+ const why = {
321
+ comment: `a comment sits inside the value it would replace, and which side of that edit the comment belongs to is not something --apply can decide`,
322
+ 'entry-not-found': `the registry file that owns its prefix holds no entry for it`,
323
+ 'not-a-literal': `the value it would replace is not written as a literal`,
324
+ 'param-dropped': `the proved end state does not carry that param, and a delta has no way to say one was removed`,
325
+ };
326
+ return {
327
+ level: 'ERROR',
328
+ code: 'apply-unsupported-delta',
329
+ reqId: refusal.reqId,
330
+ file: relativePath(root, file),
331
+ message: `--apply cannot write "${where}" into ${relativePath(root, file)}: ${why[refusal.reason]}. ` +
332
+ `Merge this requirement by hand, then run this command again to confirm — nothing has been written.`,
333
+ };
334
+ }
149
335
  /**
150
336
  * Perform the merge, or refuse it whole.
151
337
  *
@@ -163,67 +349,27 @@ export async function applyMerge(input) {
163
349
  // another — rare, and silent when it happens, which is the combination this
164
350
  // repository treats as worth the line.
165
351
  const stamp = new Date().toISOString().slice(0, 10);
166
- const refused = await refusals(input, stamp);
352
+ // Every registry edit is computed before any of them is written, which is what
353
+ // makes "refused whole" true of the write-back and not only of the delta's
354
+ // shape: a comment sitting where a modification would land, or a value the
355
+ // emitter cannot write, is now found with the tree still untouched. Before
356
+ // MODIFIED existed the text generation could only fail on a `__proto__` key
357
+ // and it failed one file at a time, so this was the same claim by luck.
358
+ const planned = await planRegistries(input);
359
+ const refused = await refusals(input, stamp, planned.issues);
167
360
  if (refused.length > 0)
168
361
  return { issues: refused, written: [] };
169
362
  const { root } = input;
170
363
  const written = [];
171
- // --- 1) Splice, first. See the note at the top of this file.
172
- const byFile = new Map();
173
- for (const id of unmergedAdded(input)) {
174
- const file = input.prefixOwners[idPrefix(id)];
175
- const group = byFile.get(file) ?? {};
176
- group[id] = input.applied[id];
177
- byFile.set(file, group);
178
- }
179
- for (const file of [...byFile.keys()].sort(byCodeUnit)) {
180
- const source = await readFile(file, 'utf8');
181
- let spliced;
182
- try {
183
- spliced = spliceRequirements(file, source, byFile.get(file));
184
- }
185
- catch (err) {
186
- // The emitter refused a value it cannot write as source — today only a
187
- // `__proto__` param key, which the schema rejects before `--apply` runs.
188
- // Caught rather than left to the CLI's crash envelope so the account of
189
- // what this merge had already written survives: `--apply` is destructive
190
- // and half a merge reported as a bare stack is the shape a resume cannot
191
- // read. The write for *this* file has not happened — the throw is in the
192
- // text generation, above `writeAtomic`.
193
- if (!(err instanceof UnwritableValue))
194
- throw err;
195
- return {
196
- issues: [
197
- {
198
- level: 'ERROR',
199
- code: 'internal-error',
200
- file: relativePath(root, file),
201
- message: `${relativePath(root, file)} could not be written: ${err.message}.`,
202
- },
203
- ],
204
- written,
205
- };
206
- }
207
- if (spliced === undefined) {
208
- // Unreachable through the command — the gate read this file as a literal
209
- // moments ago — so it is reported as the internal inconsistency it is
210
- // rather than as a diagnosis about the user's registry.
211
- return {
212
- issues: [
213
- {
214
- level: 'ERROR',
215
- code: 'internal-error',
216
- file: relativePath(root, file),
217
- message: `${relativePath(root, file)} read as a registry for the gate but not for the merge.`,
218
- },
219
- ],
220
- written,
221
- };
222
- }
223
- if (spliced !== source) {
224
- await writeAtomic(file, spliced);
225
- written.push(relativePath(root, file));
226
- }
364
+ // --- 1) Write the registries, first. See the note at the top of this file.
365
+ for (const plan of planned.files) {
366
+ if (plan.text === plan.source)
367
+ continue;
368
+ await writeAtomic(plan.file, plan.text);
369
+ written.push(relativePath(root, plan.file));
370
+ const mismatch = await verifyWritten(input, plan);
371
+ if (mismatch)
372
+ return { issues: [mismatch], written };
227
373
  }
228
374
  // --- 2) Repoint each claimed spec's delta import, then rename it in place.
229
375
  //
@@ -316,21 +316,13 @@ export async function runVerify(root, options = {}) {
316
316
  specFiles: scan.specFiles.length,
317
317
  attesting: attesting.length,
318
318
  };
319
- // A run with nothing to attest used to print "✓ No issues." and exit 0,
320
- // because "no failing tests" was read as passed. An empty registry is the
321
- // only shape of that which no other check already catches: with at least one
322
- // requirement, an absent scenario is `uncovered-requirement` and a declared
323
- // scenario that never executed is `declared-not-run`, both ERRORs. So this is
324
- // narrower than "zero scenarios ran" and firing on that too would only
325
- // double-report a run that is already red.
326
- if (counts.requirements === 0) {
327
- issues.push({
328
- level: 'ERROR',
329
- code: 'empty-spec',
330
- message: `No requirements found under this root, so this run attested nothing. ` +
331
- `Point attest at the directory holding your *.reqs.ts files, or add one.`,
332
- });
333
- }
319
+ // `empty-spec` belongs to `validateStructure` above, not here: the registry
320
+ // alone decides it, so it is owed by every command performing §5.3 rather than
321
+ // by the one that executes. The guard a reader is most likely to want here is
322
+ // "zero scenarios ran", and it is deliberately not that — with at least one
323
+ // requirement, an absent scenario is already `uncovered-requirement` and a
324
+ // declared scenario that never executed is already `declared-not-run`, both
325
+ // ERRORs, so the wider rule would only double-report a run that is red anyway.
334
326
  // Nothing declares intent, so there is nothing for Attest to run. Skipping
335
327
  // the child run keeps an incumbent suite untouched; the verdict comes from
336
328
  // empty-spec or uncovered-requirement, both already ERRORs above.
@@ -813,14 +805,44 @@ export async function runStatus(root, changeName, options = {}) {
813
805
  return nothing([invalidChangeNameIssue(changeName)]);
814
806
  if (unusable)
815
807
  return nothing([unusable]);
816
- const read = await readDelta(root, changeName, options);
817
- if ('issue' in read)
818
- return nothing([read.issue]);
819
- const scan = await scanProject(root);
820
- const { merged: plan } = await changeMergedPlan(root, read.delta, scan);
821
- const firstRun = await readRedRecord(root, changeName);
822
- const rows = statusRows(addedIds(read.delta), plan, firstRun);
823
- return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
808
+ // One loader for the whole command, or none at all. Both reads below take the
809
+ // reader `options` asks for, so the escape hatch moves them together: the
810
+ // default path evaluates nothing, which is what puts this command on ATX-16's
811
+ // roster, and `--eval` is one named decision rather than one per file.
812
+ return withLoader(options, async (loader) => {
813
+ const read = await readDelta(root, changeName, options, loader);
814
+ if ('issue' in read)
815
+ return nothing([read.issue]);
816
+ const scan = await scanProject(root);
817
+ const { registry: base, issues: registryIssues } = await readRegistry(root, options, scan.reqsFiles, loader);
818
+ // `hasError`, the gate's own predicate over the same list, because the
819
+ // registry is what the delta is applied *to*: without it there is no end
820
+ // state to describe, and a report computed against half a registry would
821
+ // answer about a change nobody proposed.
822
+ if (hasError(registryIssues))
823
+ return nothing(registryIssues);
824
+ // The gate's own call, made here for its diagnosis rather than its result:
825
+ // the rows below stay decided by the delta, the specs and the record alone
826
+ // (ATX-32), and what this adds is the refusal. A delta that cannot be
827
+ // applied describes an end state that does not exist, so there is no
828
+ // progress toward it to report — an `unproven` against it would be a
829
+ // true-looking sentence about a change that cannot exist.
830
+ const { issues: deltaIssues } = applyDelta(base, read.delta);
831
+ if (deltaIssues.length > 0)
832
+ return nothing(deltaIssues);
833
+ const { merged: plan, issues: unreadableSpecs } = await changeMergedPlan(root, read.delta, scan);
834
+ // The same refusal the gate makes, from the same list and on the same
835
+ // argument (ATX-65): every state this command reports is computed *from* the
836
+ // plan, so a spec the parser could not read does not shrink the report — it
837
+ // changes what the remaining rows say. A requirement whose only scenario is
838
+ // in that file reads as having none, which is the opposite of the truth and
839
+ // is advice to redo work already on disk.
840
+ if (unreadableSpecs.length > 0)
841
+ return nothing(unreadableSpecs);
842
+ const firstRun = await readRedRecord(root, changeName);
843
+ const rows = statusRows(addedIds(read.delta), plan, firstRun);
844
+ return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
845
+ });
824
846
  }
825
847
  /** Archive gate for a change (design §8, §9: `attest archive <change>`). */
826
848
  export async function runArchive(root, changeName, options = {}) {
@@ -34,9 +34,13 @@
34
34
  // workflow to an agent that trusts it. The backstop is that every mistake it
35
35
  // could cause is a diagnostic with a fix hint — a registry written the old way
36
36
  // is `registry-not-static`, and the agent corrects itself from the report.
37
- // It has happened once, so: read this file when a release adds a diagnostic.
38
- // Nothing gates that `ATX-57` catches a code the engine cannot emit, never one
39
- // it can.
37
+ // It has happened twice, the second time on a diagnostic that was not added but
38
+ // *redefined*the code table and the prose below it disagreed, and an agent
39
+ // branches on the table. So: read this file when a release adds a diagnostic
40
+ // **or changes what one means**, and grep this string for the code rather than
41
+ // trusting that the section you edited was its only mention. Nothing gates it —
42
+ // `ATX-57` catches a code the engine cannot emit, never one it can, and its
43
+ // rationale records that one-directionality as deliberate.
40
44
  /**
41
45
  * The one sentence that decides whether the workflow is ever loaded.
42
46
  *
@@ -128,11 +132,16 @@ promises is a two-stage workflow, and the stages are separate on purpose.
128
132
  or it is an \`unbound-param\` ERROR. A \`{placeholder}\` written into a
129
133
  *rationale* is a \`rationale-placeholder\` WARNING — rationales are not
130
134
  interpolated, so it would reach the rendered document with its braces intact.
131
- - **A registry is a literal.** Every value is written in the file: no imported
132
- constant (\`params: { maxMb: MAX_MB }\`), no computed value, no \`Date.now()\`.
133
- \`check\`, \`cover\` and \`render\` read \`*.reqs.ts\` with the compiler API and
134
- never execute it, so anything they cannot read is a \`registry-not-static\`
135
- ERROR. An imported constant also breaks the single-source rule outright — the
135
+ - **A registry is a literal, and so is a delta.** Every value is written in the
136
+ file: no imported constant (\`params: { maxMb: MAX_MB }\`), no computed value,
137
+ no \`Date.now()\`. \`check\`, \`cover\` and \`render\` read \`*.reqs.ts\` with the
138
+ compiler API and never execute it, so anything they cannot read is a
139
+ \`registry-not-static\` ERROR. **The same rule covers
140
+ \`requirements.delta.ts\`**, which \`check\` and \`status\` read the same way — a
141
+ \`const\` lifted out of a delta is that same error, and a delta is the more
142
+ exposed of the two, living in \`changes/\` — the part of the tree that is by
143
+ definition still under review.
144
+ An imported constant also breaks the single-source rule outright — the
136
145
  value's real owner is then somewhere else. Inline it, and have the application
137
146
  code read it from the registry.
138
147
  - **Write prose as one long single-line string.** A template literal with no
@@ -176,13 +185,31 @@ lib/game/specs/
176
185
  └── fog.proposed.spec.ts # this change's, and ../fog already resolves
177
186
  \`\`\`
178
187
 
179
- That name is the whole mechanism. It keeps the file out of \`attest verify\` and
180
- out of a plain \`vitest run\` while the change is in flight — the scenarios are
181
- red by construction — and \`attest archive <name>\` pulls it in by the requirement
182
- ids it covers: a change claims the proposed specs that declare a scenario for an
183
- id it ADDs, renames to, or MODIFIEs. Nothing lists paths anywhere, so nothing can
184
- fall out of step. A proposed spec no change claims is a \`proposed-spec-unclaimed\`
185
- ERROR from \`attest check\`, because it would otherwise run nowhere at all.
188
+ That name is the whole mechanism. It keeps the file out of \`attest verify\`
189
+ while the change is in flight — the scenarios are red by construction — and
190
+ \`attest archive <name>\` pulls it in by the requirement ids it covers: a change
191
+ claims the proposed specs that declare a scenario for an id it ADDs, renames to,
192
+ or MODIFIEs. Nothing lists paths anywhere, so nothing can fall out of step. A
193
+ proposed spec no change claims is a \`proposed-spec-unclaimed\` ERROR from
194
+ \`attest check\`, because it would otherwise run nowhere at all.
195
+
196
+ **It does not hide the file from your own test command.** \`*.proposed.spec.ts\`
197
+ still ends in \`.spec.ts\`, so an ordinary \`**/*.spec.ts\` include — Vitest's own
198
+ default among them — picks it up, and during stage 1 those scenarios are
199
+ *supposed* to fail. Only Attest's scope excludes them. If a red \`npm test\` while
200
+ a change is in flight is a problem for you, exclude the pattern in your own
201
+ config; that is a decision about your suite, and Attest does not make it for you:
202
+
203
+ \`\`\`ts
204
+ // vitest.config.ts — optional, and only if you want your own suite green
205
+ import { configDefaults, defineConfig } from 'vitest/config';
206
+
207
+ export default defineConfig({
208
+ // Spread the defaults: a bare \`exclude\` REPLACES them, and Vitest's include
209
+ // reaches node_modules without them.
210
+ test: { exclude: [...configDefaults.exclude, '**/*.proposed.spec.ts'] },
211
+ });
212
+ \`\`\`
186
213
 
187
214
  Writing it at its merged location is what makes merging it a rename. Its relative
188
215
  imports resolve now exactly as they will afterwards, so \`../fog\` never becomes
@@ -279,11 +306,17 @@ To see where the change stands at any point, without paying for a run:
279
306
  attest status <name> --json # per added requirement: no-scenario / unproven / proven
280
307
  \`\`\`
281
308
 
282
- It reads the delta, the specs and \`first-run.json\`, and reports what the gate
283
- will still want. A change in flight is \`ok: true\` with rows to work through —
284
- this command never fails on unmet obligations, so do not read a clean table as
285
- "done". The state it cannot see is whether the tests pass; \`attest archive\` is
286
- the only thing that answers that.
309
+ It reads the delta, the specs and \`first-run.json\`, and reports **two** of the
310
+ gate's obligations, for the ids this change ADDs only: does each have a scenario,
311
+ and has each scenario been recorded failing. A change in flight is \`ok: true\`
312
+ with rows to work through this command never fails on unmet obligations, so do
313
+ not read a clean table as "done".
314
+
315
+ **Everything else the gate checks is invisible here**, and that is more than
316
+ whether the tests pass. A scenario left \`skip\`ped is \`declared-not-run\` and
317
+ blocks the gate with the suite fully green; so does a spec file that will not
318
+ import, or a proposed spec no delta claims. A clean table plus green tests is
319
+ still not a verdict — \`attest archive\` is the only thing that decides.
287
320
 
288
321
  Then present the proposal, the ids, and the red output, and **stop**. Wait for
289
322
  agreement before implementing.
@@ -317,9 +350,32 @@ once. Branch on \`issues[].code\`, never on \`message\`:
317
350
  | \`add-conflict\` | the delta adds an id that already exists with different content |
318
351
  | \`change-not-found\` | no \`requirements.delta.ts\` for that name |
319
352
  | \`proposed-spec-name-taken\` | a proposed spec's merged name is already held by another spec |
320
- | \`apply-unsupported-delta\` | \`--apply\` writes back ADDED only, and this delta carries more |
353
+ | \`apply-unsupported-delta\` | \`--apply\` writes back ADDED and MODIFIED; this delta carries RENAMED or REMOVED, or a MODIFIED value whose source span holds a comment |
321
354
  | \`apply-no-prefix-owner\` | no registry file owns the prefix of an id this change adds |
322
355
 
356
+ ### Iterating: run the spec directly, decide with the gate
357
+
358
+ The gate does everything above after every edit, which makes it the slowest
359
+ possible inner loop. While you are still making a scenario pass, run that one
360
+ file instead, and keep \`attest archive\` for when you think you are finished:
361
+
362
+ \`\`\`
363
+ vitest run path/to/fog.proposed.spec.ts
364
+ \`\`\`
365
+
366
+ Two things make that safe, and one keeps it from being a verdict:
367
+
368
+ - **It writes nothing.** \`first-run.json\` is only ever written by the gate, so
369
+ no number of direct runs can touch the record or the never-red obligation.
370
+ - **\`attest status <name>\` still answers the readable half** without a run,
371
+ exactly as in stage 1.
372
+ - **A green file here does not predict the gate.** Your config supplies aliases,
373
+ transforms and a DOM; the gate's child run is isolated (\`config: false\`) and
374
+ supplies none of them unless told to. So a scenario can pass directly and fail
375
+ under the gate — that gap is the first thing to suspect when it does. See
376
+ \`tests-red\` in the troubleshooting document, and \`--vitest-config\` if your
377
+ specs need that environment.
378
+
323
379
  ### Four things you must not do
324
380
 
325
381
  Each turns the gate green without changing the system, which is the exact
@@ -365,7 +421,8 @@ attest archive <name> --apply
365
421
  \`\`\`
366
422
 
367
423
  That finishes the merge the verdict just approved: it splices the change's ADDED
368
- requirements into the registry file owning their id prefix, repoints each proposed
424
+ requirements into the registry file owning their id prefix, writes each MODIFIED
425
+ requirement's changed values over the values they replace, repoints each proposed
369
426
  spec's import of \`requirements.delta.ts\` at that registry, renames the specs in
370
427
  place, and moves \`changes/<name>/\` to \`archive/<date>-<name>/\`. It re-runs the
371
428
  gate first and writes nothing if that fails, and it prints every path it touched.
@@ -379,10 +436,14 @@ Two things it does not do, and both are still yours:
379
436
  registry is a \`stale-spec-doc\` ERROR.
380
437
  2. **Run the merged suite.** \`attest verify\`, on the result, reported.
381
438
 
382
- **It writes back ADDED only.** A delta also carrying RENAMED, REMOVED or MODIFIED
383
- is refused whole as \`apply-unsupported-delta\`, with nothing written — the gate
384
- still checked all four, so nothing about the change is unverified, but the rest is
385
- merged by hand.
439
+ **It writes back ADDED and MODIFIED.** A delta also carrying RENAMED or REMOVED is
440
+ refused whole as \`apply-unsupported-delta\`, with nothing written — the gate still
441
+ checked all four, so nothing about the change is unverified, but the rest is merged
442
+ by hand. The same code refuses one MODIFIED *value* when a comment sits inside the
443
+ span it would write over: the edit replaces a value and never the entry holding it,
444
+ so \`statement\` and \`rationale\` can never hit this, while a \`params\` value or an
445
+ \`outOfScope\` list can. Move the comment beside the value it belongs to, and run
446
+ the command again.
386
447
 
387
448
  Merging by hand, when it refuses: splice the delta's entries into the registry
388
449
  file that owns their prefix; then for each \`*.proposed.spec.ts\`, **repoint its
@@ -400,7 +461,7 @@ move, so nothing else about it does either. Then move the change folder.
400
461
  | \`attest cover\` | which requirements lack a scenario. |
401
462
  | \`attest render\` | the registry as Markdown for human readers; \`--check\` gates a committed copy. |
402
463
  | \`attest archive <change>\` | the completion gate for a proposed change; \`--apply\` also performs the merge it approves. |
403
- | \`attest status <change>\` | what that gate still wants, without running the suite. |
464
+ | \`attest status <change>\` | per id the change ADDs, two of the gate's obligations — scenario written, seen red — read without running the suite. A strict subset of the gate, never a second one. |
404
465
 
405
466
  \`verify\` starts the child run **isolated** — it does not read \`vitest.config.ts\`,
406
467
  so a verdict never depends on ambient configuration. Specs needing transforms, a