@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.
- package/CHANGELOG.md +84 -0
- package/README.md +2 -2
- package/SKILL.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/gates.md +1 -1
- package/references/modes/hook.md +6 -4
- package/references/modes/recommendations.md +6 -6
- package/references/modes/state-block-guard.md +51 -70
- package/references/modes/upgrade.md +6 -6
- package/references/scripts/check-docs-size-cli.test.mjs +45 -0
- package/references/scripts/check-docs-size-ensure.test.mjs +28 -0
- package/references/scripts/check-docs-size.mjs +6 -4
- package/tools/apply-danger-check.mjs +30 -13
- package/tools/commands.mjs +2 -2
- package/tools/ensure-configs.mjs +55 -22
- package/tools/ensure-ops.mjs +99 -8
- package/tools/ensure-specs.mjs +55 -23
- package/tools/ensure-vocabulary.mjs +2 -1
- package/tools/gate-hook.mjs +81 -40
- package/tools/recommendations.mjs +16 -36
- package/tools/script-priors.mjs +3 -2
- package/tools/state-block-hook.mjs +284 -0
- package/tools/upgrade-runlist.mjs +1 -1
- package/tools/write-lanes.mjs +12 -7
package/tools/ensure-ops.mjs
CHANGED
|
@@ -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:
|
|
301
|
-
// nothing to
|
|
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');
|
package/tools/ensure-specs.mjs
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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
|
|
21
|
-
//
|
|
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
|
-
|
|
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
|
|
81
|
-
const
|
|
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
|
|
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
|
|
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: () =>
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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']);
|