@sabaiway/agent-workflow-kit 5.6.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 +60 -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 +16 -3
- package/references/modes/recommendations.md +3 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/setup.md +18 -2
- package/references/modes/upgrade.md +38 -18
- 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/gates-declaration.mjs +23 -10
- package/tools/gates-init.mjs +6 -3
- 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/recommendations.mjs +315 -66
- 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 +6 -16
- package/tools/source-size-core.mjs +7 -1
- package/tools/source-size-gate-cmd.mjs +18 -46
- package/tools/tracked-tree-census.mjs +102 -0
- package/tools/upgrade-runlist.mjs +92 -0
|
@@ -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
|
+
]);
|
|
@@ -10,7 +10,7 @@ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
|
|
|
10
10
|
import { join, isAbsolute } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { fail, loadConfig, CONFIG_REL } from './orchestration-config.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { isCoverageProducerGate } from './coverage-producer.mjs';
|
|
14
14
|
import { matchesSourceSizeGate } from './source-size-core.mjs';
|
|
15
15
|
|
|
16
16
|
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
@@ -23,33 +23,40 @@ const EXIT_MALFORMED = 5;
|
|
|
23
23
|
|
|
24
24
|
const GATE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
25
|
const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
|
|
26
|
+
// The ONE optional gate key: the coverage-producer marker — a declared CLAIM that this gate writes
|
|
27
|
+
// the lcov the canonical checker reads (coverage-producer.mjs owns what the claim means and why
|
|
28
|
+
// recognition itself stays closed). Boolean only, and FORWARD-ONLY by design: an older kit has no
|
|
29
|
+
// such key and rejects a marker-carrying declaration loudly here, which is the honest failure — it
|
|
30
|
+
// could not honor the claim anyway.
|
|
31
|
+
export const LCOV_PRODUCER_KEY = 'lcovProducer';
|
|
32
|
+
const ALLOWED_GATE_KEYS = Object.freeze([...GATE_KEYS, LCOV_PRODUCER_KEY]);
|
|
26
33
|
|
|
27
34
|
// ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
|
|
28
35
|
|
|
29
36
|
// Validate a parsed gates.json object. Strict: only `_README` (string) + `gates` (array of
|
|
30
|
-
// { id, title, cmd }) are allowed; unknown keys anywhere are rejected loudly — the
|
|
31
|
-
// names WHAT to check, never lanes/models/routing. Returns the validated gates array.
|
|
37
|
+
// { id, title, cmd, lcovProducer? }) are allowed; unknown keys anywhere are rejected loudly — the
|
|
38
|
+
// declaration names WHAT to check, never lanes/models/routing. Returns the validated gates array.
|
|
32
39
|
export const validateDeclaration = (parsed) => {
|
|
33
40
|
const reject = (reason) => {
|
|
34
41
|
throw fail(EXIT_MALFORMED, `${GATES_REL}: ${reason}`);
|
|
35
42
|
};
|
|
36
43
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
37
|
-
reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd }, ...] }');
|
|
44
|
+
reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd, lcovProducer? }, ...] }');
|
|
38
45
|
}
|
|
39
46
|
for (const key of Object.keys(parsed)) {
|
|
40
47
|
if (key !== '_README' && key !== 'gates') reject(`unknown top-level key "${key}" (allowed: _README, gates)`);
|
|
41
48
|
}
|
|
42
49
|
if (parsed._README !== undefined && typeof parsed._README !== 'string') reject('"_README" must be a string');
|
|
43
|
-
if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd }');
|
|
50
|
+
if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd, lcovProducer? }');
|
|
44
51
|
const seenIds = new Set();
|
|
45
52
|
parsed.gates.forEach((gate, index) => {
|
|
46
53
|
const at = `gates[${index}]`;
|
|
47
54
|
if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
48
|
-
reject(`${at}: must be an object { id, title, cmd }`);
|
|
55
|
+
reject(`${at}: must be an object { id, title, cmd, lcovProducer? }`);
|
|
49
56
|
}
|
|
50
57
|
for (const key of Object.keys(gate)) {
|
|
51
|
-
if (!
|
|
52
|
-
reject(`${at}: unknown key "${key}" (allowed:
|
|
58
|
+
if (!ALLOWED_GATE_KEYS.includes(key)) {
|
|
59
|
+
reject(`${at}: unknown key "${key}" (allowed: ${ALLOWED_GATE_KEYS.join(', ')} — gates declare WHAT to check, never lane/model/routing)`);
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
for (const key of GATE_KEYS) {
|
|
@@ -57,6 +64,9 @@ export const validateDeclaration = (parsed) => {
|
|
|
57
64
|
reject(`${at}: "${key}" must be a non-empty string`);
|
|
58
65
|
}
|
|
59
66
|
}
|
|
67
|
+
if (gate[LCOV_PRODUCER_KEY] !== undefined && typeof gate[LCOV_PRODUCER_KEY] !== 'boolean') {
|
|
68
|
+
reject(`${at}: "${LCOV_PRODUCER_KEY}" must be a boolean (only the literal true claims this gate writes the lcov the checker reads)`);
|
|
69
|
+
}
|
|
60
70
|
if (/[\r\n]/.test(gate.cmd)) {
|
|
61
71
|
reject(`${at}: "cmd" must be ONE bash command line — embedded newlines (a multi-line script) are rejected; chain with && or move the script into a file`);
|
|
62
72
|
}
|
|
@@ -146,9 +156,12 @@ export const isFinalCapableDeclaration = (gates, projectDir) => {
|
|
|
146
156
|
// index. ORDER is the whole question: a producer declared AFTER the checker writes the lcov too late,
|
|
147
157
|
// so the checker reads nothing — or, worse, stale bytes an earlier run left behind — and still
|
|
148
158
|
// passes. ONE home for the rule: the written-declaration defects below and the advisor's
|
|
149
|
-
// inert-declaration item both decide through it, so they cannot drift apart.
|
|
159
|
+
// inert-declaration item both decide through it, so they cannot drift apart. Producer-ness itself is
|
|
160
|
+
// the canon's gate-level predicate (cmd closed-world OR the declared marker), so the slice is the
|
|
161
|
+
// only thing this function owns — and the slice is what keeps a marker on the CHECKER from
|
|
162
|
+
// self-pairing.
|
|
150
163
|
export const coverageProducerPrecedes = (gates, checkerIndex) =>
|
|
151
|
-
gates.slice(0, checkerIndex).some((gate) =>
|
|
164
|
+
gates.slice(0, checkerIndex).some((gate) => isCoverageProducerGate(gate));
|
|
152
165
|
|
|
153
166
|
// coverageDeclarationDefects(gates, projectDir) → the WRITTEN-declaration coverage rule, as a list
|
|
154
167
|
// of named defects (empty = satisfied): at most ONE canonical coverage checker; if one is present
|
package/tools/gates-init.mjs
CHANGED
|
@@ -62,7 +62,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
62
62
|
import { discoverGateCandidates, EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
|
|
63
63
|
import { GATES_REL, validateDeclaration } from './run-gates.mjs';
|
|
64
64
|
import { canonicalCheckerGates, coverageDeclarationDefects, isKitOwnedCheckerGate } from './gates-declaration.mjs';
|
|
65
|
-
import { COVERAGE_PRODUCER_BODY,
|
|
65
|
+
import { COVERAGE_PRODUCER_BODY, isCoverageProducerGate } from './coverage-producer.mjs';
|
|
66
66
|
import { loadConfig } from './orchestration-config.mjs';
|
|
67
67
|
import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
|
|
68
68
|
// The source-size practice, through its PURE READ core only (D-18): this module asks whether the
|
|
@@ -523,9 +523,12 @@ export const buildOffer = (cwd, deps = {}) => {
|
|
|
523
523
|
// a declaration the user already wrote by hand, so the rule reads the merged picture; a
|
|
524
524
|
// declaration this preview cannot read degrades to a stated note, never to a silent withhold.
|
|
525
525
|
const existing = declaredGatesBestEffort(cwd, deps);
|
|
526
|
+
// Both sides ask the ONE gate-level predicate. The offer side can only ever be a recognized cmd
|
|
527
|
+
// (this preview never EMITS the marker — it offers nothing it cannot verify), but a declaration
|
|
528
|
+
// the user wrote by hand may carry it, and the two sides must not answer through two predicates.
|
|
526
529
|
const producerPresent =
|
|
527
|
-
scripts.entries.some((entry) =>
|
|
528
|
-
existing.gates.some((gate) =>
|
|
530
|
+
scripts.entries.some((entry) => isCoverageProducerGate(entry)) ||
|
|
531
|
+
existing.gates.some((gate) => isCoverageProducerGate(gate));
|
|
529
532
|
const withholdCoverage = cc.candidate !== null && !producerPresent;
|
|
530
533
|
const coverageNote = withholdCoverage
|
|
531
534
|
? `the coverage-check candidate was withheld: nothing would PRODUCE the lcov it reads — no offered or ` +
|
package/tools/hide-footprint.mjs
CHANGED
|
@@ -444,6 +444,15 @@ export const hideFootprint = (opts = {}, deps = {}) => {
|
|
|
444
444
|
const writtenPatterns = buildBlock(writtenList.map((c) => c.pattern));
|
|
445
445
|
const needsUntrack = includedAsks.filter((a) => a.verdict === 'ask-tracked');
|
|
446
446
|
|
|
447
|
+
// The +N/−N delta against the CURRENT managed block (L3). The current set is the RAW fence body
|
|
448
|
+
// (canonicalized where recognized): a stale pattern the wholesale re-derive would silently drop
|
|
449
|
+
// is exactly what the removed list must surface.
|
|
450
|
+
const currentBlockPatterns = [...new Set(fenceBodyLines.map((l) => lineToPattern(l)).filter(Boolean).map((p) => recognizeHideRule(p) ?? p))];
|
|
451
|
+
const writtenSet = new Set(writtenPatterns);
|
|
452
|
+
const currentSet = new Set(currentBlockPatterns);
|
|
453
|
+
const added = writtenPatterns.filter((p) => !currentSet.has(p));
|
|
454
|
+
const removed = currentBlockPatterns.filter((p) => !writtenSet.has(p)).sort();
|
|
455
|
+
|
|
447
456
|
// ── build the new file (splice the fence; preserve outside lines) ──────────────
|
|
448
457
|
const fenceLines = writtenPatterns.length ? [START_MARKER, ...writtenPatterns, END_MARKER] : [];
|
|
449
458
|
const newLines = writtenPatterns.length
|
|
@@ -471,6 +480,8 @@ export const hideFootprint = (opts = {}, deps = {}) => {
|
|
|
471
480
|
action,
|
|
472
481
|
visibility: 'hidden',
|
|
473
482
|
wrote: writtenPatterns,
|
|
483
|
+
added,
|
|
484
|
+
removed,
|
|
474
485
|
asks: asks.filter((a) => !includedAsks.some((i) => i.pattern === a.pattern)).map((a) => ({ path: a.pattern, reason: a.reason, owner: a.owner })),
|
|
475
486
|
needsUntrack: needsUntrack.map((a) => {
|
|
476
487
|
const target = patternToProbe(a.pattern).replace(/\/$/, '');
|
|
@@ -530,16 +541,23 @@ const fmtGlobal = (g) => {
|
|
|
530
541
|
return [];
|
|
531
542
|
};
|
|
532
543
|
|
|
533
|
-
const formatReport = (r, dryRun) => {
|
|
544
|
+
export const formatReport = (r, dryRun) => {
|
|
534
545
|
const lines = [dryRun ? 'hide-footprint — DRY RUN (no changes)' : 'hide-footprint'];
|
|
535
|
-
if (r.visibility === 'visible') return [...lines, ` • deployment is VISIBLE (
|
|
536
|
-
if (r.ambiguous) return [...lines, ` • AMBIGUOUS visibility (
|
|
546
|
+
if (r.visibility === 'visible') return [...lines, ` • deployment is VISIBLE (${r.anchor} is tracked) — nothing to hide; wrote zero bytes`].join('\n');
|
|
547
|
+
if (r.ambiguous) return [...lines, ` • AMBIGUOUS visibility (${r.anchor} is untracked AND not ignored) — cannot tell fresh-uncommitted from broken-hidden; ASK the user before writing`].join('\n');
|
|
537
548
|
lines.push(` • ${r.action} ${r.excludeFile}`);
|
|
538
549
|
// The block contains every written pattern, but a TRACKED --include path is NOT hidden by it (it is
|
|
539
550
|
// reported separately, below) — so the "hidden" line lists only the genuinely-hidden untracked paths.
|
|
540
551
|
const untrackedOnly = new Set(r.needsUntrack.map((n) => n.path));
|
|
541
552
|
const hiddenNow = r.wrote.filter((p) => !untrackedOnly.has(p));
|
|
542
553
|
if (hiddenNow.length) lines.push(` • hidden (${hiddenNow.length}): ${hiddenNow.join(', ')}`);
|
|
554
|
+
// The block delta (L3) — rendered in dry-run and apply alike; sets listed, never counted alone.
|
|
555
|
+
// --unhide and the reconcile no-op paths carry no delta fields: their reports stay unchanged.
|
|
556
|
+
if (Array.isArray(r.added) && Array.isArray(r.removed)) {
|
|
557
|
+
if (r.added.length) lines.push(` • +${r.added.length} added: ${r.added.join(', ')}`);
|
|
558
|
+
if (r.removed.length) lines.push(` • −${r.removed.length} removed: ${r.removed.join(', ')}`);
|
|
559
|
+
if (!r.added.length && !r.removed.length) lines.push(' • +0/−0 — the hidden set is unchanged');
|
|
560
|
+
}
|
|
543
561
|
for (const a of r.asks) lines.push(` • ASK ${a.path} — ${a.reason}`);
|
|
544
562
|
for (const n of r.needsUntrack) lines.push(` • tracked, NOT hidden: ${n.path} — run \`${n.command}\` to un-track (kept on disk)`);
|
|
545
563
|
if (r.dropped.length) lines.push(` • skipped ${r.dropped.length} already-ignored (tracked .gitignore)`);
|