@sabaiway/agent-workflow-kit 1.25.0 → 1.27.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 +75 -0
- package/README.md +13 -7
- package/SKILL.md +88 -28
- package/bin/install.mjs +74 -29
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/changelog-skeleton.md +23 -0
- package/references/agents/gate-triage.md +23 -0
- package/references/agents/mechanical-sweep.md +20 -0
- package/references/contracts.md +1 -0
- package/references/scripts/archive-decisions.mjs +424 -0
- package/references/scripts/archive-decisions.test.mjs +365 -0
- package/references/scripts/install-git-hooks.mjs +1 -0
- package/references/templates/agent_rules.md +1 -0
- package/references/templates/gates.json +4 -0
- package/tools/cheap-agents.mjs +239 -0
- package/tools/commands.mjs +29 -6
- package/tools/family-members.mjs +2 -1
- package/tools/family-registry.mjs +111 -13
- package/tools/known-footprint.mjs +3 -0
- package/tools/labels.mjs +13 -0
- package/tools/procedures.mjs +19 -0
- package/tools/recipes.mjs +56 -17
- package/tools/renderers.mjs +12 -1
- package/tools/run-gates.mjs +299 -0
- package/tools/semver-lite.mjs +23 -0
- package/tools/setup-backends.mjs +143 -6
- package/tools/view-model.mjs +11 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-gates.mjs — the generic project gate runner behind `/agent-workflow-kit gates`.
|
|
3
|
+
//
|
|
4
|
+
// A project declares its verification gates ONCE in docs/ai/gates.json (seeded from
|
|
5
|
+
// references/templates/gates.json — the orchestration.json pattern: bootstrap seeds it, upgrade
|
|
6
|
+
// ensures-if-missing, the file stays hand-editable). This runner batches them: read the
|
|
7
|
+
// declaration, run each gate as ONE bash command line from the project root, print a per-gate
|
|
8
|
+
// PASS/FAIL table plus ONE machine-readable summary line, and exit 0 iff every selected gate is
|
|
9
|
+
// green. A failing gate's own output is preserved verbatim (triage without re-running); a green
|
|
10
|
+
// gate's output is not echoed — the table + summary line are the report. `--only <id>`
|
|
11
|
+
// (repeatable) re-runs a subset; an unknown id is a loud usage error.
|
|
12
|
+
//
|
|
13
|
+
// The declaration names WHAT to check — never who executes it: the schema has no lane/model/
|
|
14
|
+
// routing fields and rejects unknown keys loudly. Trust posture (stated in the template _README
|
|
15
|
+
// too): the runner executes the project's OWN declared commands with the caller's privileges —
|
|
16
|
+
// a batching convenience over commands the project already runs by hand, not a sandbox.
|
|
17
|
+
//
|
|
18
|
+
// Honest outcomes — each distinct, never a silent green (the exit-code table + the summary-line
|
|
19
|
+
// schema are pinned by run-gates.test.mjs):
|
|
20
|
+
// 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·
|
|
21
|
+
// 5 malformed/invalid declaration · 6 bash unavailable. Gate `cmd` lines are BASH command
|
|
22
|
+
// lines (brace/glob expansion); a host without bash gets the loud exit-6 preflight error,
|
|
23
|
+
// never a silent reinterpretation under another shell.
|
|
24
|
+
//
|
|
25
|
+
// The runner itself WRITES NOTHING. Dependency-free, Node >= 18. No side effects on import.
|
|
26
|
+
|
|
27
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
// The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
|
|
33
|
+
// user can open (the orchestration-config CONFIG_REL idiom).
|
|
34
|
+
export const GATES_REL = 'docs/ai/gates.json';
|
|
35
|
+
|
|
36
|
+
// The full exit-code table — one distinct code per honest outcome (never a silent green).
|
|
37
|
+
export const EXIT = Object.freeze({
|
|
38
|
+
ok: 0,
|
|
39
|
+
fail: 1,
|
|
40
|
+
usage: 2,
|
|
41
|
+
missing: 3,
|
|
42
|
+
empty: 4,
|
|
43
|
+
malformed: 5,
|
|
44
|
+
noBash: 6,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A tagged failure carrying its process exit code (the shared orchestration-config idiom).
|
|
48
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
49
|
+
|
|
50
|
+
const GATE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
51
|
+
const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
|
|
52
|
+
const NO_FAILED_IDS = '-';
|
|
53
|
+
const SPAWN_FAILED_CODE = -1;
|
|
54
|
+
const MAX_GATE_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
const USAGE = [
|
|
57
|
+
'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--help]',
|
|
58
|
+
'',
|
|
59
|
+
`Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
|
|
60
|
+
'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
|
|
61
|
+
`Exit codes: 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·`,
|
|
62
|
+
'5 malformed/invalid declaration · 6 bash unavailable.',
|
|
63
|
+
].join('\n');
|
|
64
|
+
|
|
65
|
+
// ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
|
|
66
|
+
|
|
67
|
+
// Validate a parsed gates.json object. Strict: only `_README` (string) + `gates` (array of
|
|
68
|
+
// { id, title, cmd }) are allowed; unknown keys anywhere are rejected loudly — the declaration
|
|
69
|
+
// names WHAT to check, never lanes/models/routing. Returns the validated gates array.
|
|
70
|
+
export const validateDeclaration = (parsed) => {
|
|
71
|
+
const reject = (reason) => {
|
|
72
|
+
throw fail(EXIT.malformed, `${GATES_REL}: ${reason}`);
|
|
73
|
+
};
|
|
74
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
75
|
+
reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd }, ...] }');
|
|
76
|
+
}
|
|
77
|
+
for (const key of Object.keys(parsed)) {
|
|
78
|
+
if (key !== '_README' && key !== 'gates') reject(`unknown top-level key "${key}" (allowed: _README, gates)`);
|
|
79
|
+
}
|
|
80
|
+
if (parsed._README !== undefined && typeof parsed._README !== 'string') reject('"_README" must be a string');
|
|
81
|
+
if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd }');
|
|
82
|
+
const seenIds = new Set();
|
|
83
|
+
parsed.gates.forEach((gate, index) => {
|
|
84
|
+
const at = `gates[${index}]`;
|
|
85
|
+
if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
86
|
+
reject(`${at}: must be an object { id, title, cmd }`);
|
|
87
|
+
}
|
|
88
|
+
for (const key of Object.keys(gate)) {
|
|
89
|
+
if (!GATE_KEYS.includes(key)) {
|
|
90
|
+
reject(`${at}: unknown key "${key}" (allowed: id, title, cmd — gates declare WHAT to check, never lane/model/routing)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const key of GATE_KEYS) {
|
|
94
|
+
if (typeof gate[key] !== 'string' || gate[key].trim() === '') {
|
|
95
|
+
reject(`${at}: "${key}" must be a non-empty string`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (/[\r\n]/.test(gate.cmd)) {
|
|
99
|
+
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`);
|
|
100
|
+
}
|
|
101
|
+
if (!GATE_ID_RE.test(gate.id)) reject(`${at}: id "${gate.id}" must be kebab-case (lowercase [a-z0-9] groups separated by "-")`);
|
|
102
|
+
if (seenIds.has(gate.id)) reject(`${at}: duplicate id "${gate.id}"`);
|
|
103
|
+
seenIds.add(gate.id);
|
|
104
|
+
});
|
|
105
|
+
return parsed.gates;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── declaration IO ────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
// Load the declaration from <cwd>/docs/ai/gates.json. A truly-absent file is the DISTINCT
|
|
111
|
+
// `missing` outcome (exit 3 upstream, with the recovery named) — never an error throw; anything
|
|
112
|
+
// present-but-unreadable / malformed / schema-invalid throws the loud exit-5 failure. lstat does
|
|
113
|
+
// not follow links, so a dangling symlink reads as present and its read failure surfaces loudly
|
|
114
|
+
// (no-silent-failures Hard Constraint — the loadConfig idiom).
|
|
115
|
+
export const loadDeclaration = (cwd, { readFile = readFileSync, lstat = lstatSync } = {}) => {
|
|
116
|
+
const full = join(cwd, GATES_REL);
|
|
117
|
+
try {
|
|
118
|
+
lstat(full);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err && err.code === 'ENOENT') return { outcome: 'missing' };
|
|
121
|
+
throw fail(EXIT.malformed, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
122
|
+
}
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = readFile(full, 'utf8');
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw fail(EXIT.malformed, `${GATES_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
128
|
+
}
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw fail(EXIT.malformed, `${GATES_REL}: malformed JSON (${err.message})`);
|
|
134
|
+
}
|
|
135
|
+
return { outcome: 'loaded', gates: validateDeclaration(parsed) };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ── gate selection (--only) ───────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
// Resolve the `--only` subset against the declared gates: declaration order is preserved,
|
|
141
|
+
// duplicates collapse, an unknown id is a LOUD usage error naming the declared ids.
|
|
142
|
+
export const selectGates = (gates, onlyIds) => {
|
|
143
|
+
if (onlyIds.length === 0) return gates;
|
|
144
|
+
const declared = new Set(gates.map((gate) => gate.id));
|
|
145
|
+
const unknown = onlyIds.filter((id) => !declared.has(id));
|
|
146
|
+
if (unknown.length > 0) {
|
|
147
|
+
throw fail(
|
|
148
|
+
EXIT.usage,
|
|
149
|
+
`--only: unknown gate id(s): ${unknown.join(', ')} (declared: ${gates.map((gate) => gate.id).join(', ')})`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const wanted = new Set(onlyIds);
|
|
153
|
+
return gates.filter((gate) => wanted.has(gate.id));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ── the bash spawn (the ONE real-process boundary; injectable for hermetic tests) ─────
|
|
157
|
+
|
|
158
|
+
// Spawn one gate cmd via bash from the project root. `cmd` is a BASH command line by contract
|
|
159
|
+
// (the declaration's _README states it): this repo's own gate matrix needs brace+glob expansion,
|
|
160
|
+
// which /bin/sh does not perform — hence bash explicitly, never the platform default shell.
|
|
161
|
+
export const spawnGateViaBash = (cmd, cwd) =>
|
|
162
|
+
spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
|
|
163
|
+
|
|
164
|
+
// The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
|
|
165
|
+
// so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
|
|
166
|
+
export const BASH_PROBE_CMD = 'true';
|
|
167
|
+
|
|
168
|
+
// ── run + report ──────────────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const formatSeconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
171
|
+
const trimTrailingNewline = (text) => text.replace(/\n$/, '');
|
|
172
|
+
|
|
173
|
+
// Run the selected gates sequentially (declaration order). A green gate logs one PASS line; a
|
|
174
|
+
// failing gate logs FAIL + its captured stdout/stderr VERBATIM (triage without re-running).
|
|
175
|
+
export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log }) => {
|
|
176
|
+
const results = [];
|
|
177
|
+
for (const gate of gates) {
|
|
178
|
+
log(`── ${gate.id} — ${gate.title}`);
|
|
179
|
+
const startedAt = now();
|
|
180
|
+
const res = spawn(gate.cmd, cwd);
|
|
181
|
+
const elapsedMs = now() - startedAt;
|
|
182
|
+
const spawnError = res.error ? `spawn error: ${res.error.code || res.error.message}` : null;
|
|
183
|
+
const ok = spawnError === null && res.status === 0;
|
|
184
|
+
const code = spawnError === null ? res.status : SPAWN_FAILED_CODE;
|
|
185
|
+
results.push({ id: gate.id, title: gate.title, ok, code, elapsedMs });
|
|
186
|
+
if (ok) {
|
|
187
|
+
log(` PASS (${formatSeconds(elapsedMs)})`);
|
|
188
|
+
} else {
|
|
189
|
+
log(` FAIL exit=${code} (${formatSeconds(elapsedMs)})`);
|
|
190
|
+
if (res.stdout) log(trimTrailingNewline(res.stdout));
|
|
191
|
+
if (res.stderr) log(trimTrailingNewline(res.stderr));
|
|
192
|
+
if (spawnError) log(` ${spawnError}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// The per-gate PASS/FAIL table (printed after every gate ran — failures never stop the matrix).
|
|
199
|
+
export const formatTable = (results) => {
|
|
200
|
+
const idWidth = Math.max(...results.map((result) => result.id.length), 'gate'.length);
|
|
201
|
+
const pad = (text) => text + ' '.repeat(idWidth - text.length);
|
|
202
|
+
const lines = ['', `${pad('gate')} result`];
|
|
203
|
+
for (const result of results) {
|
|
204
|
+
lines.push(`${pad(result.id)} ${result.ok ? 'PASS' : `FAIL (exit ${result.code})`}`);
|
|
205
|
+
}
|
|
206
|
+
return lines;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// The ONE machine-readable summary line — always the LAST line printed for every non-usage
|
|
210
|
+
// outcome. Schema (pinned by tests): status ∈ ok|fail|missing|empty|malformed|no-bash.
|
|
211
|
+
export const composeSummaryLine = ({ status, results = [] }) => {
|
|
212
|
+
const passed = results.filter((result) => result.ok).length;
|
|
213
|
+
const failed = results.filter((result) => !result.ok);
|
|
214
|
+
const failedIds = failed.length > 0 ? failed.map((result) => result.id).join(',') : NO_FAILED_IDS;
|
|
215
|
+
return `[run-gates] status=${status} gates=${results.length} passed=${passed} failed=${failed.length} failed_ids=${failedIds}`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
const parseArgs = (argv) => {
|
|
221
|
+
const opts = { cwd: null, only: [], help: false };
|
|
222
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
223
|
+
const arg = argv[i];
|
|
224
|
+
if (arg === '--help' || arg === '-h') {
|
|
225
|
+
opts.help = true;
|
|
226
|
+
} else if (arg === '--cwd') {
|
|
227
|
+
i += 1;
|
|
228
|
+
if (argv[i] === undefined) throw fail(EXIT.usage, '--cwd requires a directory argument');
|
|
229
|
+
opts.cwd = argv[i];
|
|
230
|
+
} else if (arg === '--only') {
|
|
231
|
+
i += 1;
|
|
232
|
+
if (argv[i] === undefined) throw fail(EXIT.usage, '--only requires a gate id argument');
|
|
233
|
+
opts.only.push(argv[i]);
|
|
234
|
+
} else {
|
|
235
|
+
throw fail(EXIT.usage, `unknown argument "${arg}"\n${USAGE}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return opts;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// The full CLI, dependency-injected for hermetic tests. Returns the process exit code; the two
|
|
242
|
+
// output sinks split human-facing report (log) from error channel (logError). The summary line is
|
|
243
|
+
// emitted via `log` as the final line of every non-usage outcome.
|
|
244
|
+
export const runCli = (argv, deps = {}) => {
|
|
245
|
+
const {
|
|
246
|
+
cwd = process.cwd(),
|
|
247
|
+
log = console.log,
|
|
248
|
+
logError = console.error,
|
|
249
|
+
spawn = spawnGateViaBash,
|
|
250
|
+
readFile,
|
|
251
|
+
lstat,
|
|
252
|
+
now,
|
|
253
|
+
} = deps;
|
|
254
|
+
try {
|
|
255
|
+
const opts = parseArgs(argv);
|
|
256
|
+
if (opts.help) {
|
|
257
|
+
log(USAGE);
|
|
258
|
+
return EXIT.ok;
|
|
259
|
+
}
|
|
260
|
+
const projectDir = opts.cwd ?? cwd;
|
|
261
|
+
const declaration = loadDeclaration(projectDir, { readFile, lstat });
|
|
262
|
+
if (declaration.outcome === 'missing') {
|
|
263
|
+
logError(`[run-gates] no gate declaration found at ${GATES_REL} — nothing was run.`);
|
|
264
|
+
logError(
|
|
265
|
+
`Recovery: create ${GATES_REL} from the gates.json template (references/templates/gates.json — ` +
|
|
266
|
+
'bootstrap seeds it; /agent-workflow-kit upgrade re-seeds a missing one), declare { id, title, cmd } gates, re-run.',
|
|
267
|
+
);
|
|
268
|
+
log(composeSummaryLine({ status: 'missing' }));
|
|
269
|
+
return EXIT.missing;
|
|
270
|
+
}
|
|
271
|
+
if (declaration.gates.length === 0) {
|
|
272
|
+
logError(`[run-gates] ${GATES_REL} declares an empty "gates" list — nothing to run (add { id, title, cmd } entries).`);
|
|
273
|
+
log(composeSummaryLine({ status: 'empty' }));
|
|
274
|
+
return EXIT.empty;
|
|
275
|
+
}
|
|
276
|
+
const selected = selectGates(declaration.gates, opts.only);
|
|
277
|
+
const probe = spawn(BASH_PROBE_CMD, projectDir);
|
|
278
|
+
if (probe.error && probe.error.code === 'ENOENT') {
|
|
279
|
+
logError(
|
|
280
|
+
'[run-gates] bash is not available on this host — gate cmd lines are BASH command lines ' +
|
|
281
|
+
'(brace/glob expansion); refusing to silently reinterpret them under another shell. Install bash and re-run.',
|
|
282
|
+
);
|
|
283
|
+
log(composeSummaryLine({ status: 'no-bash' }));
|
|
284
|
+
return EXIT.noBash;
|
|
285
|
+
}
|
|
286
|
+
const results = runGates(selected, { cwd: projectDir, spawn, log, now });
|
|
287
|
+
for (const line of formatTable(results)) log(line);
|
|
288
|
+
const allGreen = results.every((result) => result.ok);
|
|
289
|
+
log(composeSummaryLine({ status: allGreen ? 'ok' : 'fail', results }));
|
|
290
|
+
return allGreen ? EXIT.ok : EXIT.fail;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
logError(`[run-gates] ${err.message}`);
|
|
293
|
+
if (err.exitCode === EXIT.malformed) log(composeSummaryLine({ status: 'malformed' }));
|
|
294
|
+
return err.exitCode ?? EXIT.fail;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
299
|
+
if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// semver-lite.mjs — the dependency-free semver LEAF shared by the npx installer (the never-downgrade
|
|
2
|
+
// gate, bin/install.mjs) and the family registry (the bridge freshness probe).
|
|
3
|
+
//
|
|
4
|
+
// Parses the leading `x.y.z` only (prerelease/build ignored — family versions are plain).
|
|
5
|
+
// compareSemver returns -1 | 0 | 1, or null when EITHER side is unparseable. The null contract is
|
|
6
|
+
// load-bearing at both call sites: a legacy install predates any version stamp (→ no gate), and a
|
|
7
|
+
// freshness probe that cannot parse a version must degrade to "unknown" — never to a false ordering
|
|
8
|
+
// claim in either direction (INV-B). No `let`: a small functional comparison (AGENTS.md §2.3).
|
|
9
|
+
//
|
|
10
|
+
// Pure, no imports, no side effects, Node >= 18.
|
|
11
|
+
|
|
12
|
+
export const parseSemver = (str) => {
|
|
13
|
+
const m = typeof str === 'string' ? str.trim().match(/^(\d+)\.(\d+)\.(\d+)/) : null;
|
|
14
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const compareSemver = (a, b) => {
|
|
18
|
+
const pa = parseSemver(a);
|
|
19
|
+
const pb = parseSemver(b);
|
|
20
|
+
if (!pa || !pb) return null;
|
|
21
|
+
const firstDiff = [0, 1, 2].map((i) => (pa[i] === pb[i] ? 0 : pa[i] < pb[i] ? -1 : 1)).find((c) => c !== 0);
|
|
22
|
+
return firstDiff ?? 0;
|
|
23
|
+
};
|
package/tools/setup-backends.mjs
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
// symlinks. Binary install + the interactive subscription login stay GUIDED (printed, never run) —
|
|
6
6
|
// guideFor() supplies the exact commands. The read-only detector is the reader; this is the writer.
|
|
7
7
|
//
|
|
8
|
+
// Two write modes: plain setup (opt-in, in-agent — the ONLY path that ever PLACES a bridge) and the
|
|
9
|
+
// refresh-only driver (`--refresh-placed` / refreshPlacedBridges, run by `init` and Mode: upgrade) —
|
|
10
|
+
// it refreshes what setup already placed and re-links wrappers, states an absent bridge as a skip
|
|
11
|
+
// (never a first placement), and never downgrades: a placed bridge newer than the kit-bundled mirror
|
|
12
|
+
// is a stated skip naming the kit update (guarded again at the write boundary in placeSkill).
|
|
13
|
+
//
|
|
8
14
|
// Safety posture (AD-011): drive off the decoupled axes (a per-bindir wrapper check + an independent
|
|
9
15
|
// skill-dir inspection), never the detector's collapsed readiness. REFRESH only a dir we provably own
|
|
10
16
|
// (valid manifest, name+kind match) or one that is absent/empty; STOP on anything else (a marker fs
|
|
@@ -27,6 +33,7 @@ import os from 'node:os';
|
|
|
27
33
|
import { KNOWN_BACKENDS, detectBackend, detectBackends, resolveDir, guideFor, READY } from './detect-backends.mjs';
|
|
28
34
|
import { copyTreeRefresh, linkManaged } from './fs-safe.mjs';
|
|
29
35
|
import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
|
|
36
|
+
import { compareSemver } from './semver-lite.mjs';
|
|
30
37
|
|
|
31
38
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
39
|
// bridges/ ships beside tools/ in both the repo and the installed kit, so this resolves in both.
|
|
@@ -215,11 +222,43 @@ const inspectDst = (dst, source, fs) => {
|
|
|
215
222
|
|
|
216
223
|
// ── mutating primitives ───────────────────────────────────────────────────────
|
|
217
224
|
|
|
225
|
+
// The one kit-update recovery the never-downgrade skip names (the bundle only gets newer via the kit).
|
|
226
|
+
const KIT_UPDATE_RECOVERY = 'npx @sabaiway/agent-workflow-kit@latest init';
|
|
227
|
+
|
|
228
|
+
// Crash-safe placed-version read for the downgrade guard: an unreadable placed version yields null →
|
|
229
|
+
// compareSemver returns null → no ordering claim → the refresh stays allowed (legacy repair). The
|
|
230
|
+
// guard forbids only a PROVEN downgrade; it never turns an unreadable stamp into a false refusal.
|
|
231
|
+
const readPlacedVersionSafe = (skillDir, deps = {}) => {
|
|
232
|
+
const readVersion = deps.readVersion ?? readAuthoritativeVersion;
|
|
233
|
+
try {
|
|
234
|
+
return readVersion(skillDir).version ?? null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Never-downgrade guard at the WRITE boundary (belt to planFor's braces — both read the versions
|
|
241
|
+
// fresh): a placed bridge NEWER than the kit-bundled mirror (an older npx runner / a kit downgrade)
|
|
242
|
+
// is a typed STOP naming the kit update, never a copy. Load-bearing once init/upgrade run the
|
|
243
|
+
// refresh automatically.
|
|
244
|
+
const downgradeReason = (placed, bundled) =>
|
|
245
|
+
`placed bridge is v${placed} but this kit bundles the older v${bundled} — refusing to downgrade; ` +
|
|
246
|
+
`update the kit first: ${KIT_UPDATE_RECOVERY}`;
|
|
247
|
+
const assertNoDowngrade = (plan, deps = {}) => {
|
|
248
|
+
if (plan.action !== 'refresh') return;
|
|
249
|
+
const placed = readPlacedVersionSafe(plan.skillDir, deps);
|
|
250
|
+
const manifest = readBundledManifest(plan.bundleDir, deps);
|
|
251
|
+
const bundled = typeof manifest.version === 'string' ? manifest.version : null;
|
|
252
|
+
// `wouldDowngrade` lets a caller classify this STOP structurally (never by matching message text).
|
|
253
|
+
if (compareSemver(placed, bundled) === 1) throw stop(downgradeReason(placed, bundled), { skillDir: plan.skillDir, wouldDowngrade: true });
|
|
254
|
+
};
|
|
255
|
+
|
|
218
256
|
// Place/refresh the bundled bridge skill. Re-inspects before writing (never trusts a stale plan).
|
|
219
257
|
export const placeSkill = (name, deps = {}) => {
|
|
220
258
|
const entry = registryEntry(name);
|
|
221
259
|
if (!entry) throw stop(`unknown backend: ${name}`);
|
|
222
260
|
const plan = inspectSkillDir(entry, deps);
|
|
261
|
+
assertNoDowngrade(plan, deps);
|
|
223
262
|
copyTreeRefresh(plan.bundleDir, plan.skillDir, plan.skillDir, fsDeps(deps));
|
|
224
263
|
return plan;
|
|
225
264
|
};
|
|
@@ -326,6 +365,17 @@ export const planFor = (backend, deps = {}) => {
|
|
|
326
365
|
// setup never invents a second version reader. A place / absent-prior shows no arrow (never "vnull").
|
|
327
366
|
version = typeof bundledManifest.version === 'string' ? bundledManifest.version : null;
|
|
328
367
|
priorVersion = place.action === 'refresh' ? readVersion(skillDir).version ?? null : null;
|
|
368
|
+
// Never-downgrade (predicted here so a --dry-run is faithful and a real run stops before any
|
|
369
|
+
// write; placeSkill re-checks at the write boundary). An unparseable side → compareSemver null →
|
|
370
|
+
// no ordering claim → the refresh stays allowed (legacy repair). `wouldDowngrade` lets the
|
|
371
|
+
// refresh-only driver report this as a stated skip rather than a failure.
|
|
372
|
+
if (place.action === 'refresh' && compareSemver(priorVersion, version) === 1) {
|
|
373
|
+
return {
|
|
374
|
+
name: entry.name, skillDir, bindir, platform, place, links: [], guides: [], bindirHint: null,
|
|
375
|
+
outcome: 'stop', wouldDowngrade: true, version, priorVersion,
|
|
376
|
+
reason: downgradeReason(priorVersion, version),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
329
379
|
const derived = deriveLinks(bundledManifest, skillDir);
|
|
330
380
|
// Preflight the BUNDLE sources (what we will copy → link). After place, the skill source IS the
|
|
331
381
|
// bundle source, so checking it here makes a dry-run faithfully predict linkWrappers instead of
|
|
@@ -401,6 +451,77 @@ const formatBackend = (plan, applied) => {
|
|
|
401
451
|
return lines.join('\n');
|
|
402
452
|
};
|
|
403
453
|
|
|
454
|
+
// ── the refresh-only driver (the init/upgrade delivery hook — refresh, never place) ──
|
|
455
|
+
|
|
456
|
+
// Refresh-only apply: re-inspects at APPLY time and copies ONLY when the fresh inspection still says
|
|
457
|
+
// `refresh` (TOCTOU: a dir gone absent between plan and apply is a reported skip, NEVER a first
|
|
458
|
+
// placement — placement stays setup's opt-in step, AD-009/AD-011), and re-asserts the downgrade
|
|
459
|
+
// guard against a freshly-read placed version (a NEWER bridge landing between plan and apply is a
|
|
460
|
+
// typed STOP, never overwritten). No cross-process lock beyond that: like every mutating primitive
|
|
461
|
+
// here (see applyBackend), concurrent writers are resolved by CONVERGE-ON-RE-RUN — the Phase-1
|
|
462
|
+
// freshness probe flags any interleaving loser as behind and the next init/upgrade/setup repairs it;
|
|
463
|
+
// a per-bridge lock file would be new machinery for a user-driven, self-healing race.
|
|
464
|
+
const refreshSkillOnly = (entry, deps = {}) => {
|
|
465
|
+
const fresh = inspectSkillDir(entry, deps);
|
|
466
|
+
if (fresh.action !== 'refresh') return false;
|
|
467
|
+
assertNoDowngrade(fresh, deps);
|
|
468
|
+
copyTreeRefresh(fresh.bundleDir, fresh.skillDir, fresh.skillDir, fsDeps(deps));
|
|
469
|
+
return true;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const NOT_PLACED_LINE = 'skipped — not placed (placement is opt-in: /agent-workflow-kit setup)';
|
|
473
|
+
const stripPrefix = (message) => message.replace('[agent-workflow-kit] ', '');
|
|
474
|
+
|
|
475
|
+
// Refresh every ALREADY-PLACED bridge from the kit's bundled copies and re-link its wrappers (a newer
|
|
476
|
+
// bridge can add one). One reported outcome per backend — never a crash; a per-backend STOP/error
|
|
477
|
+
// becomes a `failed` result. `line` is the tool-composed sentence callers print VERBATIM (the agent
|
|
478
|
+
// pastes, never composes — deterministic-first). Outcomes: refreshed · already-current · not-placed
|
|
479
|
+
// (absent — NEVER placed here) · kept-newer (INV-D stated skip) · unsupported (win32) · failed.
|
|
480
|
+
export const refreshPlacedBridges = (deps = {}, names = KNOWN_BACKENDS.map((b) => b.name)) =>
|
|
481
|
+
names.map((name) => {
|
|
482
|
+
const canonical = resolveBackendName(name);
|
|
483
|
+
// The whole per-backend path is crash-proof, not just the apply: planFor itself can throw on a
|
|
484
|
+
// truly unexpected dependency error, and one broken backend must never abort the others' report.
|
|
485
|
+
try {
|
|
486
|
+
const plan = planFor(name, deps);
|
|
487
|
+
if (plan.outcome === 'unsupported') {
|
|
488
|
+
return { name: plan.name, outcome: 'unsupported', line: ` ${plan.name}: skipped — ${plan.reason}` };
|
|
489
|
+
}
|
|
490
|
+
if (plan.wouldDowngrade) {
|
|
491
|
+
return { name: plan.name, outcome: 'kept-newer', line: ` ${plan.name}: skipped — ${stripPrefix(plan.reason)}` };
|
|
492
|
+
}
|
|
493
|
+
// An absent/empty skill dir is `not placed` REGARDLESS of any later plan trouble (a foreign
|
|
494
|
+
// wrapper conflict, a bundle-source problem): the refresh-only driver skips an unplaced bridge
|
|
495
|
+
// before those axes matter, so it must never claim "could not refresh" what it would not touch.
|
|
496
|
+
if (plan.place && plan.place.action !== 'refresh') {
|
|
497
|
+
return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
|
|
498
|
+
}
|
|
499
|
+
if (plan.outcome === 'stop' || plan.outcome === 'error') {
|
|
500
|
+
return { name: plan.name, outcome: 'failed', line: ` ${plan.name}: could not refresh — ${stripPrefix(plan.reason)}; recover with /agent-workflow-kit setup` };
|
|
501
|
+
}
|
|
502
|
+
if (!refreshSkillOnly(registryEntry(plan.name), deps)) {
|
|
503
|
+
return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
|
|
504
|
+
}
|
|
505
|
+
const manifest = readBundledManifest(plan.place.bundleDir, deps);
|
|
506
|
+
linkWrappers(plan.skillDir, manifest, { ...deps, bindir: plan.bindir, platform: plan.platform });
|
|
507
|
+
const current = plan.version !== null && plan.priorVersion === plan.version;
|
|
508
|
+
// The equal-version line still states the copy that ran (repair-on-rerun) — the tool never
|
|
509
|
+
// reports a mutation-free "already current" while it re-synced files underneath.
|
|
510
|
+
return {
|
|
511
|
+
name: plan.name,
|
|
512
|
+
outcome: current ? 'already-current' : 'refreshed',
|
|
513
|
+
line: ` ${plan.name}: ${current ? `already current${versionLabel(plan)} — files re-synced from the bundled copy` : `refreshed${versionLabel(plan)}`}`,
|
|
514
|
+
};
|
|
515
|
+
} catch (err) {
|
|
516
|
+
// A downgrade STOP raised at the write boundary (a newer bridge landed between plan and apply)
|
|
517
|
+
// is the same stated skip as the planned one — classified structurally via the typed field.
|
|
518
|
+
if (err.wouldDowngrade) {
|
|
519
|
+
return { name: canonical, outcome: 'kept-newer', line: ` ${canonical}: skipped — ${stripPrefix(err.message)}` };
|
|
520
|
+
}
|
|
521
|
+
return { name: canonical, outcome: 'failed', line: ` ${canonical}: could not refresh — ${stripPrefix(err.message)}; recover with /agent-workflow-kit setup` };
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
404
525
|
// ── close-the-loop: surface versions + a proactive recipe offer ───────────────────
|
|
405
526
|
|
|
406
527
|
// The closing pointer: setup surfaces only the bridge it touched; the full family + deployment version
|
|
@@ -438,23 +559,28 @@ export const proactiveReviewOffer = (before, after) => {
|
|
|
438
559
|
|
|
439
560
|
// ── CLI ─────────────────────────────────────────────────────────────────────────
|
|
440
561
|
|
|
441
|
-
const USAGE = `usage: setup-backends [<backend>] [--bindir <path>] [--dry-run] [--help]
|
|
562
|
+
const USAGE = `usage: setup-backends [<backend>] [--bindir <path>] [--dry-run | --refresh-placed] [--help]
|
|
442
563
|
|
|
443
|
-
<backend>
|
|
444
|
-
--bindir
|
|
445
|
-
--dry-run
|
|
446
|
-
--
|
|
564
|
+
<backend> codex | agy | antigravity | codex-cli-bridge | antigravity-cli-bridge (default: all)
|
|
565
|
+
--bindir where to link the wrappers (default: ~/.local/bin)
|
|
566
|
+
--dry-run print the plan; change nothing
|
|
567
|
+
--refresh-placed refresh ONLY the already-placed bridges from the kit's bundled copies —
|
|
568
|
+
an absent bridge is a stated skip (never placed), a placed bridge newer
|
|
569
|
+
than the bundle is a stated skip (never downgraded). The refresh-only
|
|
570
|
+
mode init/upgrade run; does not combine with --dry-run.
|
|
571
|
+
--help, -h this help
|
|
447
572
|
|
|
448
573
|
Places the bundled bridge skill + links its wrappers (idempotent; refuses to clobber a non-symlink).
|
|
449
574
|
Binary install + the interactive subscription login stay manual — the printed guidance has the exact
|
|
450
575
|
commands. The detector ("/agent-workflow-kit backends") stays read-only; this is the only writer.`;
|
|
451
576
|
|
|
452
577
|
const parseArgs = (argv) => {
|
|
453
|
-
const out = { dryRun: false, help: false, bindir: undefined, backend: undefined, bad: null };
|
|
578
|
+
const out = { dryRun: false, refreshPlaced: false, help: false, bindir: undefined, backend: undefined, bad: null };
|
|
454
579
|
for (let i = 0; i < argv.length; i += 1) {
|
|
455
580
|
const a = argv[i];
|
|
456
581
|
if (a === '--help' || a === '-h') out.help = true;
|
|
457
582
|
else if (a === '--dry-run') out.dryRun = true;
|
|
583
|
+
else if (a === '--refresh-placed') out.refreshPlaced = true;
|
|
458
584
|
else if (a === '--bindir') {
|
|
459
585
|
// Do NOT greedily swallow a following flag (e.g. `--bindir --dry-run` must not become
|
|
460
586
|
// bindir="--dry-run" and silently mutate); a missing or flag-like value is a usage error.
|
|
@@ -482,6 +608,7 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
482
608
|
log(USAGE);
|
|
483
609
|
return 0;
|
|
484
610
|
}
|
|
611
|
+
if (args.refreshPlaced && args.dryRun) args.bad = '--refresh-placed does not combine with --dry-run';
|
|
485
612
|
if (args.bad) {
|
|
486
613
|
errlog(args.bad);
|
|
487
614
|
errlog(USAGE);
|
|
@@ -501,6 +628,16 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
|
501
628
|
}
|
|
502
629
|
|
|
503
630
|
const runDeps = { ...deps, bindir: args.bindir ?? deps.bindir };
|
|
631
|
+
|
|
632
|
+
// Refresh-only mode: act on what setup already placed; never place, never downgrade. Every line is
|
|
633
|
+
// tool-composed — callers (init, Mode: upgrade) print/paste them verbatim.
|
|
634
|
+
if (args.refreshPlaced) {
|
|
635
|
+
log('agent-workflow placed-bridge refresh (refresh-only — placement stays opt-in: /agent-workflow-kit setup)');
|
|
636
|
+
const results = refreshPlacedBridges(runDeps, targets);
|
|
637
|
+
for (const r of results) log(r.line);
|
|
638
|
+
return results.some((r) => r.outcome === 'failed') ? 1 : 0;
|
|
639
|
+
}
|
|
640
|
+
|
|
504
641
|
log(args.dryRun ? 'agent-workflow backend setup — DRY RUN (no changes)' : 'agent-workflow backend setup (link-only)');
|
|
505
642
|
// Snapshot review readiness BEFORE applying, so we can offer the recipe only on a true readiness flip.
|
|
506
643
|
const reviewBefore = args.dryRun ? null : reviewReadyCount(runDeps);
|
package/tools/view-model.mjs
CHANGED
|
@@ -21,6 +21,9 @@ const memberVm = (m) => {
|
|
|
21
21
|
notes: m.notes ?? [], // the verbatim caveats — printed as ↳ sub-lines (INV-3: no dedupe)
|
|
22
22
|
behind: Boolean(refresh.behind), // used ONLY for the headline count on the direct CLI (INV-3)
|
|
23
23
|
recommend: refresh.recommend ?? null,
|
|
24
|
+
// The checked-vs-unknown freshness signal (INV-C): 'current' | 'behind' | 'unknown' | 'not-checked'.
|
|
25
|
+
// An envelope predating the field defaults to not-checked (behind stays behind) — never a claim.
|
|
26
|
+
freshness: refresh.freshness ?? (refresh.behind ? 'behind' : 'not-checked'),
|
|
24
27
|
};
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -82,7 +85,14 @@ export const toViewModel = (envelope = {}) => {
|
|
|
82
85
|
return {
|
|
83
86
|
deploymentHead: envelope.deploymentHead ?? null,
|
|
84
87
|
members,
|
|
85
|
-
headline: {
|
|
88
|
+
headline: {
|
|
89
|
+
total: members.length,
|
|
90
|
+
behind: members.filter((m) => m.behind).length,
|
|
91
|
+
// The zero-behind verdict scope (INV-C): checked = a freshness probe RAN and concluded;
|
|
92
|
+
// unknown = it ran but could not conclude (INV-B — blocks the all-current verdict).
|
|
93
|
+
checked: members.filter((m) => m.freshness === 'current' || m.freshness === 'behind').length,
|
|
94
|
+
unknown: members.filter((m) => m.freshness === 'unknown').length,
|
|
95
|
+
},
|
|
86
96
|
bridges: envelope.bridges ? envelope.bridges.map(bridgeVm) : null,
|
|
87
97
|
project: projectVm(envelope.project),
|
|
88
98
|
};
|