@sabaiway/agent-workflow-kit 14.29.1 → 14.31.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.
@@ -24,7 +24,7 @@
24
24
  //
25
25
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
26
26
 
27
- import { readFileSync, lstatSync } from 'node:fs';
27
+ import { readFileSync, lstatSync, unlinkSync } from 'node:fs';
28
28
  import { spawnSync } from 'node:child_process';
29
29
  import { join } from 'node:path';
30
30
  import { CANON_README, CONFIG_REL, SEED_CONFIG, loadConfig, normalizeCanonical, refreshReadme } from './orchestration-config.mjs';
@@ -36,6 +36,8 @@ import { VEHICLES_REL } from './vehicle-settings.mjs';
36
36
  import { surveyAdrLayoutStrict } from './family-registry.mjs';
37
37
  import { ENSURE_TOKENS, FAILURE_CAUSES, SEED_SCRIPTS } from './ensure-vocabulary.mjs';
38
38
  import { NODE_EVIDENCE, describeNodeProbes, probeNodeEvidence } from './node-evidence.mjs';
39
+ import { classifyDeployedScript } from './script-priors.mjs';
40
+ import { parseFrontmatter } from '../references/scripts/check-docs-size.mjs';
39
41
 
40
42
  // The closed vocabulary lives in its own PURE leaf so the read-only doc-parity lint can bind the
41
43
  // relayed token set without importing this module's writer graph. Re-exported here because every
@@ -297,11 +299,12 @@ export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
297
299
 
298
300
  // ── docs/ai/index.md — the GENERATED navigator, regenerated when missing or stale ───────────────
299
301
 
300
- // The only ensure whose target is generated rather than authored: there is nothing to preserve, and
301
- // nothing to seed from either — the bundled generator IS the writer, driven through its idempotent
302
+ // The only ensure whose target is generated rather than authored: nothing to seed from, and on the
303
+ // bundled path nothing to preserve — the bundled generator IS the writer, driven through its idempotent
302
304
  // finalizer mode. Sync-over-async by the same precedent the ADR rotator uses (spawnSync the CLI):
303
305
  // the ensure framework is synchronous, and a second index implementation here would be the drift
304
- // the one-generator rule exists to prevent.
306
+ // the one-generator rule exists to prevent. Under a hook module that is not the bundled one, the
307
+ // project's own checker judges the navigator and a write it rejects is put back (spec docs-navigator).
305
308
  const INDEX_REL = 'docs/ai/index.md';
306
309
  const GENERATOR_PATH = ['references', 'scripts', 'check-docs-size.mjs'];
307
310
  const ENSURE_INDEX_OUTCOME = /^ensure-index: (regenerated|already-current)\b/m;
@@ -317,9 +320,90 @@ const MAY_HAVE_WRITTEN = 'the navigator may already have been written — re-rea
317
320
 
318
321
  const describeExit = (result) => (result.signal ? `on signal ${result.signal}` : `with code ${result.status}`);
319
322
  const bothStreams = (result) => `${String(result.stdout ?? '')}${String(result.stderr ?? '')}`.trim();
