@sabaiway/agent-workflow-kit 5.0.0 → 5.2.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/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
- package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +19 -13
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -5
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -2
- package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
- package/bridges/codex-cli-bridge/capability.json +8 -6
- package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/flow-writer.md +37 -0
- package/references/modes/gates.md +4 -4
- package/references/modes/procedures.md +4 -2
- package/references/modes/receipt-deadline.md +16 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/set-flow.md +22 -0
- package/tools/cheap-agents.mjs +8 -2
- package/tools/commands.mjs +24 -2
- package/tools/commit-guard.mjs +44 -9
- package/tools/core-evidence.mjs +25 -22
- package/tools/detect-backends.mjs +32 -11
- package/tools/doc-parity.mjs +29 -2
- package/tools/flow-check.mjs +806 -0
- package/tools/flow-record.mjs +795 -0
- package/tools/flow-store-read.mjs +114 -0
- package/tools/flow-store.mjs +1178 -0
- package/tools/flow-writer.mjs +1265 -0
- package/tools/fs-read-nofollow.mjs +128 -0
- package/tools/gates-declaration.mjs +184 -0
- package/tools/gates-init.mjs +59 -17
- package/tools/orchestration-config.mjs +105 -4
- package/tools/orchestration-write.mjs +3 -3
- package/tools/plan-files.mjs +35 -0
- package/tools/procedures.mjs +75 -11
- package/tools/receipt-deadline.mjs +242 -0
- package/tools/recipes.mjs +21 -0
- package/tools/repo-lex.mjs +22 -0
- package/tools/review-state.mjs +240 -80
- package/tools/run-gates.mjs +361 -139
- package/tools/set-flow.mjs +465 -0
- package/tools/velocity-profile.mjs +8 -2
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// set-flow.mjs — the ARMING writer for the `flow` block of docs/ai/orchestration.json (flow
|
|
3
|
+
// orchestration, Plan 3 Step 3.1). Division of labor mirrors set-recipe (AD-025): the AGENT turns
|
|
4
|
+
// plain language into explicit `--preset` / `--set <key>=<value>` / `--unset <key>` ops; the KIT
|
|
5
|
+
// does the deterministic parse → merge → floor-check → preview → write. Preview by default;
|
|
6
|
+
// `--write` applies via the hardened writeConfig. It NEVER commits and NEVER runs a backend.
|
|
7
|
+
//
|
|
8
|
+
// Merge semantics (#30): the preset is a SEED — its values come verbatim from the ONE schema-1
|
|
9
|
+
// literal (FLOW_SCHEMA_1_FIXTURE, P20; candidates stay explicit — they name the project's real
|
|
10
|
+
// backends); explicit --set keys win over the seed, the seed wins over the existing block, and
|
|
11
|
+
// `schema` is pinned by the kit (never an op). The merged result previews before any write.
|
|
12
|
+
//
|
|
13
|
+
// Arming floors (#31 — validateConfig stays shape-only; EVERY deep floor lives here):
|
|
14
|
+
// • kitMinVersion (Decision 6, #54): the null-GUARDED semver comparison characterized in the
|
|
15
|
+
// FLOW-VERSION-FLOORS block of semver-lite.test.mjs — an unparseable version on EITHER side
|
|
16
|
+
// never passes (the bare `>= 0` shape fails open on null and is banned).
|
|
17
|
+
// • debtQueue / convergenceSummary (#37/#69): each declared path must be a single regular
|
|
18
|
+
// TRACKED file OR carry its explicit declared-excluded flag (loud); never a symlink or
|
|
19
|
+
// directory on disk; never under docs/ai/; never a literal substring of any declared gate cmd
|
|
20
|
+
// (docs/ai/gates.json). The undecidable remainder is printed as a DISCLOSED residual
|
|
21
|
+
// (FLOW_BOOKKEEPING_FLOOR_RESIDUAL), never a pretended rule.
|
|
22
|
+
// Floor refusals hold on the preview AND the write lane (a preview that could never write is
|
|
23
|
+
// already a failed check) — exit 1, nothing written.
|
|
24
|
+
//
|
|
25
|
+
// Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when
|
|
26
|
+
// narrating. Exit codes: 0 success/preview; 2 usage (bad key/value/flag); 1 floor refusal, config
|
|
27
|
+
// error, or a write STOP. main(argv, ctx) → { code, stdout, stderr }; cwd / fs / git / kit
|
|
28
|
+
// version are injectable for hermetic tests. Dependency-free, Node >= 22. No side effects on
|
|
29
|
+
// import (the isDirectRun idiom).
|
|
30
|
+
|
|
31
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
32
|
+
import { join, dirname, resolve } from 'node:path';
|
|
33
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import {
|
|
36
|
+
CONFIG_REL,
|
|
37
|
+
fail,
|
|
38
|
+
loadConfig,
|
|
39
|
+
validateConfig,
|
|
40
|
+
serializeConfig,
|
|
41
|
+
FLOW_SCHEMA_VERSION,
|
|
42
|
+
FLOW_SCHEMA_1_KEYS,
|
|
43
|
+
FLOW_PRESET_VALUES,
|
|
44
|
+
FLOW_CANDIDATE_CLASSES,
|
|
45
|
+
FLOW_SCHEMA_1_FIXTURE,
|
|
46
|
+
} from './orchestration-config.mjs';
|
|
47
|
+
import { writeConfig as writeConfigFs } from './orchestration-write.mjs';
|
|
48
|
+
import { loadDeclaration } from './run-gates.mjs';
|
|
49
|
+
import { compareSemver } from './semver-lite.mjs';
|
|
50
|
+
import { lexicalRepoRelative } from './core-evidence.mjs';
|
|
51
|
+
import { readAuthoritativeVersion } from './manifest/validate.mjs';
|
|
52
|
+
|
|
53
|
+
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
54
|
+
const GATES_REL = 'docs/ai/gates.json';
|
|
55
|
+
|
|
56
|
+
// The honest boundary of the bookkeeping floors — printed on EVERY floor evaluation and
|
|
57
|
+
// doc-parity-bound into the set-flow mode doc, so the admission can never be reworded away.
|
|
58
|
+
export const FLOW_BOOKKEEPING_FLOOR_RESIDUAL =
|
|
59
|
+
'the bookkeeping floors decide only what is decidable at arming time: a gate command reading the declared path INDIRECTLY (through its own script), content-level abuse inside the file, and a path re-pointed after arming stay undecided — bookkeeping WRITES are bound by digest and custody proof at the checker instead (#37/#69); this line is the honest boundary, not a pretended rule';
|
|
60
|
+
|
|
61
|
+
// ── op parsing (usage errors → exit 2) ──────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
// The settable key set = the closed schema-1 surface minus the kit-pinned `schema`.
|
|
64
|
+
const SETTABLE_KEYS = FLOW_SCHEMA_1_KEYS.filter((k) => k !== 'schema');
|
|
65
|
+
|
|
66
|
+
const parseCandidates = (raw) => {
|
|
67
|
+
if (raw === '') return [];
|
|
68
|
+
return raw.split(',').map((token) => {
|
|
69
|
+
const at = token.indexOf(':');
|
|
70
|
+
if (at <= 0 || at === token.length - 1) {
|
|
71
|
+
throw fail(2, `--set candidates takes comma-separated <name>:<class> pairs (got "${token}") — e.g. candidates=codex:review,agy:review`);
|
|
72
|
+
}
|
|
73
|
+
const name = token.slice(0, at);
|
|
74
|
+
const cls = token.slice(at + 1);
|
|
75
|
+
if (!FLOW_CANDIDATE_CLASSES.includes(cls)) {
|
|
76
|
+
throw fail(2, `candidate class must be one of ${FLOW_CANDIDATE_CLASSES.join(' | ')} (got "${cls}")`);
|
|
77
|
+
}
|
|
78
|
+
return { name, class: cls };
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// One typed value parser per settable key — a malformed value is a USAGE error; the shape walk
|
|
83
|
+
// (validateConfig) re-checks the merged result defensively.
|
|
84
|
+
const parseFlowValue = (key, raw) => {
|
|
85
|
+
if (key === 'preset') {
|
|
86
|
+
if (!FLOW_PRESET_VALUES.includes(raw)) throw fail(2, `preset must be one of ${FLOW_PRESET_VALUES.join(' | ')} (got "${raw}")`);
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
if (key === 'councilRounds') {
|
|
90
|
+
const n = Number(raw);
|
|
91
|
+
if (!Number.isInteger(n) || n < 1 || String(n) !== raw.trim()) throw fail(2, `councilRounds must be a positive integer (got "${raw}")`);
|
|
92
|
+
return n;
|
|
93
|
+
}
|
|
94
|
+
if (key === 'debtQueueExcluded' || key === 'convergenceSummaryExcluded') {
|
|
95
|
+
if (raw !== 'true' && raw !== 'false') throw fail(2, `${key} must be true or false (got "${raw}")`);
|
|
96
|
+
return raw === 'true';
|
|
97
|
+
}
|
|
98
|
+
if (key === 'pregateExclude') {
|
|
99
|
+
if (raw === '') return [];
|
|
100
|
+
const ids = raw.split(',');
|
|
101
|
+
if (ids.some((id) => id === '')) throw fail(2, `pregateExclude carries an empty gate id (got "${raw}") — comma-separated non-empty gate ids, e.g. pregateExclude=unit,lint`);
|
|
102
|
+
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
103
|
+
if (dup !== undefined) throw fail(2, `pregateExclude carries a duplicate gate id "${dup}" (got "${raw}") — name each gate id at most once`);
|
|
104
|
+
return ids;
|
|
105
|
+
}
|
|
106
|
+
if (key === 'candidates') return parseCandidates(raw);
|
|
107
|
+
if (raw === '') throw fail(2, `${key} must be a non-empty value`);
|
|
108
|
+
return raw; // debtQueue / convergenceSummary / kitMinVersion — strings
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const parseArgs = (argv) => {
|
|
112
|
+
const sets = {};
|
|
113
|
+
const unsets = new Set();
|
|
114
|
+
let preset = null;
|
|
115
|
+
let write = false;
|
|
116
|
+
let json = false;
|
|
117
|
+
const takeSet = (tok) => {
|
|
118
|
+
if (tok === undefined || tok.startsWith('--')) throw fail(2, '--set requires <key>=<value>');
|
|
119
|
+
const eq = tok.indexOf('=');
|
|
120
|
+
if (eq <= 0) throw fail(2, `--set must be <key>=<value> (got "${tok}")`);
|
|
121
|
+
const key = tok.slice(0, eq);
|
|
122
|
+
if (key === 'schema') throw fail(2, `"schema" is pinned by the kit (${FLOW_SCHEMA_VERSION}) — never an op`);
|
|
123
|
+
if (!SETTABLE_KEYS.includes(key)) throw fail(2, `unknown flow key "${key}" (settable: ${SETTABLE_KEYS.join(', ')})`);
|
|
124
|
+
if (key in sets || unsets.has(key)) throw fail(2, `duplicate op for flow key "${key}" — name each key at most once`);
|
|
125
|
+
sets[key] = parseFlowValue(key, tok.slice(eq + 1));
|
|
126
|
+
};
|
|
127
|
+
const takeUnset = (tok) => {
|
|
128
|
+
if (tok === undefined || tok.startsWith('--')) throw fail(2, '--unset requires <key>');
|
|
129
|
+
if (tok === 'schema') throw fail(2, '"schema" is pinned by the kit — never an op');
|
|
130
|
+
if (!SETTABLE_KEYS.includes(tok)) throw fail(2, `unknown flow key "${tok}" (settable: ${SETTABLE_KEYS.join(', ')})`);
|
|
131
|
+
if (tok in sets || unsets.has(tok)) throw fail(2, `duplicate op for flow key "${tok}" — name each key at most once`);
|
|
132
|
+
unsets.add(tok);
|
|
133
|
+
};
|
|
134
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
135
|
+
const a = argv[i];
|
|
136
|
+
if (a === '--json') json = true;
|
|
137
|
+
else if (a === '--write') write = true;
|
|
138
|
+
else if (a === '--preset') {
|
|
139
|
+
const tok = argv[i + 1];
|
|
140
|
+
if (tok === undefined || tok.startsWith('--')) throw fail(2, `--preset requires one of ${FLOW_PRESET_VALUES.join(' | ')}`);
|
|
141
|
+
if (preset !== null) throw fail(2, 'duplicate --preset — name one seed at most once');
|
|
142
|
+
if (!FLOW_PRESET_VALUES.includes(tok)) throw fail(2, `unknown preset "${tok}" (known: ${FLOW_PRESET_VALUES.join(', ')})`);
|
|
143
|
+
preset = tok;
|
|
144
|
+
i += 1;
|
|
145
|
+
} else if (a.startsWith('--preset=')) {
|
|
146
|
+
const tok = a.slice('--preset='.length);
|
|
147
|
+
if (preset !== null) throw fail(2, 'duplicate --preset — name one seed at most once');
|
|
148
|
+
if (!FLOW_PRESET_VALUES.includes(tok)) throw fail(2, `unknown preset "${tok}" (known: ${FLOW_PRESET_VALUES.join(', ')})`);
|
|
149
|
+
preset = tok;
|
|
150
|
+
} else if (a === '--set') { takeSet(argv[i + 1]); i += 1; }
|
|
151
|
+
else if (a === '--unset') { takeUnset(argv[i + 1]); i += 1; }
|
|
152
|
+
else if (a.startsWith('--set=')) takeSet(a.slice('--set='.length));
|
|
153
|
+
else if (a.startsWith('--unset=')) takeUnset(a.slice('--unset='.length));
|
|
154
|
+
else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
|
|
155
|
+
else throw fail(2, `unexpected argument: ${a}`);
|
|
156
|
+
}
|
|
157
|
+
if (write && preset === null && Object.keys(sets).length === 0 && unsets.size === 0) {
|
|
158
|
+
throw fail(2, 'nothing to write — pass --preset and/or at least one --set/--unset (a bare --write is a no-op)');
|
|
159
|
+
}
|
|
160
|
+
return { sets, unsets, preset, write, json };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// ── the merge (#30: existing < preset seed < explicit ops; schema pinned) ───────────
|
|
164
|
+
|
|
165
|
+
// The preset SEED = the schema-1 literal fixture's values verbatim (P20 — the arming path consumes
|
|
166
|
+
// the SAME fixture the structural validator pins), with the preset key set to the chosen preset.
|
|
167
|
+
// Candidates are never seeded — they name the project's REAL backends and stay explicit.
|
|
168
|
+
const presetSeed = (preset) => ({
|
|
169
|
+
preset,
|
|
170
|
+
councilRounds: FLOW_SCHEMA_1_FIXTURE.councilRounds,
|
|
171
|
+
debtQueue: FLOW_SCHEMA_1_FIXTURE.debtQueue,
|
|
172
|
+
convergenceSummary: FLOW_SCHEMA_1_FIXTURE.convergenceSummary,
|
|
173
|
+
debtQueueExcluded: FLOW_SCHEMA_1_FIXTURE.debtQueueExcluded,
|
|
174
|
+
convergenceSummaryExcluded: FLOW_SCHEMA_1_FIXTURE.convergenceSummaryExcluded,
|
|
175
|
+
pregateExclude: [...FLOW_SCHEMA_1_FIXTURE.pregateExclude],
|
|
176
|
+
kitMinVersion: FLOW_SCHEMA_1_FIXTURE.kitMinVersion,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export const mergeFlowBlock = ({ existing, preset, sets, unsets }) => {
|
|
180
|
+
const merged = {
|
|
181
|
+
...(existing ?? {}),
|
|
182
|
+
...(preset === null ? {} : presetSeed(preset)),
|
|
183
|
+
...sets,
|
|
184
|
+
schema: FLOW_SCHEMA_VERSION,
|
|
185
|
+
};
|
|
186
|
+
for (const key of unsets) delete merged[key];
|
|
187
|
+
return merged;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ── the arming floors (#31 — deep checks live HERE only) ────────────────────────────
|
|
191
|
+
|
|
192
|
+
// Decision 6: the guarded shape from the FLOW-VERSION-FLOORS characterization — null (unparseable
|
|
193
|
+
// EITHER side) never meets a floor; the bare `>= 0` relational is the trap this refuses to repeat.
|
|
194
|
+
const meetsVersionFloor = (version, floor) => {
|
|
195
|
+
const cmp = compareSemver(version, floor);
|
|
196
|
+
return cmp !== null && cmp >= 0;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const defaultRunGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
|
|
200
|
+
|
|
201
|
+
const gitToplevel = (cwd, runGit) => {
|
|
202
|
+
const r = runGit(['rev-parse', '--show-toplevel'], cwd);
|
|
203
|
+
if (r.error || r.status !== 0) return null;
|
|
204
|
+
const top = r.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
205
|
+
return top === '' ? null : top;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Single-regular-tracked-file check via a strict -z parse of a LITERAL pathspec: exactly one
|
|
209
|
+
// stage-0 entry whose path EQUALS the declared rel and whose mode is plain 100644.
|
|
210
|
+
const TRACKED_ENTRY_RE = /^(100644) ([0-9a-f]{40}|[0-9a-f]{64}) 0\t(.*)$/;
|
|
211
|
+
const isSingleRegularTrackedFile = (top, rel, runGit) => {
|
|
212
|
+
const out = runGit(['ls-files', '-s', '-z', '--', `:(literal)${rel}`], top);
|
|
213
|
+
if (out.error || out.status !== 0) return { ok: false, reason: 'git ls-files failed — tracked-ness is undecidable (fail closed)' };
|
|
214
|
+
const text = out.stdout.toString('utf8');
|
|
215
|
+
if (text === '') return { ok: false, reason: 'not tracked' };
|
|
216
|
+
if (!text.endsWith('\0')) return { ok: false, reason: 'unparseable git ls-files output — tracked-ness is undecidable (fail closed)' };
|
|
217
|
+
const entries = text.slice(0, -1).split('\0');
|
|
218
|
+
if (entries.length !== 1) return { ok: false, reason: `${entries.length} index entries — a single tracked file is required` };
|
|
219
|
+
const m = TRACKED_ENTRY_RE.exec(entries[0]);
|
|
220
|
+
if (m === null || m[3] !== rel) return { ok: false, reason: 'not a plain stage-0 100644 regular-file index entry' };
|
|
221
|
+
return { ok: true };
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// No-follow walk over every ancestor prefix of the declared path under the toplevel: a symlinked
|
|
225
|
+
// component would let the leaf checks judge a different physical home than the spelling claims.
|
|
226
|
+
const symlinkedAncestor = (top, rel, lstat) => {
|
|
227
|
+
const segments = rel.split('/');
|
|
228
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
229
|
+
const prefix = segments.slice(0, i).join('/');
|
|
230
|
+
let st = null;
|
|
231
|
+
try {
|
|
232
|
+
st = lstat(join(top, prefix));
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (err && err.code === 'ENOENT') return { ok: true }; // nothing deeper exists to alias
|
|
235
|
+
return { ok: false, failure: `unstatable ancestor "${prefix}" (${(err && err.code) || (err && err.message) || err}) — the class walk is undecidable (fail closed)` };
|
|
236
|
+
}
|
|
237
|
+
if (st.isSymbolicLink()) return { ok: false, failure: `ancestor "${prefix}" is a symlink — the class walk is no-follow (fail closed, #37)` };
|
|
238
|
+
}
|
|
239
|
+
return { ok: true };
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const bookkeepingFloorFailures = ({ key, rel, excluded, top, gates, lstat, runGit }) => {
|
|
243
|
+
const failures = [];
|
|
244
|
+
const notes = [];
|
|
245
|
+
const lex = lexicalRepoRelative(rel);
|
|
246
|
+
if (!lex.ok) {
|
|
247
|
+
failures.push(`flow.${key} "${rel}": must be lexically repo-relative — ${lex.reason} (#37)`);
|
|
248
|
+
return { failures, notes };
|
|
249
|
+
}
|
|
250
|
+
// lexicalRepoRelative NORMALIZES interior dot segments — a "docs/plans/../ai/…" spelling would
|
|
251
|
+
// dodge every raw-prefix floor below while resolving inside docs/ai (fail closed on segments).
|
|
252
|
+
// A backslash byte is refused outright: on Windows it is a separator the raw-prefix floors do
|
|
253
|
+
// not judge, so "docs\\ai\\…" would resolve under docs/ai there — forward-slash only.
|
|
254
|
+
if (rel.includes('\\')) {
|
|
255
|
+
failures.push(`flow.${key} "${rel}": carries a backslash — forward-slash is the only separator the floors judge (#37)`);
|
|
256
|
+
return { failures, notes };
|
|
257
|
+
}
|
|
258
|
+
if (rel.split('/').some((s) => s === '..' || s === '.' || s === '')) {
|
|
259
|
+
failures.push(`flow.${key} "${rel}": must be a plain forward-slash path without "." or ".." segments (#37)`);
|
|
260
|
+
return { failures, notes };
|
|
261
|
+
}
|
|
262
|
+
if (rel === 'docs/ai' || rel.startsWith('docs/ai/')) {
|
|
263
|
+
failures.push(`flow.${key} "${rel}": a bookkeeping path never lives under docs/ai/ (#37)`);
|
|
264
|
+
}
|
|
265
|
+
for (const gate of gates) {
|
|
266
|
+
if (gate.cmd.includes(rel)) {
|
|
267
|
+
failures.push(`flow.${key} "${rel}": a literal substring of declared gate "${gate.id}"'s cmd — a bookkeeping path never feeds a gate (#37)`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (top === null) {
|
|
271
|
+
failures.push(`flow.${key} "${rel}": not inside a git work tree — the disk class and tracked-ness are undecidable (fail closed)`);
|
|
272
|
+
return { failures, notes };
|
|
273
|
+
}
|
|
274
|
+
const walk = symlinkedAncestor(top, rel, lstat);
|
|
275
|
+
if (!walk.ok) {
|
|
276
|
+
failures.push(`flow.${key} "${rel}": ${walk.failure}`);
|
|
277
|
+
return { failures, notes };
|
|
278
|
+
}
|
|
279
|
+
let st = null;
|
|
280
|
+
try {
|
|
281
|
+
st = lstat(join(top, rel));
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (!err || err.code !== 'ENOENT') {
|
|
284
|
+
failures.push(`flow.${key} "${rel}": unstatable (${(err && err.code) || (err && err.message) || err}) — the disk class is undecidable (fail closed)`);
|
|
285
|
+
return { failures, notes };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (st?.isSymbolicLink()) failures.push(`flow.${key} "${rel}": a symlink — never a bookkeeping path (#37)`);
|
|
289
|
+
else if (st?.isDirectory()) failures.push(`flow.${key} "${rel}": a directory — never a bookkeeping path (#37)`);
|
|
290
|
+
else if (st && !st.isFile()) failures.push(`flow.${key} "${rel}": not a regular file — never a bookkeeping path (#37)`);
|
|
291
|
+
else if (st == null && !excluded) {
|
|
292
|
+
// Only the declared-excluded lane may point at a not-yet-present machine-local file; a
|
|
293
|
+
// tracked path deleted from the worktree would otherwise pass on its index entry alone.
|
|
294
|
+
failures.push(`flow.${key} "${rel}": absent from the worktree — a bookkeeping file must exist as a regular file on disk, or be declared excluded loudly (#37/#69)`);
|
|
295
|
+
}
|
|
296
|
+
if (excluded) {
|
|
297
|
+
notes.push(`flow.${key} "${rel}": DECLARED-EXCLUDED — the tracked-file floor is waived by the explicit declaration (hide-footprint support, #31); the checker still binds its writes by digest and custody proof`);
|
|
298
|
+
return { failures, notes };
|
|
299
|
+
}
|
|
300
|
+
const tracked = isSingleRegularTrackedFile(top, rel, runGit);
|
|
301
|
+
if (!tracked.ok) {
|
|
302
|
+
failures.push(`flow.${key} "${rel}": ${tracked.reason} — track it as a single regular file, or declare it excluded loudly (${key}Excluded: true) (#37/#69)`);
|
|
303
|
+
}
|
|
304
|
+
return { failures, notes };
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// The declaration is read through the gate runner's OWN loader (loadDeclaration: lstat-first, so
|
|
308
|
+
// a dangling symlink reads as present-but-unreadable, then the shared structural validator) —
|
|
309
|
+
// only a TRULY absent file means "no gates"; every other failure is an undecidable gate-cmd
|
|
310
|
+
// floor, never a silently-empty gate list (fail closed).
|
|
311
|
+
const readDeclaredGates = (cwd, readFile, lstat) => {
|
|
312
|
+
try {
|
|
313
|
+
const declaration = loadDeclaration(cwd, { readFile, lstat });
|
|
314
|
+
return { gates: declaration.outcome === 'missing' ? [] : declaration.gates };
|
|
315
|
+
} catch (err) {
|
|
316
|
+
return { error: `${err.message} — the gate-cmd floor is undecidable (fail closed)` };
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// evaluateArmingFloors(merged, io) → { failures, notes }. Pure over injected io; consulted on the
|
|
321
|
+
// preview AND the write lane (same verdicts both ways).
|
|
322
|
+
export const evaluateArmingFloors = (merged, { cwd, readFile, lstat, runGit, kitVersion }) => {
|
|
323
|
+
const failures = [];
|
|
324
|
+
const notes = [];
|
|
325
|
+
if (typeof merged.kitMinVersion === 'string') {
|
|
326
|
+
if (!meetsVersionFloor(kitVersion, merged.kitMinVersion)) {
|
|
327
|
+
failures.push(`flow.kitMinVersion "${merged.kitMinVersion}": this kit (${kitVersion ?? 'unknown version'}) does not meet the declared floor — the comparison is null-guarded: an unparseable version on either side never passes (Decision 6, #54)`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const declared = [
|
|
331
|
+
{ key: 'debtQueue', rel: merged.debtQueue, excluded: merged.debtQueueExcluded === true },
|
|
332
|
+
{ key: 'convergenceSummary', rel: merged.convergenceSummary, excluded: merged.convergenceSummaryExcluded === true },
|
|
333
|
+
].filter((d) => typeof d.rel === 'string');
|
|
334
|
+
if (declared.length > 0) {
|
|
335
|
+
const gatesRead = readDeclaredGates(cwd, readFile, lstat);
|
|
336
|
+
if (gatesRead.error) {
|
|
337
|
+
failures.push(gatesRead.error);
|
|
338
|
+
} else {
|
|
339
|
+
const top = gitToplevel(cwd, runGit);
|
|
340
|
+
for (const d of declared) {
|
|
341
|
+
const r = bookkeepingFloorFailures({ ...d, top, gates: gatesRead.gates, lstat, runGit });
|
|
342
|
+
failures.push(...r.failures);
|
|
343
|
+
notes.push(...r.notes);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { failures, notes };
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// ── rendering (ENGLISH; the agent localizes) ────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
const valueLabel = (v) => (v === undefined ? '(absent)' : JSON.stringify(v));
|
|
353
|
+
|
|
354
|
+
const changedKeys = (before, after) => {
|
|
355
|
+
const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])].sort();
|
|
356
|
+
return keys
|
|
357
|
+
.filter((k) => JSON.stringify(before?.[k]) !== JSON.stringify(after[k]))
|
|
358
|
+
.map((k) => ({ key: k, from: before?.[k], to: after[k] }));
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// Three honest not-written headers (the reviewer's D-branch split): a pure preview may invite
|
|
362
|
+
// --write; a REQUESTED write that wrote nothing states exactly why and never re-invites itself.
|
|
363
|
+
const formatHuman = ({ changed, merged, floors, wrote, writeRequested, noop, fileBody }) => {
|
|
364
|
+
const floorsOk = floors.failures.length === 0;
|
|
365
|
+
const lines = [];
|
|
366
|
+
if (wrote) lines.push(`wrote ${CONFIG_REL}`);
|
|
367
|
+
else if (writeRequested && !floorsOk) lines.push('set-flow — nothing written (arming floors refused)');
|
|
368
|
+
else if (writeRequested && noop) lines.push('set-flow — nothing written (the merged flow block equals the current one)');
|
|
369
|
+
else lines.push('set-flow — preview (nothing written)');
|
|
370
|
+
for (const c of changed) lines.push(` flow.${c.key}: ${valueLabel(c.from)} → ${valueLabel(c.to)}`);
|
|
371
|
+
if (noop && !writeRequested) lines.push(' no changes — the merged flow block equals the current one.');
|
|
372
|
+
lines.push('', 'merged flow block:', JSON.stringify(merged, null, 2));
|
|
373
|
+
for (const note of floors.notes) lines.push(` ⚠ ${note}`);
|
|
374
|
+
if (floorsOk) lines.push(' arming floors: PASS');
|
|
375
|
+
for (const f of floors.failures) lines.push(` FLOOR REFUSED — ${f}`);
|
|
376
|
+
lines.push(` residual: ${FLOW_BOOKKEEPING_FLOOR_RESIDUAL}`);
|
|
377
|
+
if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
|
|
378
|
+
if (wrote) lines.push('', 'the CONFIG half is armed — the chain half arms at plan adoption (flow-writer adoption <plan-file>); gates-init offers the checker TRIO for a flow-carrying config.');
|
|
379
|
+
if (!wrote && !writeRequested && !noop && floorsOk) lines.push('', `would write ${CONFIG_REL} — re-run with --write to apply.`);
|
|
380
|
+
if (!wrote && !floorsOk) lines.push('', 'nothing will be written until every floor passes.');
|
|
381
|
+
return lines.join('\n');
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const buildJson = ({ changed, merged, floors, writtenPath, noop }) => ({
|
|
385
|
+
changed: changed.map((c) => ({ key: c.key, from: c.from ?? null, to: c.to ?? null })),
|
|
386
|
+
merged,
|
|
387
|
+
floors: { ok: floors.failures.length === 0, failures: floors.failures, notes: floors.notes, residual: FLOW_BOOKKEEPING_FLOOR_RESIDUAL },
|
|
388
|
+
writtenPath: writtenPath ?? null,
|
|
389
|
+
noop,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const HELP = `set-flow — arm the flow block of the per-project orchestration config (${CONFIG_REL}).
|
|
393
|
+
|
|
394
|
+
Usage:
|
|
395
|
+
node set-flow.mjs [--preset <${FLOW_PRESET_VALUES.join('|')}>] [--set <key>=<value>]... [--unset <key>]... [--write] [--json]
|
|
396
|
+
|
|
397
|
+
--preset seed the block from the schema-1 canon (explicit --set keys win; candidates stay explicit)
|
|
398
|
+
--set <key>=<value> — settable keys: ${SETTABLE_KEYS.join(', ')}
|
|
399
|
+
(candidates: <name>:<class> pairs, comma-separated; pregateExclude: comma-separated ids)
|
|
400
|
+
--unset <key> — drop a key from the flow block ("schema" is pinned by the kit)
|
|
401
|
+
--write apply (default: preview only — writes nothing)
|
|
402
|
+
--json machine-readable output
|
|
403
|
+
|
|
404
|
+
Deep arming floors run HERE only (#31; validateConfig stays shape-only): the null-guarded
|
|
405
|
+
kitMinVersion comparison (an unparseable version never passes), and the bookkeeping floors —
|
|
406
|
+
debtQueue/convergenceSummary each a single regular TRACKED file or loudly declared-excluded,
|
|
407
|
+
never a symlink/directory, never under docs/ai/, never a literal substring of a declared gate cmd.
|
|
408
|
+
The undecidable remainder prints as a disclosed residual. Floors hold on preview AND write.
|
|
409
|
+
|
|
410
|
+
Exit codes: 0 success/preview; 2 usage; 1 floor refusal, config error, or a write STOP.`;
|
|
411
|
+
|
|
412
|
+
// ── main ────────────────────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
export const main = (argv, ctx = {}) => {
|
|
415
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
416
|
+
const readFile = ctx.readFileSync ?? readFileSync;
|
|
417
|
+
const lstat = ctx.lstatSync ?? lstatSync;
|
|
418
|
+
const writeConfig = ctx.writeConfig ?? writeConfigFs;
|
|
419
|
+
const runGit = ctx.runGit ?? defaultRunGit;
|
|
420
|
+
const kitVersion = 'kitVersion' in ctx ? ctx.kitVersion : readAuthoritativeVersion(KIT_ROOT).version;
|
|
421
|
+
try {
|
|
422
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
423
|
+
const { sets, unsets, preset, write, json } = parseArgs(argv);
|
|
424
|
+
const { config: current } = loadConfig(cwd, readFile, lstat);
|
|
425
|
+
|
|
426
|
+
if (preset === null && Object.keys(sets).length === 0 && unsets.size === 0) {
|
|
427
|
+
if (json) return { code: 0, stdout: JSON.stringify({ flow: current?.flow ?? null, noop: true }, null, 2), stderr: '' };
|
|
428
|
+
const shown = current?.flow === undefined ? `(no flow block in ${CONFIG_REL} yet — the flow is config-unarmed)` : JSON.stringify(current.flow, null, 2);
|
|
429
|
+
const hint = `\nPass --preset ${FLOW_PRESET_VALUES.join('|')} and/or --set <key>=<value> (preview), then --write to apply.`;
|
|
430
|
+
return { code: 0, stdout: `${shown}${hint}`, stderr: '' };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const merged = mergeFlowBlock({ existing: current?.flow, preset, sets, unsets });
|
|
434
|
+
const after = { ...(current ?? {}), flow: merged };
|
|
435
|
+
validateConfig(after); // shape walk (closed key set + per-key types) — defensive; ops pre-validate
|
|
436
|
+
const floors = evaluateArmingFloors(merged, { cwd, readFile, lstat, runGit, kitVersion });
|
|
437
|
+
const changed = changedKeys(current?.flow, merged);
|
|
438
|
+
const noop = changed.length === 0;
|
|
439
|
+
const floorsOk = floors.failures.length === 0;
|
|
440
|
+
|
|
441
|
+
if (!write || noop || !floorsOk) {
|
|
442
|
+
const stdout = json
|
|
443
|
+
? JSON.stringify(buildJson({ changed, merged, floors, writtenPath: null, noop }), null, 2)
|
|
444
|
+
: formatHuman({ changed, merged, floors, wrote: false, writeRequested: write, noop });
|
|
445
|
+
return { code: floorsOk ? 0 : 1, stdout, stderr: floorsOk ? '' : 'set-flow: arming floors refused — nothing written' };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const { writtenPath } = writeConfig(cwd, after, ctx);
|
|
449
|
+
const fileBody = serializeConfig(after);
|
|
450
|
+
const stdout = json
|
|
451
|
+
? JSON.stringify(buildJson({ changed, merged, floors, writtenPath, noop: false }), null, 2)
|
|
452
|
+
: formatHuman({ changed, merged, floors, wrote: true, writeRequested: true, noop: false, fileBody });
|
|
453
|
+
return { code: 0, stdout, stderr: '' };
|
|
454
|
+
} catch (err) {
|
|
455
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `set-flow: ${err.message}` };
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
460
|
+
if (isDirectRun) {
|
|
461
|
+
const r = main(process.argv.slice(2));
|
|
462
|
+
if (r.stdout) console.log(r.stdout);
|
|
463
|
+
if (r.stderr) console.error(r.stderr);
|
|
464
|
+
process.exit(r.code);
|
|
465
|
+
}
|
|
@@ -1232,8 +1232,14 @@ const observedPhrase = (harness) =>
|
|
|
1232
1232
|
? `observed ${HARNESS_BIN} ${harness.version}`
|
|
1233
1233
|
: `the installed ${HARNESS_BIN} version could not be determined — ${harness.reason}`;
|
|
1234
1234
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1235
|
+
// The null-guarded floor shape (FLOOR-NULL-COERCION / the FLOW-VERSION-FLOORS discipline): a bare
|
|
1236
|
+
// `>= 0` relational coerces compareSemver's null (unparseable EITHER side) to a PASS — an
|
|
1237
|
+
// unverifiable capability must fail closed, so the null is checked before the relational.
|
|
1238
|
+
const supportsCredentialDenial = (harness) => {
|
|
1239
|
+
if (harness.version === null) return false;
|
|
1240
|
+
const cmp = compareSemver(harness.version, CREDENTIALS_DENY_SINCE);
|
|
1241
|
+
return cmp !== null && cmp >= 0;
|
|
1242
|
+
};
|
|
1237
1243
|
|
|
1238
1244
|
// effectiveAutonomyLevel(resolved) → the ONE global level the (global, static) settings file renders.
|
|
1239
1245
|
// The autonomy policy is per-activity (Decision 2), but .claude/settings.json is global and the
|