@sabaiway/agent-workflow-kit 5.5.0 → 5.7.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 +122 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +7 -1
- package/references/modes/doc-parity.md +1 -1
- package/references/modes/gates.md +20 -4
- package/references/modes/procedures.md +2 -0
- package/references/modes/recommendations.md +4 -1
- package/references/modes/review-state.md +1 -1
- package/references/modes/setup.md +18 -2
- package/references/modes/upgrade.md +38 -18
- package/references/modes/velocity.md +1 -0
- package/references/scripts/migrate-gates-branches.test.mjs +146 -1
- package/references/scripts/migrate-gates.mjs +295 -60
- package/references/scripts/migrate-gates.test.mjs +206 -14
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/gates.json +1 -1
- package/tools/ack-write.mjs +20 -11
- package/tools/atomic-write.mjs +71 -18
- package/tools/checker-claim.mjs +100 -0
- package/tools/coverage-producer.mjs +43 -6
- package/tools/direct-run.mjs +76 -0
- package/tools/doc-parity.mjs +34 -3
- package/tools/engine-source.mjs +12 -8
- package/tools/ensure-configs.mjs +141 -0
- package/tools/ensure-ops.mjs +284 -0
- package/tools/ensure-vocabulary.mjs +71 -0
- package/tools/flow-check-cores.mjs +253 -0
- package/tools/flow-check-git-lane.mjs +56 -0
- package/tools/flow-check-rungs.mjs +330 -0
- package/tools/flow-check.mjs +23 -611
- package/tools/gates-declaration.mjs +36 -11
- package/tools/gates-init.mjs +140 -25
- package/tools/hide-footprint.mjs +21 -3
- package/tools/lens-region.mjs +74 -23
- package/tools/orchestration-config.mjs +5 -3
- package/tools/orchestration-write.mjs +7 -0
- package/tools/procedures.mjs +64 -5
- package/tools/recommendations.mjs +384 -34
- package/tools/refresh-parity.mjs +263 -0
- package/tools/run-gates.mjs +8 -5
- package/tools/setup-backends.mjs +88 -77
- package/tools/source-size-check.mjs +310 -0
- package/tools/source-size-config.mjs +244 -0
- package/tools/source-size-core.mjs +59 -0
- package/tools/source-size-gate-cmd.mjs +27 -0
- package/tools/source-size-judge.mjs +114 -0
- package/tools/source-size-refusal.mjs +70 -0
- package/tools/source-size-report.mjs +254 -0
- package/tools/source-size-scope.mjs +145 -0
- package/tools/tracked-tree-census.mjs +102 -0
- package/tools/upgrade-runlist.mjs +92 -0
- package/tools/velocity-profile.mjs +24 -3
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// ensure-ops.mjs — the FOUR upgrade ensure operations, one function each, behind one shared outcome
|
|
2
|
+
// shape. The CLI that orders and runs them is ensure-configs.mjs; this module owns what each ensure
|
|
3
|
+
// DOES and, more importantly, what it is allowed to CLAIM.
|
|
4
|
+
//
|
|
5
|
+
// Why they became code at all: `references/modes/upgrade.md` prescribed each of them as prose an agent
|
|
6
|
+
// was expected to carry out by hand ("create it from the template if missing", "copy the pair from
|
|
7
|
+
// references/scripts/ if missing"). A prescribed state-changing operation with no runnable command is
|
|
8
|
+
// a step that silently varies per session — the feedback item this phase answers.
|
|
9
|
+
//
|
|
10
|
+
// The invariants every op holds (they are what the tests pin):
|
|
11
|
+
// • CREATE-ONLY seeds. A seed never clobbers: the write is the link-based create-only arm of
|
|
12
|
+
// atomic-write.mjs, so a file that appears between the probe and the write survives byte-for-byte
|
|
13
|
+
// and the op says `already-present` rather than reporting a write it did not do.
|
|
14
|
+
// • The DECISION lives where it already lived. The orchestration `_README` refresh asks
|
|
15
|
+
// orchestration-config.mjs (refreshReadme / the known-prior canonical set) and writes through
|
|
16
|
+
// orchestration-write.mjs — the file's one writer. Nothing here re-derives either.
|
|
17
|
+
// • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node`
|
|
18
|
+
// names the missing package.json; an ADR-layout read that fails is `adr-layout-unverifiable` and
|
|
19
|
+
// writes NOTHING (the STRICT survey, fail-closed — the lenient status wrapper reads an unreadable
|
|
20
|
+
// tree as `none`, which here would mean seeding a rotator beside a store nobody could inspect).
|
|
21
|
+
// • A failed op is a non-zero signal, never a line that reads like success.
|
|
22
|
+
//
|
|
23
|
+
// Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
|
|
24
|
+
|
|
25
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { CANON_README, CONFIG_REL, SEED_CONFIG, loadConfig, normalizeCanonical, refreshReadme } from './orchestration-config.mjs';
|
|
28
|
+
import { seedConfig, writeConfig } from './orchestration-write.mjs';
|
|
29
|
+
import { lstatNoFollow, writeDocsAiFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
|
|
30
|
+
import { GATES_REL } from './gates-declaration.mjs';
|
|
31
|
+
import { AUTONOMY_REL } from './autonomy-config.mjs';
|
|
32
|
+
import { surveyAdrLayoutStrict } from './family-registry.mjs';
|
|
33
|
+
import { ENSURE_TOKENS, FAILURE_CAUSES, SEED_SCRIPTS } from './ensure-vocabulary.mjs';
|
|
34
|
+
|
|
35
|
+
// The closed vocabulary lives in its own PURE leaf so the read-only doc-parity lint can bind the
|
|
36
|
+
// relayed token set without importing this module's writer graph. Re-exported here because every
|
|
37
|
+
// consumer of the ops also speaks the vocabulary.
|
|
38
|
+
export {
|
|
39
|
+
ENSURE_OPS,
|
|
40
|
+
ENSURE_TOKENS,
|
|
41
|
+
DRY_RUN_TOKENS,
|
|
42
|
+
FAILURE_CAUSES,
|
|
43
|
+
RELAYED_ENSURE_TOKENS,
|
|
44
|
+
SEED_SCRIPTS,
|
|
45
|
+
WRITE_TOKENS,
|
|
46
|
+
} from './ensure-vocabulary.mjs';
|
|
47
|
+
|
|
48
|
+
const PACKAGE_JSON = 'package.json';
|
|
49
|
+
const SCRIPTS_DIR = 'scripts';
|
|
50
|
+
|
|
51
|
+
const outcome = (op, token, lines, failed = false) => {
|
|
52
|
+
if (!ENSURE_TOKENS.includes(token)) {
|
|
53
|
+
throw new Error(`[agent-workflow-kit] unknown ensure outcome "${token}" — the token vocabulary is closed`);
|
|
54
|
+
}
|
|
55
|
+
return { op, token, failed, lines };
|
|
56
|
+
};
|
|
57
|
+
const ok = (op, token, ...lines) => outcome(op, token, lines, false);
|
|
58
|
+
// A LOUD failure the doc gives its OWN token (today: malformed-preserved — the file is preserved, and
|
|
59
|
+
// that is exactly what the reader must be told).
|
|
60
|
+
const loudToken = (op, token, ...lines) => outcome(op, token, lines, true);
|
|
61
|
+
// Every other LOUD failure: the token is always `failed`, the closed cause word opens the first line.
|
|
62
|
+
const loud = (op, cause, ...lines) => {
|
|
63
|
+
if (!FAILURE_CAUSES.includes(cause)) {
|
|
64
|
+
throw new Error(`[agent-workflow-kit] unknown ensure failure cause "${cause}" — the cause vocabulary is closed`);
|
|
65
|
+
}
|
|
66
|
+
return outcome(op, 'failed', [`${cause} — ${lines[0]}`, ...lines.slice(1)], true);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// The closed-vocabulary DOOR, exported as a seam so the refusal itself is testable: EVERY outcome in
|
|
70
|
+
// this module is composed through one of these two, and a word outside the closed sets throws here
|
|
71
|
+
// instead of reaching a caller that has no idea how to relay it.
|
|
72
|
+
export const composeOutcome = outcome;
|
|
73
|
+
export const composeFailure = loud;
|
|
74
|
+
|
|
75
|
+
const causeOf = (err) => String((err && err.message) || err);
|
|
76
|
+
|
|
77
|
+
// The CLI's catch-all, here rather than there so EVERY outcome in the system — including the one
|
|
78
|
+
// nobody planned for — is composed through the closed vocabulary. A throw that reached here is
|
|
79
|
+
// `unexpected-error`: the cause word is still one of the closed set, and the thrown message follows
|
|
80
|
+
// it (a bare `${op}: …` line would be the one failure in the system that names no cause).
|
|
81
|
+
export const failedOutcome = (op, err) => loud(op, 'unexpected-error', `${op}: ${causeOf(err)}`);
|
|
82
|
+
|
|
83
|
+
// `already-present` must mean a FILE is there. An lstat that merely finds SOMETHING would let a
|
|
84
|
+
// directory or a symlink named gates.json report a green ensure while the declaration the project
|
|
85
|
+
// needs does not exist — an exit 0 proving nothing (both review backends found this).
|
|
86
|
+
const NODE_KIND = (st) => (st.isSymbolicLink() ? 'a symlink' : st.isDirectory() ? 'a directory' : 'not a regular file');
|
|
87
|
+
const probeSeedTarget = (abs, lstat) => {
|
|
88
|
+
const st = lstatNoFollow(abs, lstat);
|
|
89
|
+
if (st === null) return { present: false };
|
|
90
|
+
return st.isFile() ? { present: true } : { present: true, wrongKind: NODE_KIND(st) };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// A leftover temp file never fails a completed write, and is never silent either.
|
|
94
|
+
const tmpNote = (rel, tmpLeftBehind) =>
|
|
95
|
+
(tmpLeftBehind ? [`${rel}: the write stands, but its temp file could not be removed — delete it by hand: ${tmpLeftBehind}`] : []);
|
|
96
|
+
|
|
97
|
+
// ── 1. orchestration.json — seed, or refresh ONLY a still-canonical onboarding note ────────────────
|
|
98
|
+
|
|
99
|
+
// Which no-change outcome is it? refreshReadme returns `changed: false` for two very different trees:
|
|
100
|
+
// a note that already IS the current canonical, and a note the user rewrote. Reporting both as
|
|
101
|
+
// "already current" would claim the second is something it is not.
|
|
102
|
+
const unchangedNoteToken = (config) =>
|
|
103
|
+
normalizeCanonical(config?._README ?? '') === normalizeCanonical(CANON_README) ? 'already-current' : 'customized-preserved';
|
|
104
|
+
|
|
105
|
+
const applyNoteRefresh = (cwd, config, dryRun, deps) => {
|
|
106
|
+
const { config: next, changed } = refreshReadme(config);
|
|
107
|
+
if (!changed) {
|
|
108
|
+
const token = unchangedNoteToken(config);
|
|
109
|
+
return ok(
|
|
110
|
+
'orchestration',
|
|
111
|
+
token,
|
|
112
|
+
token === 'already-current'
|
|
113
|
+
? `${CONFIG_REL}: the onboarding note is the current canonical — nothing written`
|
|
114
|
+
: `${CONFIG_REL}: the onboarding note carries your own wording — preserved verbatim, nothing written`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (dryRun) return ok('orchestration', 'would-refresh-note', `${CONFIG_REL}: the onboarding note matches a previous canonical and would be refreshed (every recipe you set is kept)`);
|
|
118
|
+
writeConfig(cwd, next, deps);
|
|
119
|
+
return ok('orchestration', 'note-refreshed', `${CONFIG_REL}: the onboarding note was refreshed to the current canonical (every recipe you set is kept)`);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// A load failure is not automatically "malformed": a file that VANISHED between the reader's own
|
|
123
|
+
// lstat and its read surfaces as unreadable, and calling that preserved-and-malformed would state
|
|
124
|
+
// two things this run did not observe. Probe once more and classify by what is there NOW.
|
|
125
|
+
const loadFailureOutcome = (cwd, err, lstat, whenSeeding) => {
|
|
126
|
+
if (lstatNoFollow(join(cwd, CONFIG_REL), lstat) === null) {
|
|
127
|
+
return loud('orchestration', 'race-unresolved', `${CONFIG_REL}: could not be read and is not there now — something is creating and removing it underneath this run; nothing written, re-run when the tree is settled`);
|
|
128
|
+
}
|
|
129
|
+
const where = whenSeeding ? 'appeared while this run was seeding it, and ' : '';
|
|
130
|
+
return loudToken('orchestration', 'malformed-preserved', `${CONFIG_REL}: ${where}could not be read as the config it must be — preserved untouched, nothing written. ${causeOf(err)}`);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const ensureOrchestration = ({ cwd, dryRun = false, deps = {} }) => {
|
|
134
|
+
const read = deps.readFile ?? readFileSync;
|
|
135
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
136
|
+
// The KIND comes before the content: a symlink pointing at valid JSON parses fine and would report
|
|
137
|
+
// `already-current` over a file this ensure would refuse to write through — an exit 0 that proves
|
|
138
|
+
// nothing about the config the project actually has.
|
|
139
|
+
const kind = probeSeedTarget(join(cwd, CONFIG_REL), lstat);
|
|
140
|
+
if (kind.wrongKind) {
|
|
141
|
+
return loud('orchestration', 'wrong-node-kind', `${CONFIG_REL}: exists but is ${kind.wrongKind} — nothing was read or written; resolve it by hand, then re-run`);
|
|
142
|
+
}
|
|
143
|
+
let loaded;
|
|
144
|
+
try {
|
|
145
|
+
loaded = loadConfig(cwd, read, lstat);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// Malformed / unreadable: preserved untouched, and LOUD — clobbering a file we cannot parse would
|
|
148
|
+
// destroy hand-authored configuration to fix a note.
|
|
149
|
+
return loadFailureOutcome(cwd, err, lstat, false);
|
|
150
|
+
}
|
|
151
|
+
if (loaded.config !== null) return applyNoteRefresh(cwd, loaded.config, dryRun, deps);
|
|
152
|
+
if (dryRun) return ok('orchestration', 'would-seed', `${CONFIG_REL}: absent — would be created from the canonical seed`);
|
|
153
|
+
|
|
154
|
+
const { created, tmpLeftBehind } = seedConfig(cwd, SEED_CONFIG, deps);
|
|
155
|
+
const seedNote = tmpNote(CONFIG_REL, tmpLeftBehind);
|
|
156
|
+
if (created) return ok('orchestration', 'seeded', `${CONFIG_REL}: created from the canonical seed`, ...seedNote);
|
|
157
|
+
// It appeared between the probe and the write. Nothing was overwritten; read it once more and
|
|
158
|
+
// report what it now IS, rather than a claim about the file we did not write.
|
|
159
|
+
let second;
|
|
160
|
+
try {
|
|
161
|
+
second = loadConfig(cwd, read, lstat);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return loadFailureOutcome(cwd, err, lstat, true);
|
|
164
|
+
}
|
|
165
|
+
if (second.config === null) {
|
|
166
|
+
return loud('orchestration', 'race-unresolved', `${CONFIG_REL}: something is creating and removing this file underneath this run — nothing written; re-run when the tree is settled`, ...seedNote);
|
|
167
|
+
}
|
|
168
|
+
const refreshed = applyNoteRefresh(cwd, second.config, dryRun, deps);
|
|
169
|
+
return { ...refreshed, lines: [...refreshed.lines, ...seedNote] };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ── 2/3. gates.json + autonomy.json — seed-if-missing, existing file preserved byte-for-byte ───────
|
|
173
|
+
|
|
174
|
+
const seedFromTemplate = ({ op, rel, template, noun, cwd, kitRoot, dryRun, deps }) => {
|
|
175
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
176
|
+
const read = deps.readFile ?? readFileSync;
|
|
177
|
+
const probe = probeSeedTarget(join(cwd, rel), lstat);
|
|
178
|
+
if (probe.wrongKind) {
|
|
179
|
+
return loud(op, 'wrong-node-kind', `${rel}: exists but is ${probe.wrongKind} — nothing was written, and this is NOT a usable declaration; resolve it by hand, then re-run`);
|
|
180
|
+
}
|
|
181
|
+
if (probe.present) return ok(op, 'already-present', `${rel}: already present — preserved byte-for-byte, nothing written`);
|
|
182
|
+
if (dryRun) return ok(op, 'would-seed', `${rel}: absent — would be created from the bundled template`);
|
|
183
|
+
let body;
|
|
184
|
+
try {
|
|
185
|
+
body = String(read(join(kitRoot, 'references', 'templates', template), 'utf8'));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return loud(op, 'template-unreadable', `${rel}: the bundled template could not be read, so nothing was written — reinstall the kit. ${causeOf(err)}`);
|
|
188
|
+
}
|
|
189
|
+
const { created, tmpLeftBehind } = writeDocsAiFileAtomic(cwd, rel, body, deps, { noun, createOnly: true });
|
|
190
|
+
return created
|
|
191
|
+
? ok(op, 'seeded', `${rel}: created from the bundled template`, ...tmpNote(rel, tmpLeftBehind))
|
|
192
|
+
: ok(op, 'already-present', `${rel}: appeared while this run was seeding it — the existing file stands, byte-for-byte`, ...tmpNote(rel, tmpLeftBehind));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const ensureGates = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
|
|
196
|
+
seedFromTemplate({ op: 'gates', rel: GATES_REL, template: 'gates.json', noun: 'a gate declaration', cwd, kitRoot, dryRun, deps });
|
|
197
|
+
|
|
198
|
+
export const ensureAutonomy = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
|
|
199
|
+
seedFromTemplate({ op: 'autonomy', rel: AUTONOMY_REL, template: 'autonomy.json', noun: 'an autonomy policy', cwd, kitRoot, dryRun, deps });
|
|
200
|
+
|
|
201
|
+
// ── 4. scripts/ — the ADR-cascade enforcement pairs, detect-first ──────────────────────────────────
|
|
202
|
+
|
|
203
|
+
// A project with no package.json at its root is not where Node enforcement scripts belong. Stated,
|
|
204
|
+
// never silent: the token names the evidence, and the three config ensures still run.
|
|
205
|
+
const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
|
|
206
|
+
|
|
207
|
+
const OLD_ADR_LAYOUTS = new Set(['old', 'old-unrotated']);
|
|
208
|
+
|
|
209
|
+
// The scripts ensure copies file by file, so a failure PARTWAY leaves earlier copies in place. Saying
|
|
210
|
+
// so is the difference between a failure the reader can act on and one they read as "nothing happened"
|
|
211
|
+
// — the CLI's summary therefore claims nothing about writes, and this line states the truth per op.
|
|
212
|
+
const partialNote = (lines) => {
|
|
213
|
+
const copied = lines.filter((line) => line.includes(': copied from the bundled scripts')).length;
|
|
214
|
+
return copied > 0 ? [`the copying stopped PARTWAY — the ${copied} file(s) named above were already copied and are NOT rolled back`] : [];
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
|
|
218
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
219
|
+
const read = deps.readFile ?? readFileSync;
|
|
220
|
+
if (!isNodeProject(cwd, lstat)) {
|
|
221
|
+
return ok('scripts', 'skipped-no-node', `${SCRIPTS_DIR}/: no ${PACKAGE_JSON} at the project root — the seeded pairs are Node enforcement; nothing written`);
|
|
222
|
+
}
|
|
223
|
+
let layout;
|
|
224
|
+
try {
|
|
225
|
+
layout = surveyAdrLayoutStrict(cwd, deps);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return loud('scripts', 'adr-layout-unverifiable', `${SCRIPTS_DIR}/: the ADR-store layout could not be read, so nothing was written — a rotator seeded beside an un-migrated store reds the ADR gate. ${causeOf(err)}`);
|
|
228
|
+
}
|
|
229
|
+
if (OLD_ADR_LAYOUTS.has(layout)) {
|
|
230
|
+
return ok(
|
|
231
|
+
'scripts',
|
|
232
|
+
'old-adr-layout-migration-instructed',
|
|
233
|
+
`${SCRIPTS_DIR}/: this project is still on the older ADR layout (${layout}) — nothing written. Run the opt-in /agent-workflow-kit migrate-adr-store (it previews, and never commits); the seed lands on the next upgrade.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const lines = [];
|
|
237
|
+
let anyCreated = false;
|
|
238
|
+
for (const name of SEED_SCRIPTS) {
|
|
239
|
+
const rel = `${SCRIPTS_DIR}/${name}`;
|
|
240
|
+
const probe = probeSeedTarget(join(cwd, SCRIPTS_DIR, name), lstat);
|
|
241
|
+
if (probe.wrongKind) {
|
|
242
|
+
return loud('scripts', 'wrong-node-kind', `${rel}: exists but is ${probe.wrongKind} — that is not the enforcement script this run places`, ...lines, ...partialNote(lines));
|
|
243
|
+
}
|
|
244
|
+
if (probe.present) {
|
|
245
|
+
lines.push(`${rel}: already present — preserved, never overwritten`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (dryRun) {
|
|
249
|
+
lines.push(`${rel}: absent — would be copied from the bundled scripts`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
let body;
|
|
253
|
+
try {
|
|
254
|
+
body = String(read(join(kitRoot, 'references', 'scripts', name), 'utf8'));
|
|
255
|
+
} catch (err) {
|
|
256
|
+
return loud('scripts', 'bundle-unreadable', `${rel}: the bundled script could not be read — reinstall the kit. ${causeOf(err)}`, ...lines, ...partialNote(lines));
|
|
257
|
+
}
|
|
258
|
+
// The WRITE is caught here, not by the CLI's catch-all: a throw that escapes this loop would take
|
|
259
|
+
// the accumulated lines with it, and the run would report a failure without saying which files it
|
|
260
|
+
// had already copied.
|
|
261
|
+
let result;
|
|
262
|
+
try {
|
|
263
|
+
result = writeProjectFileCreateOnly(cwd, rel, body, deps, { noun: 'a seeded enforcement script' });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return loud('scripts', 'write-refused', `${rel}: ${causeOf(err)}`, ...lines, ...partialNote(lines));
|
|
266
|
+
}
|
|
267
|
+
anyCreated = anyCreated || result.created;
|
|
268
|
+
lines.push(result.created ? `${rel}: copied from the bundled scripts` : `${rel}: appeared while this run was seeding it — the existing file stands`);
|
|
269
|
+
lines.push(...tmpNote(rel, result.tmpLeftBehind));
|
|
270
|
+
}
|
|
271
|
+
if (dryRun) {
|
|
272
|
+
const wouldSeed = lines.some((line) => line.includes('would be copied'));
|
|
273
|
+
return outcome('scripts', wouldSeed ? 'would-seed' : 'already-present', lines, false);
|
|
274
|
+
}
|
|
275
|
+
return outcome('scripts', anyCreated ? 'seeded' : 'already-present', lines, false);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// The op table the CLI walks — name → implementation, in ENSURE_OPS order.
|
|
279
|
+
export const ENSURE_IMPLEMENTATIONS = Object.freeze({
|
|
280
|
+
orchestration: ensureOrchestration,
|
|
281
|
+
gates: ensureGates,
|
|
282
|
+
autonomy: ensureAutonomy,
|
|
283
|
+
scripts: ensureScripts,
|
|
284
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ensure-vocabulary.mjs — the CLOSED vocabulary of the upgrade ensures: which operations exist, which
|
|
2
|
+
// outcome tokens they may print, which cause words may open a failure line, and which of those the
|
|
3
|
+
// mode doc teaches.
|
|
4
|
+
//
|
|
5
|
+
// It is a PURE LEAF (no imports at all) for one reason: the read-only doc-parity lint binds the
|
|
6
|
+
// relayed token set into `references/modes/upgrade.md`, and reading a vocabulary must never drag the
|
|
7
|
+
// ensure implementation — and through it the orchestration WRITER and the atomic-write core — into a
|
|
8
|
+
// read-only tool's import graph. Vocabulary here, behaviour in ensure-ops.mjs.
|
|
9
|
+
|
|
10
|
+
// The FIXED order the CLI runs them in — the order references/modes/upgrade.md already prescribed.
|
|
11
|
+
export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'scripts']);
|
|
12
|
+
|
|
13
|
+
// Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
|
|
14
|
+
// walks this set), and each has exactly one `would-` counterpart below.
|
|
15
|
+
export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed']);
|
|
16
|
+
export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note']);
|
|
17
|
+
|
|
18
|
+
// The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
|
|
19
|
+
// token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
|
|
20
|
+
// of and the caller has no idea how to relay. EVERY operational failure prints the ONE token
|
|
21
|
+
// `failed` and names its CAUSE at the head of its detail line — a specific token would read as
|
|
22
|
+
// vocabulary the mode doc never taught (both review backends found exactly that).
|
|
23
|
+
export const ENSURE_TOKENS = Object.freeze([
|
|
24
|
+
...WRITE_TOKENS,
|
|
25
|
+
...DRY_RUN_TOKENS,
|
|
26
|
+
'already-current',
|
|
27
|
+
'already-present',
|
|
28
|
+
'customized-preserved',
|
|
29
|
+
'malformed-preserved',
|
|
30
|
+
'skipped-no-node',
|
|
31
|
+
'old-adr-layout-migration-instructed',
|
|
32
|
+
'failed',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// The closed CAUSE set — the word that opens a `failed` line. Closed for the same reason the tokens
|
|
36
|
+
// are: the doc promises "a failed line names its cause", and this is that list. `unexpected-error`
|
|
37
|
+
// is the honest cause for a throw nobody planned for: the failure is still named, never bare.
|
|
38
|
+
export const FAILURE_CAUSES = Object.freeze([
|
|
39
|
+
'race-unresolved',
|
|
40
|
+
'template-unreadable',
|
|
41
|
+
'bundle-unreadable',
|
|
42
|
+
'adr-layout-unverifiable',
|
|
43
|
+
'wrong-node-kind',
|
|
44
|
+
'write-refused',
|
|
45
|
+
'unexpected-error',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// The subset references/modes/upgrade.md enumerates, so the agent relaying an upgrade knows every
|
|
49
|
+
// outcome by name. doc-parity binds each of these into that doc: a reworded doc that drops one fails
|
|
50
|
+
// the check instead of silently teaching an outcome set the tool no longer has. The dry-run pair is
|
|
51
|
+
// deliberately outside it — upgrade never runs the preview.
|
|
52
|
+
export const RELAYED_ENSURE_TOKENS = Object.freeze([
|
|
53
|
+
'seeded',
|
|
54
|
+
'note-refreshed',
|
|
55
|
+
'already-current',
|
|
56
|
+
'customized-preserved',
|
|
57
|
+
'malformed-preserved',
|
|
58
|
+
'already-present',
|
|
59
|
+
'skipped-no-node',
|
|
60
|
+
'old-adr-layout-migration-instructed',
|
|
61
|
+
'failed',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// The four files the enforcement-script ensure seeds (AD-051's ADR cascade + the tokenizer it needs).
|
|
65
|
+
// Seed nothing else: the other tokenizer-era tests red beside an OLD archiver.
|
|
66
|
+
export const SEED_SCRIPTS = Object.freeze([
|
|
67
|
+
'archive-decisions.mjs',
|
|
68
|
+
'archive-decisions.test.mjs',
|
|
69
|
+
'markdown-blocks.mjs',
|
|
70
|
+
'markdown-blocks.test.mjs',
|
|
71
|
+
]);
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// flow-check-cores.mjs — the checker's decision cores over the FULL read-results of BOTH stores
|
|
2
|
+
// (flow + core evidence) and the tree context: store health, chain adoption and transition
|
|
3
|
+
// legality, prior-terminal references, worktree scoping, bookkeeping-delta custody and
|
|
4
|
+
// re-attestation, degrade-before-final ordering, armed base motion — and `decideFlowCheck`, the
|
|
5
|
+
// ONE composition every consumer reads. Split out of flow-check.mjs (baseline-practices tranche 1);
|
|
6
|
+
// the CLI, the store reads and the report render stay there.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no store IO and no git of its own — the base-motion inputs arrive as INJECTED resolvers,
|
|
9
|
+
// so flow-check-git-lane.mjs is never imported here. The evidence rungs live one module down
|
|
10
|
+
// (flow-check-rungs.mjs), which also owns the refusal vocabulary both halves share.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CHAIN_KIND, validateChainSequence, validateSupersessions, canonicalFlowDigest,
|
|
14
|
+
authoritativeFlowRecords,
|
|
15
|
+
} from './flow-record.mjs';
|
|
16
|
+
import {
|
|
17
|
+
walkChainState, validateOpenerReference, resolveRecordReference, isAuthoritativeReferenceTarget,
|
|
18
|
+
} from './flow-store.mjs';
|
|
19
|
+
import {
|
|
20
|
+
short, shellQuote, writerCommand,
|
|
21
|
+
collectUnansweredRedRefusals, collectDegradeCoverageRefusals, collectReceiptCoverageRefusals,
|
|
22
|
+
} from './flow-check-rungs.mjs';
|
|
23
|
+
|
|
24
|
+
// The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
|
|
25
|
+
// operand shapes: flag values ride the inline --flag='value' form and positionals follow a
|
|
26
|
+
// literal ` -- ` — one shape for EVERY id, so a leading-dash operand stays recoverable.
|
|
27
|
+
const parkRecovery = (planId) =>
|
|
28
|
+
`recovery (pasteable): ${writerCommand(`park -- ${shellQuote(planId)}`)}`;
|
|
29
|
+
|
|
30
|
+
// Arms in dependency order; the first failing arm reports, and integrityClean gates the caller's
|
|
31
|
+
// dependent arms (base motion) off a broken chain.
|
|
32
|
+
const planRefusals = (records, chain, planId, owner, advisories) => {
|
|
33
|
+
if (chain[0].purpose !== 'adoption') {
|
|
34
|
+
return { integrityClean: false, refusals: [`plan "${planId}": the chain has no content-digest-bound adoption record — a chain starts at adoption binding the plan content digest (#58); the store is append-only, so inspect how this chain was written`] };
|
|
35
|
+
}
|
|
36
|
+
const seq = validateChainSequence(chain);
|
|
37
|
+
if (!seq.ok) return { integrityClean: false, refusals: [`plan "${planId}": illegal transition — ${seq.reason}`] };
|
|
38
|
+
const state = walkChainState(chain);
|
|
39
|
+
const referenceIssues = [];
|
|
40
|
+
for (const { record } of state.openers) {
|
|
41
|
+
const check = validateOpenerReference(records.slice(0, records.indexOf(record)), record);
|
|
42
|
+
if (!check.ok) referenceIssues.push(`plan "${planId}": step-opening round (step "${record.stepId}") — ${check.reason}`);
|
|
43
|
+
}
|
|
44
|
+
for (const r of chain) {
|
|
45
|
+
if (r.purpose !== 'refresh') continue;
|
|
46
|
+
const prefix = records.slice(0, records.indexOf(r));
|
|
47
|
+
if (resolveRecordReference(prefix, r.refreshedRecord) === undefined) {
|
|
48
|
+
referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord does not match the store (no earlier record digests to ${short(r.refreshedRecord)}) — a re-attestation binds an existing record`);
|
|
49
|
+
} else if (!isAuthoritativeReferenceTarget(prefix, r.refreshedRecord)) {
|
|
50
|
+
referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord targets a superseded record — a re-attestation binds the authoritative latest record of its key (as of the refresh's own raw position)`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (referenceIssues.length > 0) return { integrityClean: false, refusals: referenceIssues };
|
|
54
|
+
const open = !state.completed && !state.parked && state.mode === 'in-step';
|
|
55
|
+
if (!open) return { integrityClean: true, refusals: [] };
|
|
56
|
+
if (chain[0].owner !== owner) {
|
|
57
|
+
advisories.push(`plan "${planId}": an OPEN chain owned by "${chain[0].owner}" (a foreign worktree) — advisory visibility only, never this tree's refusal (#57)`);
|
|
58
|
+
return { integrityClean: true, refusals: [] };
|
|
59
|
+
}
|
|
60
|
+
return { integrityClean: true, refusals: [`plan "${planId}" has an OPEN chain owned by this worktree ("${owner}"): step "${state.stepId}" is not converged — a commit closes only at a terminal. ${parkRecovery(planId)}`] };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// The custody arm verifies the PERSISTED proof against a bare declaration (#60): the masked
|
|
64
|
+
// recompute must equal fingerprintBefore, and every delta must be re-attested by a SUBSEQUENT
|
|
65
|
+
// chain refresh binding {refreshedRecord, fingerprintBefore = the delta's fingerprintAfter} (#45)
|
|
66
|
+
// — an earlier or fingerprint-mismatched record never satisfies (raw order decides). Satisfaction
|
|
67
|
+
// is STORE-GLOBAL: the locked delta shape carries no chain field, so WHICH chain's refresh cap
|
|
68
|
+
// the re-attestation consumes is the Plan-3 decideCheck arm (#61), not a Plan-2 refusal.
|
|
69
|
+
// The recovery lane needs the invoker's OWN OPEN chains: a refresh is a within-step record, so
|
|
70
|
+
// only such a chain can carry the re-attestation (and its refresh cap is what the mint consumes,
|
|
71
|
+
// #61). A command under a "pasteable" label is always CONCRETE — with no own open chain the
|
|
72
|
+
// recovery states the precondition instead of printing a placeholder command.
|
|
73
|
+
const ownOpenChainPlanIds = (records, owner) =>
|
|
74
|
+
[...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))].filter((planId) => {
|
|
75
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
76
|
+
if (chain[0].owner !== owner || chain[0].purpose !== 'adoption' || !validateChainSequence(chain).ok) return false;
|
|
77
|
+
const state = walkChainState(chain);
|
|
78
|
+
return !state.completed && !state.parked && state.mode === 'in-step';
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// The ONE per-record custody predicate (Plan 4 Phase 3, round-2 fold): the confinement equality
|
|
82
|
+
// + the mint-only invariants the record-level shape validation cannot see — shared by the
|
|
83
|
+
// gate-time walk below and the writer's terminal move validation, so a forged proof can neither
|
|
84
|
+
// pass the gates nor carry a terminal. → issue string | null.
|
|
85
|
+
export const deltaCustodyIssue = (r) => {
|
|
86
|
+
if (r.custodyProof.maskedFingerprint !== r.fingerprintBefore) {
|
|
87
|
+
return `the persisted custody proof does not prove confinement (maskedFingerprint ${short(r.custodyProof.maskedFingerprint)} ≠ fingerprintBefore ${short(r.fingerprintBefore)}) — a bare or tampered declaration never passes; re-mint through mintBookkeepingDelta`;
|
|
88
|
+
}
|
|
89
|
+
const proof = r.custodyProof;
|
|
90
|
+
const mintInvariant = !proof.tracked ? null
|
|
91
|
+
: proof.preClass !== 'present' ? 'a tracked path with an absent pre-state never mints'
|
|
92
|
+
: proof.indexDigest === null ? 'a staged deletion (a HEAD entry without an index entry) never mints'
|
|
93
|
+
: proof.worktreeDigest !== proof.indexDigest ? 'the clean-at-path rule (pre-change worktree bytes = the index entry) never minted this'
|
|
94
|
+
: null;
|
|
95
|
+
return mintInvariant === null ? null : `the persisted custody proof violates a mint invariant — ${mintInvariant}; an unmintable proof never passes (fail closed)`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const deltaRefusals = (records, owner) => {
|
|
99
|
+
const refusals = [];
|
|
100
|
+
const openPlanIds = ownOpenChainPlanIds(records, owner);
|
|
101
|
+
// The re-attestation OBLIGATION binds only AUTHORITATIVE deltas: a superseded same-key delta
|
|
102
|
+
// never enters classifyDeltaChain and the refresh preflight refuses to reference it, so
|
|
103
|
+
// demanding its refresh would be exactly the unrecoverable red the plan bans — supersession is
|
|
104
|
+
// the store's own recovery valve. Custody and mint checks stay RAW-wide (tamper detection).
|
|
105
|
+
const authoritative = new Set(authoritativeFlowRecords(records));
|
|
106
|
+
records.forEach((r, i) => {
|
|
107
|
+
if (r.kind !== 'bookkeeping-delta') return;
|
|
108
|
+
const custody = deltaCustodyIssue(r);
|
|
109
|
+
if (custody !== null) {
|
|
110
|
+
refusals.push(`bookkeeping-delta at ${r.path}: ${custody}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!authoritative.has(r)) return;
|
|
114
|
+
const digest = canonicalFlowDigest(r);
|
|
115
|
+
const satisfied = records.some((s, j) => j > i && s.kind === CHAIN_KIND && s.purpose === 'refresh'
|
|
116
|
+
&& s.refreshedRecord === digest && s.fingerprintBefore === r.fingerprintAfter);
|
|
117
|
+
if (!satisfied) {
|
|
118
|
+
const recovery = openPlanIds.length > 0
|
|
119
|
+
? `recovery (pasteable; choose the chain whose refresh cap this consumes, #61): ${openPlanIds.map((planId) => writerCommand(`refresh --cause='bookkeeping delta re-attestation' --refreshed-record=${digest} -- ${shellQuote(planId)}`)).join(' OR ')}`
|
|
120
|
+
: `recovery: no own OPEN chain can carry the re-attestation yet — open the owning plan's step round, then mint the refresh binding --refreshed-record=${digest}`;
|
|
121
|
+
refusals.push(`bookkeeping-delta at ${r.path}: no satisfying re-attestation — a SUBSEQUENT chain refresh must bind {refreshedRecord: ${short(digest)}, fingerprintBefore: ${short(r.fingerprintAfter)}}; an earlier or fingerprint-mismatched record never satisfies. ${recovery}`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return refusals;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Degrade-before-final (#64), decidable from RAW core-store order and grouped BY FINGERPRINT: a
|
|
128
|
+
// degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
|
|
129
|
+
// fingerprint completed (its `final` record landed after it). The checker reads raw records,
|
|
130
|
+
// never the authoritative selection (#65).
|
|
131
|
+
const degradeOrderingRefusals = (coreRecords) => {
|
|
132
|
+
const refusals = [];
|
|
133
|
+
coreRecords.forEach((r, i) => {
|
|
134
|
+
if (r.kind !== 'degrade') return;
|
|
135
|
+
const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
|
|
136
|
+
if (!startedBefore) return;
|
|
137
|
+
const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
|
|
138
|
+
&& coreRecords.some((c, k) => k > j && c.kind === 'final' && c.attempt === s.attempt && c.fingerprintBefore === s.fingerprint));
|
|
139
|
+
if (!cured) {
|
|
140
|
+
refusals.push(`a core degrade (backend "${r.backend}") landed AFTER a final-start at its fingerprint (${short(r.fingerprint)}) with no later completed re-run at it — degrades mint strictly BEFORE the final run (#64); re-run run-gates.mjs --final on this tree`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return refusals;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// #62: base delta ∩ plan surface — disjoint ⇒ re-baseline, intersecting/undecidable ⇒ refresh.
|
|
147
|
+
export const classifyBaseMotion = ({ baseDelta, changedSurface }) => {
|
|
148
|
+
if (!baseDelta?.ok) {
|
|
149
|
+
return { motion: 'undecidable', requires: 'refresh', reason: `the base delta is undecidable (${baseDelta?.reason ?? 'no delta supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
|
|
150
|
+
}
|
|
151
|
+
if (!changedSurface?.ok) {
|
|
152
|
+
return { motion: 'undecidable', requires: 'refresh', reason: `the changed surface is undecidable (${changedSurface?.reason ?? 'no surface supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
|
|
153
|
+
}
|
|
154
|
+
const surface = new Set(changedSurface.paths);
|
|
155
|
+
const witness = baseDelta.paths.find((p) => surface.has(p));
|
|
156
|
+
if (witness !== undefined) return { motion: 'intersecting', requires: 'refresh', witness };
|
|
157
|
+
return { motion: 'disjoint', requires: 're-baseline' };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// In-step base transitions of the LAST segment (lifecycle projection — round revisions collapsed)
|
|
161
|
+
// must land the class the delta requires; boundary and park→resume are exempt (every commit moves
|
|
162
|
+
// HEAD); the tail binds only a live in-step chain.
|
|
163
|
+
const baseMotionRefusals = (chain, planId, owner, motion) => {
|
|
164
|
+
if (chain[0].owner !== owner) return [];
|
|
165
|
+
const display = (b) => (b == null ? 'null' : short(b));
|
|
166
|
+
const refusals = [];
|
|
167
|
+
const classify = (fromBase, toBase) => classifyBaseMotion({
|
|
168
|
+
baseDelta: motion.resolveBaseDelta(fromBase, toBase),
|
|
169
|
+
changedSurface: motion.resolveChangedSurface(),
|
|
170
|
+
});
|
|
171
|
+
const requirement = (cls) => (cls.motion === 'disjoint' ? 'the delta is disjoint from the plan surface — re-baseline only, never a dispatch (#40)'
|
|
172
|
+
: cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}`
|
|
173
|
+
: cls.reason);
|
|
174
|
+
const seenRounds = new Set();
|
|
175
|
+
const lifecycle = chain.filter((r) => {
|
|
176
|
+
if (r.purpose !== 'round') return true;
|
|
177
|
+
const key = JSON.stringify([r.cycle, r.stepId, r.round]);
|
|
178
|
+
if (seenRounds.has(key)) return false;
|
|
179
|
+
seenRounds.add(key);
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
const isSegmentStart = (r) => r.purpose === 'resume' || r.purpose === 'unfreeze' || (r.purpose === 'round' && r.opensFrom !== null);
|
|
183
|
+
const states = [];
|
|
184
|
+
const walk = { mode: 'boundary', parked: false };
|
|
185
|
+
for (const r of lifecycle) {
|
|
186
|
+
states.push({ ...walk });
|
|
187
|
+
if (r.purpose === 'park') walk.parked = true;
|
|
188
|
+
else if (r.purpose === 'resume') walk.parked = false;
|
|
189
|
+
else if (r.purpose === 'converged' || r.purpose === 'complete') walk.mode = 'boundary';
|
|
190
|
+
else if (r.purpose === 'unfreeze' || (r.purpose === 'round' && walk.mode === 'boundary')) walk.mode = 'in-step';
|
|
191
|
+
}
|
|
192
|
+
const segStart = lifecycle.reduce((last, r, i) => (isSegmentStart(r) ? i : last), 0);
|
|
193
|
+
for (let i = segStart + 1; i < lifecycle.length; i += 1) {
|
|
194
|
+
const prev = lifecycle[i - 1];
|
|
195
|
+
const r = lifecycle[i];
|
|
196
|
+
if (states[i].mode !== 'in-step' || states[i].parked || r.base === prev.base) continue;
|
|
197
|
+
const cls = classify(prev.base, r.base);
|
|
198
|
+
if (r.purpose !== cls.requires) {
|
|
199
|
+
refusals.push(`plan "${planId}": a mid-step base transition (${display(prev.base)} → ${display(r.base)}) landed a "${r.purpose}" record but requires a ${cls.requires} record — ${requirement(cls)}; final gates must re-run after base motion (#62)`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (r.purpose === 're-baseline' && r.baseBefore !== prev.base) {
|
|
203
|
+
refusals.push(`plan "${planId}": the mid-step re-baseline's baseBefore (${display(r.baseBefore)}) does not match the previous record's base (${display(prev.base)}) — a re-baseline binds the actual pre-motion base (#62)`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const state = walkChainState(chain);
|
|
207
|
+
if (state.completed || state.parked || state.mode !== 'in-step') return refusals;
|
|
208
|
+
const recorded = lifecycle[lifecycle.length - 1].base;
|
|
209
|
+
if (recorded === motion.currentBase) return refusals;
|
|
210
|
+
const cls = classify(recorded, motion.currentBase);
|
|
211
|
+
const recovery = cls.requires === 're-baseline'
|
|
212
|
+
? writerCommand(`re-baseline -- ${shellQuote(planId)}`)
|
|
213
|
+
: writerCommand(`refresh --cause='base motion' --refreshed-record=${canonicalFlowDigest(chain[chain.length - 1])} -- ${shellQuote(planId)}`);
|
|
214
|
+
const tailRequirement = cls.requires === 're-baseline'
|
|
215
|
+
? 'a re-baseline record suffices (the delta is disjoint from the plan surface)'
|
|
216
|
+
: `a refresh dispatch is REQUIRED (${cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}` : cls.reason})`;
|
|
217
|
+
refusals.push(`plan "${planId}": the base moved under the armed chain (recorded ${display(recorded)} → current ${display(motion.currentBase)}) and no ${cls.requires} record landed — ${tailRequirement}; final gates must re-run after base motion (#62). recovery (pasteable): ${recovery}`);
|
|
218
|
+
return refusals;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// decideFlowCheck({ flowRead, coreRead, owner, motion?, evidence?, consumer? }) → { refusals,
|
|
222
|
+
// advisories }. Pure — consumes the FULL read-results of both stores; store health fails closed
|
|
223
|
+
// BEFORE any content judgment. `motion` ({ currentBase, resolveBaseDelta, resolveChangedSurface })
|
|
224
|
+
// arms the Step-1.4 base-motion refusals; `evidence` ({ receipts, tree, backends }) arms the three
|
|
225
|
+
// Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
|
|
226
|
+
// byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
|
|
227
|
+
// to the STRICT lane, so a caller that forgets to thread it inherits strictness.
|
|
228
|
+
export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
|
|
229
|
+
const refusals = [];
|
|
230
|
+
const advisories = [];
|
|
231
|
+
if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
|
|
232
|
+
else if (flowRead.malformed > 0) refusals.push(`the flow store carries ${flowRead.malformed} malformed line(s) (${flowRead.malformedReasons[0]}) — unknown kinds and broken records fail closed; inspect ${flowPath}`);
|
|
233
|
+
if (coreRead.readError) refusals.push(`the core evidence store is unreadable (${coreRead.readError}) — inspect ${corePath} (fail closed)`);
|
|
234
|
+
else if ((coreRead.malformed ?? 0) > 0) refusals.push(`the core evidence store carries ${coreRead.malformed} malformed line(s) (${coreRead.malformedReasons[0]}) — inspect ${corePath} (fail closed)`);
|
|
235
|
+
if (refusals.length > 0) return { refusals, advisories };
|
|
236
|
+
const records = flowRead.records;
|
|
237
|
+
const sup = validateSupersessions(records);
|
|
238
|
+
if (!sup.ok) refusals.push(`supersession legality: ${sup.reason} — inspect ${flowPath}`);
|
|
239
|
+
for (const planId of [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))]) {
|
|
240
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
241
|
+
const plan = planRefusals(records, chain, planId, owner, advisories);
|
|
242
|
+
refusals.push(...plan.refusals);
|
|
243
|
+
if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
|
|
244
|
+
}
|
|
245
|
+
refusals.push(...deltaRefusals(records, owner));
|
|
246
|
+
refusals.push(...degradeOrderingRefusals(coreRead.records));
|
|
247
|
+
if (evidence != null) {
|
|
248
|
+
refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint }));
|
|
249
|
+
refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
|
|
250
|
+
refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
|
|
251
|
+
}
|
|
252
|
+
return { refusals, advisories };
|
|
253
|
+
};
|