323
+ const describeExitWithOutput = (result, subject = 'it') => `${subject} exited ${describeExit(result)}${bothStreams(result) ? `: ${bothStreams(result)}` : ''}`;
324
+
325
+ const HOOK_MODULES = ['check-docs-size.mjs', 'spec-schema.mjs'];
326
+ const KEPT_JUDGE = `the project's own checker (${SCRIPTS_DIR}/check-docs-size.mjs --check-index)`;
327
+ const KEPT_FITS = `in sync under ${KEPT_JUDGE} and within the maxLines it stamps`;
328
+ const KEPT_PROBE_TIMEOUT_MS = 30_000;
329
+ const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
330
+ const readStampedCap = (text) => { const value = parseFrontmatter(text)?.maxLines; const cap = value ? Number(value) : NaN; return Number.isNaN(cap) ? undefined : cap; };
331
+
332
+ const surveyHookModule = (cwd, kitRoot, name, read, lstat) => {
333
+ const rel = `${SCRIPTS_DIR}/${name}`;
334
+ const probe = (() => { try { return probeSeedTarget(join(cwd, rel), lstat); } catch (err) { if (err?.code === 'ENOTDIR') return { present: false }; throw err; } })();
335
+ if (probe.wrongKind || !probe.present) return { rel, state: probe.wrongKind ? 'wrong-kind' : 'absent', wrongKind: probe.wrongKind };
336
+ const deployed = read(join(cwd, rel));
337
+ try {
338
+ return { rel, state: classifyDeployedScript(deployed, name, read(join(kitRoot, ...GENERATOR_PATH.slice(0, 2), name))) };
339
+ } catch (err) {
340
+ return { rel, state: 'bundle-unreadable', error: causeOf(err) };
341
+ }
342
+ };
343
+
344
+ const judgeKept = ({ cwd, spawn, read, lstat, deps }) => {
345
+ const result = spawn(process.execPath, [join(cwd, SCRIPTS_DIR, 'check-docs-size.mjs'), '--check-index'], { cwd, encoding: 'utf8', timeout: deps.keptProbeTimeoutMs ?? KEPT_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' });
346
+ if (result.error) return { error: `${causeOf(result.error)}${result.signal ? `, killed on signal ${result.signal}` : ''}` };
347
+ if (result.status !== 0) return { accepted: false, said: describeExitWithOutput(result), half: 'its index check' };
348
+ const text = lstatNoFollow(join(cwd, INDEX_REL), lstat)?.isFile() ? String(read(join(cwd, INDEX_REL), 'utf8')) : null;
349
+ const cap = text === null ? undefined : readStampedCap(text);
350
+ if (cap !== undefined && countLines(text) <= Number(cap)) return { accepted: true };
351
+ const why = text === null ? 'no navigator file stands' : cap === undefined ? 'the navigator stamps no maxLines' : `${countLines(text)} lines against the maxLines ${cap} it stamps`;
352
+ return { accepted: false, said: describeExitWithOutput(result), half: `the cap it stamps (${why})` };
353
+ };
354
+
355
+ const removeIndexIfPresent = (cwd, deps) => {
356
+ try {
357
+ (deps.unlink ?? unlinkSync)(join(cwd, INDEX_REL));
358
+ } catch (err) {
359
+ if (err?.code !== 'ENOENT') throw err;
360
+ }
361
+ };
362
+
363
+ const putBackIndex = (cwd, found, deps) => {
364
+ try {
365
+ if (found !== null) writeDocsAiFileAtomic(cwd, INDEX_REL, found, deps, { noun: 'the navigator' });
366
+ else removeIndexIfPresent(cwd, deps);
367
+ } catch (err) {
368
+ return causeOf(err);
369
+ }
370
+ return null;
371
+ };
372
+
373
+ const ensureKeptIndex = ({ cwd, kitRoot, drive, spawn, read, lstat, deps, hook }) => {
374
+ const judge = () => judgeKept({ cwd, spawn, read, lstat, deps });
375
+ const reportUnanswered = (verdict, after) => loud('index', 'index-probe-failed', `${INDEX_REL}: ${KEPT_JUDGE} could not be launched or did not answer in time (${verdict.error}) — ${after}`);
376
+ const first = judge();
377
+ if (first.error) return reportUnanswered(first, 'nothing was written');
378
+ if (first.accepted) return ok('index', 'already-current', `${INDEX_REL}: ${KEPT_FITS} — nothing written`);
379
+ const custom = [...hook, ...['check-docs-size.test.mjs', 'spec-schema.test.mjs'].map((name) => surveyHookModule(cwd, kitRoot, name, read, lstat))].filter((entry) => entry.state === 'custom').map((entry) => entry.rel).sort();
380
+ const route = custom.length > 0 ? `the replace line printed for the spec layer (${custom.join(', ')}), run on the user's yes` : "the spec layer's refresh of the modules the project's hook loads, then this step again";
381
+ const found = lstatNoFollow(join(cwd, INDEX_REL), lstat) === null ? null : read(join(cwd, INDEX_REL));
382
+ const restored = found === null ? 'the navigator was put back: found absent, it was removed again' : 'the navigator was put back byte for byte';
383
+ const restoreThen = (failure, compose) => {
384
+ const refused = putBackIndex(cwd, found, deps);
385
+ return refused === null ? compose() : loud('index', 'write-refused', `${INDEX_REL}: putting the navigator back failed (${refused})${failure ? ` after ${failure}` : ''} — a navigator ${KEPT_JUDGE} rejects may stand; re-read it before re-running`);
386
+ };
387
+ const run = drive('--ensure-index');
388
+ const refusal = String(run.stderr ?? '').match(ENSURE_INDEX_REFUSAL);
389
+ if (run.error || run.status !== 0 || !String(run.stdout ?? '').match(ENSURE_INDEX_OUTCOME)) {
390
+ const cause = run.error ? 'generator-unlaunchable' : run.status === 2 && refusal ? (refusal[1] === 'write-refused' ? 'write-refused' : 'index-probe-failed') : 'generator-failed';
391
+ const what = run.error ? `the bundled generator could not be started (${causeOf(run.error)})` : describeExitWithOutput(run, 'the bundled generator');
392
+ return restoreThen(what, () => loud('index', cause, `${INDEX_REL}: ${what} — ${restored}`));
393
+ }
394
+ let second;
395
+ try { second = judge(); } catch (err) { return restoreThen(`the navigator could not be read back (${causeOf(err)})`, () => loud('index', 'unexpected-error', `${INDEX_REL}: the navigator could not be read back after the bundled write (${causeOf(err)}) — ${restored}`)); }
396
+ if (second.error) return restoreThen(`${KEPT_JUDGE} could not answer (${second.error})`, () => reportUnanswered(second, restored));
397
+ if (second.accepted) return ok('index', 'regenerated', `${INDEX_REL}: written by the bundled generator — ${KEPT_FITS}`);
398
+ return restoreThen(null, () => ok('index', 'customized-preserved', `${INDEX_REL}: ${KEPT_JUDGE} rejected the bundled render on ${second.half} (${second.said}), and the navigator as found did not pass it either (${first.half}) — ${restored}. The route: ${route}`));
399
+ };
400
+
401
+ const buildKeptDryRunNote = (hook) =>
402
+ `a module the project's hook loads (${hook.filter((entry) => entry.state !== 'current').map((entry) => entry.rel).join(', ')}) is not the bundled one: unless the spec-layer step of this run first refreshes every such module, the real run judges the navigator by ${KEPT_JUDGE} and puts back a write it rejects`;
320
403
 
321
404
  export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
322
405
  const lstat = deps.lstat ?? lstatSync;
406
+ const read = deps.readFile ?? readFileSync;
323
407
  const spawn = deps.spawnSync ?? spawnSync;
324
408
  const generator = join(kitRoot, ...GENERATOR_PATH);
325
409
  const drive = (mode) => spawn(process.execPath, [generator, mode, `--root=${cwd}`], { encoding: 'utf8' });
@@ -330,14 +414,21 @@ export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
330
414
  if (probe.wrongKind) {
331
415
  return loud('index', 'wrong-node-kind', `${INDEX_REL}: exists but is ${probe.wrongKind} — the navigator is a generated file and this is not one; nothing was read or written, resolve it by hand and re-run`);
332
416
  }
333
-
417
+ const hook = HOOK_MODULES.reduce((acc, name) => (acc[0]?.state === 'absent' || acc[0]?.wrongKind ? acc : [...acc, surveyHookModule(cwd, kitRoot, name, read, lstat)]), []);
418
+ const wrong = hook.find((entry) => entry.state === 'wrong-kind');
419
+ if (wrong) return loud('index', 'wrong-node-kind', `${wrong.rel}: exists but is ${wrong.wrongKind} — the project's hook loads it, so nothing was read or written; resolve it by hand and re-run`);
420
+ const kept = hook[0].state !== 'absent' && !hook.every((entry) => entry.state === 'current');
421
+ const unreadable = kept && hook.find((entry) => entry.state === 'bundle-unreadable');
422
+ if (unreadable) return loud('index', 'bundle-unreadable', `${unreadable.rel}: the bundled module could not be read, so nothing was written — reinstall the kit. ${unreadable.error}`);
423
+ if (kept && !dryRun) return ensureKeptIndex({ cwd, kitRoot, drive, spawn, read, lstat, deps, hook });
424
+ const dryNote = kept ? [buildKeptDryRunNote(hook)] : [];
334
425
  if (dryRun) {
335
426
  const check = drive('--check-index');
336
427
  if (check.error) return unlaunchable(check);
337
428
  const out = bothStreams(check);
338
- if (check.status === 0 && PROBE_FRESH.test(out)) return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing would be written`);
339
- if (check.status === 1 && PROBE_STALE.test(out)) return ok('index', 'would-regenerate', `${INDEX_REL}: missing or stale — would be written from the source frontmatter`);
340
- return loud('index', 'index-probe-failed', `${INDEX_REL}: the freshness probe answered neither fresh nor stale (exited ${describeExit(check)}), so nothing was written. ${out}`);
429
+ if (check.status === 0 && PROBE_FRESH.test(out)) return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing would be written`, ...dryNote);
430
+ if (check.status === 1 && PROBE_STALE.test(out)) return ok('index', 'would-regenerate', `${INDEX_REL}: missing or stale — would be written from the source frontmatter`, ...dryNote);
431
+ return loud('index', 'index-probe-failed', `${INDEX_REL}: the freshness probe answered neither fresh nor stale (exited ${describeExit(check)}), so nothing was written. ${out}`, ...dryNote);
341
432
  }
342
433
 
343
434
  const run = drive('--ensure-index');
@@ -3,8 +3,8 @@
3
3
  // a FIXED order the measured coupling dictates:
4
4
  // 1. the reader pair scripts/spec-schema.mjs + .test.mjs — created when absent; since 2a
5
5
  // also REFRESHED when the deployed bytes are a body a release shipped (script-priors.mjs), a
6
- // custom body preserved verbatim (brief D1 shipped it create-only; the 4.6.x bodies joining the
7
- // catalog is what keeps those deployments out of the stranded-custom cell);
6
+ // custom body preserved verbatim until the user's yes runs the printed replace line
7
+ // (--replace-custom <digest>, spec docs-navigator);
8
8
  // 2. the checker pair scripts/check-docs-size.mjs + .test.mjs — the same prior-refresh lane, and
9
9
  // only behind a reader pair this run leaves byte-current;
10
10
  // 3. the store root docs/ai/specs/index.md — seeded (placeholders rendered)
@@ -17,16 +17,18 @@
17
17
  // The state table (enumerate by PROOF, never by exclusion): every file is classified into exactly one
18
18
  // of current | prior | custom | absent | wrong-kind before anything is written, each write admits
19
19
  // through ONE conjunction of those facts (decideWrites), and every other cell is a stated refusal —
20
- // a custom file of either pair preserves itself AND withholds every write that depends on it. Writes
21
- // run in order and each is idempotent, so a run that stopped partway converges on the next run.
20
+ // a custom file of either pair preserves itself AND withholds every write that depends on it, and the
21
+ // ONE replace line names every custom file; on the yes, a replace run whose digest still matches
22
+ // rewrites them. Writes run in order and each is idempotent, so a stopped run converges on the next.
22
23
  //
23
24
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
24
25
 
25
26
  import { readFileSync, lstatSync } from 'node:fs';
26
27
  import { join } from 'node:path';
27
28
  import { writeContainedFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
28
- import { classifyDeployedScript } from './script-priors.mjs';
29
+ import { classifyDeployedScript, digestOf } from './script-priors.mjs';
29
30
  import { composeFailure, composeOutcome, nodeEvidenceRefusal, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
31
+ import { shellQuoteArg } from './repo-lex.mjs';
30
32
 
31
33
  const OP = 'specs';
32
34
  const SCRIPTS_DIR = 'scripts';
@@ -59,9 +61,14 @@ const surveyScript = ({ cwd, kitRoot, name, read, lstat }) => {
59
61
  const probe = probeSeedTarget(join(cwd, SCRIPTS_DIR, name), lstat);
60
62
  if (probe.wrongKind) return { rel, name, state: 'wrong-kind', wrongKind: probe.wrongKind, bundle };
61
63
  if (!probe.present) return { rel, name, state: 'absent', bundle };
62
- return { rel, name, state: classifyDeployedScript(read(join(cwd, SCRIPTS_DIR, name)), name, bundle), bundle };
64
+ const bytes = read(join(cwd, SCRIPTS_DIR, name));
65
+ return { rel, name, state: classifyDeployedScript(bytes, name, bundle), bundle, bytes };
63
66
  };
64
67
 
68
+ // The custom set's digest binds the set AND the bytes: the rel-sorted `<rel> <sha256>` lines, LF-joined.
69
+ export const customSetDigest = (files) =>
70
+ digestOf([...files].sort((a, b) => (a.rel < b.rel ? -1 : 1)).map((f) => `${f.rel} ${digestOf(f.bytes)}`).join('\n'));
71
+
65
72
  const surveyStoreRoot = (cwd, lstat) => {
66
73
  const probe = probeSeedTarget(join(cwd, STORE_ROOT_REL), lstat);
67
74
  if (probe.wrongKind) return { rel: STORE_ROOT_REL, state: 'wrong-kind', wrongKind: probe.wrongKind };
@@ -73,21 +80,25 @@ const surveyStoreRoot = (cwd, lstat) => {
73
80
  // Reader seeds are create-only and always admitted. A REFRESH overwrites bytes, so it runs only
74
81
  // inside a pair whose every file is proven refreshable (current | prior | absent) — one custom file
75
82
  // withholds its whole pair; the checker lane (its seeds included) and the store additionally wait
76
- // for a reader pair this run leaves byte-current (seeded + refreshed = current).
83
+ // for a reader pair this run leaves byte-current (seeded + refreshed = current). Under the user's
84
+ // replace yes a custom file is refreshable too: it is rewritten to the bundled body.
77
85
  const PAIR_ELIGIBLE = new Set(['current', 'prior', 'absent']);
86
+ const REPLACE_ELIGIBLE = new Set([...PAIR_ELIGIBLE, 'custom']);
78
87
 
79
- export const decideWrites = ({ reader, checker, store }) => {
80
- const readerEligible = reader.every((f) => PAIR_ELIGIBLE.has(f.state));
81
- const checkerEligible = readerEligible && checker.every((f) => PAIR_ELIGIBLE.has(f.state));
88
+ export const decideWrites = ({ reader, checker, store }, replace = false) => {
89
+ const eligible = replace ? REPLACE_ELIGIBLE : PAIR_ELIGIBLE;
90
+ const refreshable = replace ? ['prior', 'custom'] : ['prior'];
91
+ const readerEligible = reader.every((f) => eligible.has(f.state));
92
+ const checkerEligible = readerEligible && checker.every((f) => eligible.has(f.state));
82
93
  const writes = [];
83
94
  for (const f of reader) {
84
95
  if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
85
- if (readerEligible && f.state === 'prior') writes.push({ kind: 'refresh', file: f });
96
+ if (readerEligible && refreshable.includes(f.state)) writes.push({ kind: 'refresh', file: f });
86
97
  }
87
98
  if (checkerEligible) {
88
99
  for (const f of checker) {
89
100
  if (f.state === 'absent') writes.push({ kind: 'seed', file: f });
90
- if (f.state === 'prior') writes.push({ kind: 'refresh', file: f });
101
+ if (refreshable.includes(f.state)) writes.push({ kind: 'refresh', file: f });
91
102
  }
92
103
  }
93
104
  if (checkerEligible && store.state === 'absent') writes.push({ kind: 'store' });
@@ -103,7 +114,10 @@ export const decideWrites = ({ reader, checker, store }) => {
103
114
  const LEFT_FOR = { withheld: 'it waits on the pair named above', stopped: 'the run stopped before it' };
104
115
  const SCRIPT_LINES = {
105
116
  current: () => 'already the bundled body — nothing written',
106
- custom: () => "carries a body this kit did not ship as current — preserved verbatim; the writes that depend on it wait until the pair matches the bundled scripts (copy them by hand from the kit's references/scripts/ when convenient, then re-run the upgrade)",
117
+ custom: (fate) => ({
118
+ written: "carried an edited body — replaced by the bundled one on the user's yes",
119
+ stopped: 'carries an edited body — not replaced; the run stopped before it',
120
+ })[fate] ?? "carries a body this kit did not ship as current — preserved verbatim; the writes that depend on it wait until it is replaced on the user's yes (the replace line below)",
107
121
  absent: (fate) => ({
108
122
  written: 'copied from the bundled scripts',
109
123
  stood: 'appeared while this run was seeding it — the bundled body stands',
@@ -120,10 +134,13 @@ const STORE_LINES = {
120
134
  written: "created from the bundled template (today's date rendered)",
121
135
  stood: 'appeared while this run was seeding it — the existing file stands',
122
136
  would: 'absent — would be created from the bundled template',
123
- withheld: "not seeded — an older or edited checker renders the spec store row by row and reds the hook's index check, so the store root waits for a current checker pair",
137
+ withheld: (custom) => `not seeded — it waits on the edited ${custom.join(', ')} (a body this kit did not ship): an older or edited checker renders the spec store row by row and reds the hook's index check, so the store root waits for a current checker pair`,
124
138
  stopped: 'absent — not created; the run stopped before it',
125
139
  };
126
- const storeLine = (store, fate) => `${STORE_ROOT_REL}: ${STORE_LINES[store.state === 'present' ? 'present' : fate]}`;
140
+ const storeLine = (store, fate, custom) => {
141
+ const line = STORE_LINES[store.state === 'present' ? 'present' : fate];
142
+ return `${STORE_ROOT_REL}: ${typeof line === 'function' ? line(custom) : line}`;
143
+ };
127
144
  const relOf = (write) => (write.kind === 'store' ? STORE_ROOT_REL : write.file.rel);
128
145
 
129
146
  // The fate of every path for the lines: a write that ran carries its result, an admitted write the
@@ -138,10 +155,17 @@ const composeLines = ({ reader, checker, store, writes, fates, withheld, stopped
138
155
  const fate = (rel) => fateOf(rel, { writes, fates, withheld, stopped });
139
156
  const lines = [...reader, ...checker].map((f) => scriptLine(f, fate(f.rel)));
140
157
  const storeFate = fate(STORE_ROOT_REL);
141
- if (store.state === 'present' || storeFate !== 'none') lines.push(storeLine(store, storeFate));
158
+ const custom = [...reader, ...checker].filter((f) => f.state === 'custom').map((f) => f.rel);
159
+ if (store.state === 'present' || storeFate !== 'none') lines.push(storeLine(store, storeFate, custom));
142
160
  return lines;
143
161
  };
144
162
 
163
+ // The ONE replace line: the exact command, its condition first, both paths shell-quoted.
164
+ const replaceLineFor = (custom, kitRoot, cwd) => (custom.length === 0 ? [] : [
165
+ `to replace the edited files named above, run this only on the user's yes — the bundled bodies overwrite their edits: node ${shellQuoteArg(join(kitRoot, 'tools', 'ensure-configs.mjs'))} --reconcile --replace-custom ${customSetDigest(custom)} --cwd ${shellQuoteArg(cwd)}`,
166
+ ]);
167
+ const RACE_LINE = 'the edited files changed since the replace line was printed (another set or other bytes), so nothing was written — ask the user again with the fresh replace line below';
168
+
145
169
  // A per-file loop can stop partway; the run says which files already landed — never "nothing happened".
146
170
  const partialNote = (count) =>
147
171
  (count > 0 ? [`the writes stopped PARTWAY — the ${count} file(s) named above were already written and are NOT rolled back`] : []);
@@ -156,7 +180,7 @@ const renderStoreRoot = (kitRoot, read, today) => {
156
180
  return body;
157
181
  };
158
182
 
159
- export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
183
+ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {}, replaceCustom }) => {
160
184
  const lstat = deps.lstat ?? lstatSync;
161
185
  const read = deps.readFile ?? readFileSync;
162
186
  const refusal = nodeEvidenceRefusal(OP, cwd, lstat);
@@ -172,7 +196,15 @@ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
172
196
  if (f.state === 'wrong-kind') return loud('wrong-node-kind', `${f.rel}: exists but is ${f.wrongKind} — nothing was read or written; resolve it by hand, then re-run`);
173
197
  }
174
198
 
175
- const { writes, withheld } = decideWrites({ reader, checker, store });
199
+ const custom = [...reader, ...checker].filter((f) => f.state === 'custom');
200
+ const replace = replaceCustom !== undefined && custom.length > 0;
201
+ const replaceLine = replaceLineFor(custom, kitRoot, cwd);
202
+ if (replace && replaceCustom !== customSetDigest(custom)) {
203
+ const asFound = decideWrites({ reader, checker, store });
204
+ return loud('race-unresolved', RACE_LINE, ...composeLines({ reader, checker, store, ...asFound, fates: new Map(), stopped: true }), ...replaceLine);
205
+ }
206
+ const { writes, withheld } = decideWrites({ reader, checker, store }, replace);
207
+ const tail = replace ? [] : replaceLine;
176
208
  const kinds = new Set(writes.map((w) => w.kind));
177
209
  // ONE token by precedence — a write that happened (or, dry, would happen) always outranks the
178
210
  // preserved-custom report, which names a run that wrote nothing behind an edited pair.
@@ -184,14 +216,14 @@ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
184
216
  const fates = new Map();
185
217
  const linesNow = (stopped) => composeLines({ reader, checker, store, writes, fates, withheld, stopped });
186
218
  const writtenCount = () => [...fates.values()].filter((fate) => fate === 'written').length;
187
- if (dryRun) return ok(tokenFor(kinds.has('seed') || kinds.has('store'), kinds.has('refresh')), linesNow(false));
219
+ if (dryRun) return ok(tokenFor(kinds.has('seed') || kinds.has('store'), kinds.has('refresh')), [...linesNow(false), ...tail]);
188
220
 
189
221
  let storeBody = null;
190
222
  if (kinds.has('store')) {
191
223
  try {
192
224
  storeBody = renderStoreRoot(kitRoot, read, deps.today ?? todayIso());
193
225
  } catch (err) {
194
- return loud('template-unreadable', `${STORE_ROOT_REL}: ${causeOf(err)}`, ...linesNow(true));
226
+ return loud('template-unreadable', `${STORE_ROOT_REL}: ${causeOf(err)}`, ...linesNow(true), ...tail);
195
227
  }
196
228
  }
197
229
  const writeOne = ({ kind, file }) => {
@@ -209,14 +241,14 @@ export const ensureSpecs = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
209
241
  try {
210
242
  result = writeOne(write);
211
243
  } catch (err) {
212
- return loud('write-refused', `${rel}: ${causeOf(err)}`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
244
+ return loud('write-refused', `${rel}: ${causeOf(err)}`, ...linesNow(true), ...notes, ...partialNote(writtenCount()), ...tail);
213
245
  }
214
246
  if (write.kind !== 'refresh' && !result.created && !stoodCurrent(write)) {
215
- return loud('race-unresolved', `${rel}: appeared while this run was seeding it and does not carry the bundled body — something is writing there underneath this run; nothing further written, re-run when the tree is settled`, ...linesNow(true), ...notes, ...partialNote(writtenCount()));
247
+ return loud('race-unresolved', `${rel}: appeared while this run was seeding it and does not carry the bundled body — something is writing there underneath this run; nothing further written, re-run when the tree is settled`, ...linesNow(true), ...notes, ...partialNote(writtenCount()), ...tail);
216
248
  }
217
249
  fates.set(rel, write.kind !== 'refresh' && !result.created ? 'stood' : 'written');
218
250
  notes.push(...tmpNote(rel, result.tmpLeftBehind));
219
251
  }
220
252
  const landed = (kind) => writes.some((w) => w.kind === kind && fates.get(relOf(w)) === 'written');
221
- return ok(tokenFor(landed('seed') || landed('store'), landed('refresh')), [...linesNow(false), ...notes]);
253
+ return ok(tokenFor(landed('seed') || landed('store'), landed('refresh')), [...linesNow(false), ...notes, ...tail]);
222
254
  };
@@ -16,7 +16,8 @@ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', '
16
16
  // Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
17
17
  // walks this set), and each has exactly one `would-` counterpart below. `refreshed` is the spec-layer
18
18
  // ensure's pair refresh (reader or checker) — a deployed script on a body a release shipped,
19
- // rewritten to the bundled one (a custom body is never refreshed).
19
+ // rewritten to the bundled one; a custom body is rewritten only by the replace run the user's yes
20
+ // starts (--replace-custom), never by a plain run.
20
21
  // `re-derived` is the executor ensure rewriting a placed executor whose bytes differ from the body derived from docs/ai/vehicles.json.
21
22
  export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated', 're-derived']);
22
23
  export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate', 'would-re-derive']);