@sabaiway/agent-workflow-kit 2.1.0 → 3.0.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 +45 -0
- package/README.md +6 -5
- package/SKILL.md +12 -8
- package/bin/install.mjs +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +95 -4
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +44 -0
- package/migrations/README.md +1 -1
- package/package.json +2 -2
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/commit-guard.md +19 -0
- package/references/modes/core-evidence.md +15 -0
- package/references/modes/coverage-check.md +12 -0
- package/references/modes/doc-parity.md +7 -6
- package/references/modes/gates.md +7 -6
- package/references/modes/grounding.md +1 -2
- package/references/modes/hook.md +1 -1
- package/references/modes/review-state.md +3 -3
- package/references/modes/upgrade.md +6 -6
- package/references/modes/velocity.md +2 -2
- package/references/scripts/archive-decisions.mjs +1 -1
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/tools/ack-write.mjs +1 -1
- package/tools/atomic-write.mjs +2 -2
- package/tools/autonomy-config.mjs +1 -1
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/autonomy-write.mjs +1 -1
- package/tools/bridge-settings-read.mjs +1 -1
- package/tools/bridge-settings.mjs +1 -1
- package/tools/changed-surface.mjs +8 -69
- package/tools/cheap-agents.mjs +2 -2
- package/tools/commands.mjs +17 -10
- package/tools/commit-guard.mjs +167 -0
- package/tools/core-evidence.mjs +914 -0
- package/tools/coverage-check.mjs +260 -0
- package/tools/delegation.mjs +1 -1
- package/tools/detect-backends.mjs +3 -3
- package/tools/doc-parity.mjs +11 -27
- package/tools/engine-source.mjs +1 -1
- package/tools/family-members.mjs +1 -1
- package/tools/family-registry.mjs +1 -1
- package/tools/fs-safe.mjs +1 -1
- package/tools/gate-hook.mjs +1 -1
- package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
- package/tools/grounding.mjs +6 -83
- package/tools/hide-footprint.mjs +1 -1
- package/tools/inject-methodology.mjs +1 -1
- package/tools/known-footprint.mjs +1 -1
- package/tools/labels.mjs +1 -1
- package/tools/lcov.mjs +6 -10
- package/tools/lens-region.mjs +1 -1
- package/tools/manifest/validate.mjs +19 -0
- package/tools/migrate-adr-store.mjs +2 -2
- package/tools/orchestration-config.mjs +1 -1
- package/tools/orchestration-write.mjs +1 -1
- package/tools/presentation.mjs +1 -1
- package/tools/procedures.mjs +2 -2
- package/tools/recipes.mjs +60 -9
- package/tools/recommendations.mjs +63 -7
- package/tools/release-scan.mjs +1 -1
- package/tools/renderers.mjs +1 -1
- package/tools/review-state.mjs +288 -340
- package/tools/run-gates.mjs +216 -92
- package/tools/sandbox-masks.mjs +1 -1
- package/tools/semver-lite.mjs +1 -1
- package/tools/set-autonomy.mjs +1 -1
- package/tools/set-recipe.mjs +1 -1
- package/tools/setup-backends.mjs +1 -1
- package/tools/surface.mjs +1 -1
- package/tools/uninstall.mjs +1 -1
- package/tools/velocity-profile.mjs +2 -2
- package/tools/view-model.mjs +1 -1
- package/references/modes/fold-completeness.md +0 -30
- package/references/modes/review-ledger.md +0 -34
- package/references/templates/verification-profile.json +0 -10
- package/tools/fold-completeness-run.mjs +0 -1120
- package/tools/fold-completeness.mjs +0 -672
- package/tools/review-ledger-core.mjs +0 -428
- package/tools/review-ledger-write.mjs +0 -647
- package/tools/review-ledger.mjs +0 -630
- package/tools/sarif.mjs +0 -52
- package/tools/verification-profile.mjs +0 -219
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// migrate-gates.mjs — the consented LEGACY gates.json migration (strip-the-kit D8). A deployment
|
|
3
|
+
// that predates the hardened core declares gates the stripped kit no longer ships (the
|
|
4
|
+
// review-ledger / fold-completeness checks); this tool migrates the declaration ATOMICALLY and
|
|
5
|
+
// COMPLETELY in one consented step: it REMOVES the canonical legacy entries, EXTENDS the
|
|
6
|
+
// canonical `unit-tests` cmd with the built-in lcov reporters (the D3(d) coverage source), and
|
|
7
|
+
// ADDS the coverage-check gate LAST (removal alone is not a migration — the result must satisfy
|
|
8
|
+
// `run-gates --final` and carry a working commit path).
|
|
9
|
+
//
|
|
10
|
+
// Matching is by the DOCUMENTED cmd forms ONLY (hand-wired history included — never by seed
|
|
11
|
+
// provenance): a legacy entry is `node <path>/review-ledger.mjs --check` or
|
|
12
|
+
// `node <path>/fold-completeness.mjs --check` (quoted or bare path, ONE plain invocation); the
|
|
13
|
+
// canonical unit-tests entry is id `unit-tests` with a cmd starting `node --test `. ANYTHING
|
|
14
|
+
// else is a CUSTOMIZED entry — never auto-touched: the preview reports it loudly and prints the
|
|
15
|
+
// paste-ready recovery so the maintainer applies the intent by hand; the commit guard is NOT to
|
|
16
|
+
// be installed until the declaration is final-run-capable.
|
|
17
|
+
//
|
|
18
|
+
// Write discipline (the family's consented-writer contract): preview (dry-run) is the DEFAULT
|
|
19
|
+
// and writes NOTHING; `--apply` rewrites docs/ai/gates.json via exclusive tmp+rename in the same
|
|
20
|
+
// directory; a symlinked gates.json (or docs/ai parent) is a STOP; a malformed declaration is
|
|
21
|
+
// never written over. `--kit-tools <dir>` names the installed kit's tools directory — the
|
|
22
|
+
// migration writes RESOLVED, QUOTED paths for the coverage-check gate (no runtime guessing).
|
|
23
|
+
//
|
|
24
|
+
// Exit codes: 0 done / preview / nothing-to-migrate; 1 precondition STOP; 2 usage.
|
|
25
|
+
// Dependency-free. No side effects on import.
|
|
26
|
+
|
|
27
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
28
|
+
import { join, resolve, isAbsolute } from 'node:path';
|
|
29
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
30
|
+
import { randomBytes } from 'node:crypto';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
|
|
33
|
+
export const MIGRATE_GATES_STOP = 'MIGRATE_GATES_STOP';
|
|
34
|
+
const stop = (message) =>
|
|
35
|
+
Object.assign(new Error(`[agent-workflow] ${message}`), { name: 'MigrateGatesStop', code: MIGRATE_GATES_STOP, exitCode: 1 });
|
|
36
|
+
const usageFail = (message) => Object.assign(new Error(`[agent-workflow] ${message}`), { exitCode: 2 });
|
|
37
|
+
|
|
38
|
+
export const GATES_REL = join('docs', 'ai', 'gates.json');
|
|
39
|
+
|
|
40
|
+
// The documented canonical forms — ONE plain invocation, quoted or bare path, `--check` at END
|
|
41
|
+
// (the run-gates strict-matcher shape; a masked/compound form is a CUSTOMIZED entry).
|
|
42
|
+
const legacyRe = (basename) => new RegExp(`^node\\s+(?:"(?:[^"]*[/\\\\])?${basename}"|(?:[^\\s"]*[/\\\\])?${basename})\\s+--check$`);
|
|
43
|
+
export const LEGACY_FORMS = Object.freeze([
|
|
44
|
+
{ name: 'review-ledger', re: legacyRe('review-ledger\\.mjs') },
|
|
45
|
+
{ name: 'fold-completeness', re: legacyRe('fold-completeness\\.mjs') },
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// The D3(d) reporter flags the canonical unit-tests cmd gains — lcov at the fixed git-dir path
|
|
49
|
+
// run-gates --final exports, plus an explicit stdout reporter (without it the lcov reporter
|
|
50
|
+
// swallows the human TAP/spec stream).
|
|
51
|
+
export const UNIT_TESTS_COVERAGE_FLAGS =
|
|
52
|
+
'--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
|
|
53
|
+
|
|
54
|
+
// The RETIRED kit-owned git-dir stores the deleted machinery wrote — dead data a consumer's
|
|
55
|
+
// upgrade would otherwise strand forever. The migration cleans them (consented via the preview;
|
|
56
|
+
// ENOENT is a silent no-op; any other unlink error is reported loudly but never fails the
|
|
57
|
+
// migration — the stores are not a gate input).
|
|
58
|
+
export const RETIRED_STORE_BASENAMES = Object.freeze([
|
|
59
|
+
'agent-workflow-review-ledger.jsonl',
|
|
60
|
+
'agent-workflow-review-ledger.v5-orphans.jsonl',
|
|
61
|
+
'agent-workflow-fold-completeness.jsonl',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const trueGitDir = (cwd) => {
|
|
65
|
+
const r = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd, encoding: 'utf8', windowsHide: true });
|
|
66
|
+
return r.error || r.status !== 0 ? null : r.stdout.replace(/\r?\n$/, '');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// findRetiredStores(cwd) → the absolute paths of retired stores PRESENT in the target's git dir.
|
|
70
|
+
export const findRetiredStores = (cwd) => {
|
|
71
|
+
const gitDir = trueGitDir(cwd);
|
|
72
|
+
if (gitDir == null) return [];
|
|
73
|
+
return RETIRED_STORE_BASENAMES.map((name) => join(gitDir, name)).filter((p) => existsSync(p));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const UNIT_TESTS_PREFIX = 'node --test ';
|
|
77
|
+
|
|
78
|
+
// The core-check forms the stripped core anchors on (same strict single-invocation shape as the
|
|
79
|
+
// legacy matcher). Canonicity is PURE path equality against the caller-named kit tools dir —
|
|
80
|
+
// an ABSOLUTE token resolving to the installed tool; run-gates --final does the live realpath
|
|
81
|
+
// check. A cmd that MATCHES the shape but resolves elsewhere (or relatively) is a LOOKALIKE —
|
|
82
|
+
// reported customized, never counted as the core check.
|
|
83
|
+
const CORE_CHECK_RE = { 'coverage-check': legacyRe('coverage-check\\.mjs'), 'review-state': legacyRe('review-state\\.mjs') };
|
|
84
|
+
const coreCheckToken = (cmd) => /^node\s+(?:"([^"]+)"|([^\s"]+))\s+--check$/.exec(cmd.trim())?.slice(1).find(Boolean) ?? null;
|
|
85
|
+
const samePath = (a, b) => {
|
|
86
|
+
try {
|
|
87
|
+
return realpathSync(a) === realpathSync(b);
|
|
88
|
+
} catch {
|
|
89
|
+
return resolve(a) === resolve(b); // an unresolvable side falls back to the lexical compare
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const isCanonicalCoreCheck = (name, cmd, kitToolsDir) => {
|
|
93
|
+
if (!CORE_CHECK_RE[name].test(cmd.trim())) return false;
|
|
94
|
+
const token = coreCheckToken(cmd);
|
|
95
|
+
return token !== null && isAbsolute(token) && samePath(token, join(kitToolsDir, `${name}.mjs`));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// buildMigrationPlan(gates, kitToolsDir) → the PURE migration plan.
|
|
99
|
+
// plan rows: { action: 'keep' | 'remove' | 'extend' | 'move' | 'add', entry, reason }.
|
|
100
|
+
// finalCapable mirrors the run-gates --final acceptance shape: the canonical review-state check
|
|
101
|
+
// must be PRESENT (the checker itself is guaranteed last by the plan) — missing means the result
|
|
102
|
+
// is NOT final-run-capable and the preview says so loudly with the paste-ready candidate.
|
|
103
|
+
export const buildMigrationPlan = (gates, kitToolsDir) => {
|
|
104
|
+
const plan = [];
|
|
105
|
+
const customized = [];
|
|
106
|
+
let unitTestsExtended = false;
|
|
107
|
+
let checkerRow = null;
|
|
108
|
+
let hasReviewState = false;
|
|
109
|
+
const coverageCmd = `node "${join(kitToolsDir, 'coverage-check.mjs')}" --check`;
|
|
110
|
+
for (const gate of gates) {
|
|
111
|
+
const legacy = LEGACY_FORMS.find((f) => f.re.test(gate.cmd.trim()));
|
|
112
|
+
if (legacy) {
|
|
113
|
+
plan.push({ action: 'remove', entry: gate, reason: `the ${legacy.name} check died with its tool (strip-the-kit)` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (isCanonicalCoreCheck('coverage-check', gate.cmd, kitToolsDir)) {
|
|
117
|
+
checkerRow = { action: 'keep', entry: gate, reason: null };
|
|
118
|
+
plan.push(checkerRow);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (isCanonicalCoreCheck('review-state', gate.cmd, kitToolsDir)) {
|
|
122
|
+
hasReviewState = true;
|
|
123
|
+
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (gate.id === 'unit-tests') {
|
|
127
|
+
if (gate.cmd.includes(UNIT_TESTS_COVERAGE_FLAGS)) {
|
|
128
|
+
plan.push({ action: 'keep', entry: gate, reason: null }); // already fully configured
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (gate.cmd.startsWith(UNIT_TESTS_PREFIX) && !/--experimental-test-coverage|--test-reporter/.test(gate.cmd)) {
|
|
132
|
+
const extended = { ...gate, cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} ${gate.cmd.slice(UNIT_TESTS_PREFIX.length)}` };
|
|
133
|
+
plan.push({ action: 'extend', entry: extended, reason: 'the unit-tests cmd gains the built-in lcov reporters (the D3(d) coverage source)' });
|
|
134
|
+
unitTestsExtended = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// npm test / a wrapper / a partial flag set: the coverage contract cannot be VERIFIED on a
|
|
138
|
+
// cmd this tool does not understand — customized, never silently counted as configured.
|
|
139
|
+
customized.push(gate);
|
|
140
|
+
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// A dead-tool reference or a core-check LOOKALIKE in a non-canonical form — reported, never touched.
|
|
144
|
+
if (/review-ledger|fold-completeness|verification-profile|seed-gates|sarif|coverage-check\.mjs|review-state\.mjs/.test(gate.cmd)) {
|
|
145
|
+
customized.push(gate);
|
|
146
|
+
}
|
|
147
|
+
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
148
|
+
}
|
|
149
|
+
const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend');
|
|
150
|
+
let collision = null;
|
|
151
|
+
if (checkerRow === null) {
|
|
152
|
+
// A surviving NON-canonical entry already holding the checker's id blocks the add — two
|
|
153
|
+
// `coverage-check` rows would be ambiguous; the customized entry must be resolved by hand
|
|
154
|
+
// FIRST (the caller turns this into a loud STOP on preview and apply alike).
|
|
155
|
+
if (kept.some((r) => r.entry.id === 'coverage-check')) {
|
|
156
|
+
collision = 'coverage-check';
|
|
157
|
+
} else {
|
|
158
|
+
plan.push({
|
|
159
|
+
action: 'add',
|
|
160
|
+
entry: { id: 'coverage-check', title: 'Changed-line coverage + red-proof verification (the final-run checker)', cmd: coverageCmd },
|
|
161
|
+
reason: 'run-gates --final requires the canonical checker as the LAST declared gate',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
} else if (kept[kept.length - 1] !== checkerRow) {
|
|
165
|
+
checkerRow.action = 'move';
|
|
166
|
+
checkerRow.reason = 'the canonical checker must be the LAST declared gate (nothing may run after it consumed the lcov)';
|
|
167
|
+
}
|
|
168
|
+
const reviewStateCandidate = `{ "id": "review-state", "title": "Review receipts converged (D3(b))", "cmd": "node \\"${join(kitToolsDir, 'review-state.mjs')}\\" --check" }`;
|
|
169
|
+
return { plan, customized, unitTestsExtended, finalCapable: hasReviewState, reviewStateCandidate, collision };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const resultingGates = (plan) => {
|
|
173
|
+
const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend').map((r) => r.entry);
|
|
174
|
+
const moved = plan.filter((r) => r.action === 'move').map((r) => r.entry);
|
|
175
|
+
const added = plan.filter((r) => r.action === 'add').map((r) => r.entry);
|
|
176
|
+
return [...kept, ...moved, ...added]; // move/add go LAST — the checker ends up the last declared gate
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// The per-entry customized recovery: a unit-tests-class entry gets the FULL canonical flag set
|
|
180
|
+
// (the intent is known — the cmd shape just cannot be verified); anything else gets the
|
|
181
|
+
// dead-tool recovery.
|
|
182
|
+
const customizedRecovery = (gate) =>
|
|
183
|
+
gate.id === 'unit-tests'
|
|
184
|
+
? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
|
|
185
|
+
: 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
|
|
186
|
+
|
|
187
|
+
const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
|
|
188
|
+
const lines = [];
|
|
189
|
+
for (const gate of customized) {
|
|
190
|
+
lines.push(` CUSTOMIZED (untouched): ${gate.id}: ${gate.cmd}`);
|
|
191
|
+
lines.push(` recovery (apply by hand if the intent still stands): ${customizedRecovery(gate)}`);
|
|
192
|
+
}
|
|
193
|
+
if (customized.length) {
|
|
194
|
+
lines.push(' IMPORTANT: do NOT install the commit guard until every customized entry above is resolved — a declaration that cannot pass run-gates --final would block every commit.');
|
|
195
|
+
}
|
|
196
|
+
if (!finalCapable) {
|
|
197
|
+
lines.push(' WARNING: the result is NOT final-run-capable — no canonical review-state check is declared. Add it (paste-ready), then run-gates --final can mint the receipt:');
|
|
198
|
+
lines.push(` ${reviewStateCandidate}`);
|
|
199
|
+
}
|
|
200
|
+
return lines;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export const formatPreview = (analysis, applyHint) => {
|
|
204
|
+
const { plan, unitTestsExtended, finalCapable, retiredStores = [] } = analysis;
|
|
205
|
+
const lines = ['[agent-workflow] legacy gates.json migration preview (dry-run — nothing was written):'];
|
|
206
|
+
const acted = plan.filter((r) => r.action !== 'keep');
|
|
207
|
+
for (const r of acted) {
|
|
208
|
+
lines.push(` ${r.action.toUpperCase()} ${r.entry.id}: ${r.entry.cmd} (${r.reason})`);
|
|
209
|
+
}
|
|
210
|
+
for (const p of retiredStores) {
|
|
211
|
+
lines.push(` CLEAN ${p} (a retired kit-owned store the deleted machinery wrote — dead data, unlinked on apply)`);
|
|
212
|
+
}
|
|
213
|
+
if (!acted.length && !retiredStores.length) {
|
|
214
|
+
lines.push(
|
|
215
|
+
finalCapable
|
|
216
|
+
? ' nothing to migrate — no canonical legacy entries, no retired stores, and the declaration is already final-run-capable.'
|
|
217
|
+
: ' nothing to migrate mechanically — no canonical legacy entries and no retired stores; the warnings below still need a hand.',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (!unitTestsExtended && !plan.some((r) => r.entry.id === 'unit-tests')) {
|
|
221
|
+
lines.push(' note: no canonical `unit-tests` entry found — declare your suite gate with the lcov reporters by hand (the coverage-check gate reads the file it produces).');
|
|
222
|
+
}
|
|
223
|
+
lines.push(...warningLines(analysis));
|
|
224
|
+
if (acted.length || retiredStores.length) {
|
|
225
|
+
lines.push(` apply exactly this migration: ${applyHint}`);
|
|
226
|
+
}
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// The parent chain (docs, docs/ai) is lstat'd NO-FOLLOW: a symlinked parent means the write
|
|
231
|
+
// would land OUTSIDE the target project — a STOP on preview and apply alike. ENOENT reads as
|
|
232
|
+
// "no declaration" (a preview no-op); any OTHER lstat failure (EACCES, EIO) fails CLOSED —
|
|
233
|
+
// an unverifiable parent is never treated as safe. lstatFn is the injectable test seam.
|
|
234
|
+
const checkRealParents = (cwd, lstatFn) => {
|
|
235
|
+
for (const rel of ['docs', join('docs', 'ai')]) {
|
|
236
|
+
const p = join(cwd, rel);
|
|
237
|
+
let st;
|
|
238
|
+
try {
|
|
239
|
+
st = lstatFn(p);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err.code === 'ENOENT') return { missing: true };
|
|
242
|
+
throw stop(`${p}: cannot verify the declaration parent (${err.code ?? err.message}) — refusing to proceed (fail closed)`);
|
|
243
|
+
}
|
|
244
|
+
if (st.isSymbolicLink()) throw stop(`${p} is a symlink — refusing to read or write the declaration through a symlinked parent`);
|
|
245
|
+
if (!st.isDirectory()) throw stop(`${p} is not a real directory — fix the layout by hand`);
|
|
246
|
+
}
|
|
247
|
+
return { missing: false };
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const loadDeclaration = (cwd, lstatFn = lstatSync) => {
|
|
251
|
+
const full = join(cwd, GATES_REL);
|
|
252
|
+
let leaf = null;
|
|
253
|
+
try {
|
|
254
|
+
leaf = lstatFn(full);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// ONLY "not there" reads as missing — an EACCES/EIO leaf must never let apply proceed
|
|
257
|
+
// (it would clean the retired stores while silently not migrating the declaration).
|
|
258
|
+
if (err.code === 'ENOENT') return { outcome: 'missing' };
|
|
259
|
+
throw stop(`${GATES_REL}: cannot lstat the declaration (${err.code ?? err.message}) — refusing to proceed (fail closed)`);
|
|
260
|
+
}
|
|
261
|
+
if (leaf.isSymbolicLink()) throw stop(`${GATES_REL} is a symlink — refusing to touch it`);
|
|
262
|
+
let parsed;
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(readFileSync(full, 'utf8'));
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw stop(`${GATES_REL}: malformed JSON (${err.message}) — fix it by hand; the migration never writes over a declaration it cannot parse`);
|
|
267
|
+
}
|
|
268
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.gates)) {
|
|
269
|
+
throw stop(`${GATES_REL}: not a { gates: [...] } declaration — fix it by hand`);
|
|
270
|
+
}
|
|
271
|
+
return { outcome: 'loaded', parsed };
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// The parent chain is re-verified immediately before the tmp write AND again before the rename
|
|
275
|
+
// (an honest-effort TOCTOU guard — a parent swapped for a symlink mid-migration never gets
|
|
276
|
+
// written through).
|
|
277
|
+
const writeAtomic = (cwd, body, lstatFn) => {
|
|
278
|
+
const full = join(cwd, GATES_REL);
|
|
279
|
+
if (checkRealParents(cwd, lstatFn).missing) throw stop(`${GATES_REL}: the docs/ai parent vanished during the migration — nothing was written`);
|
|
280
|
+
const tmp = `${full}.${randomBytes(6).toString('hex')}.tmp`;
|
|
281
|
+
writeFileSync(tmp, body, { flag: 'wx' });
|
|
282
|
+
try {
|
|
283
|
+
if (checkRealParents(cwd, lstatFn).missing) throw stop(`${GATES_REL}: the docs/ai parent vanished during the migration — nothing was written`);
|
|
284
|
+
renameSync(tmp, full);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
try { unlinkSync(tmp); } catch { /* best-effort tmp cleanup */ }
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
return full;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export const parseArgs = (argv) => {
|
|
293
|
+
const args = { cwd: process.cwd(), apply: false, kitTools: null, help: false };
|
|
294
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
295
|
+
const a = argv[i];
|
|
296
|
+
if (a === '--help' || a === '-h') args.help = true;
|
|
297
|
+
else if (a === '--apply') args.apply = true;
|
|
298
|
+
else if (a === '--dry-run') args.apply = false;
|
|
299
|
+
else if (a === '--cwd') {
|
|
300
|
+
i += 1;
|
|
301
|
+
if (!argv[i]) throw usageFail('--cwd needs a directory');
|
|
302
|
+
args.cwd = argv[i];
|
|
303
|
+
} else if (a === '--kit-tools') {
|
|
304
|
+
i += 1;
|
|
305
|
+
if (!argv[i]) throw usageFail('--kit-tools needs the installed kit tools directory');
|
|
306
|
+
args.kitTools = argv[i];
|
|
307
|
+
} else throw usageFail(`unknown argument: ${a}`);
|
|
308
|
+
}
|
|
309
|
+
return args;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const HELP = `migrate-gates — the consented legacy gates.json migration (strip-the-kit D8).
|
|
313
|
+
|
|
314
|
+
Usage:
|
|
315
|
+
node migrate-gates.mjs --kit-tools <installed-kit-tools-dir> [--cwd <project>] [--apply]
|
|
316
|
+
|
|
317
|
+
Default is a dry-run PREVIEW (writes nothing). --apply rewrites ${GATES_REL} atomically:
|
|
318
|
+
canonical legacy entries (review-ledger / fold-completeness --check, matched by their documented
|
|
319
|
+
single-invocation forms) are REMOVED; the canonical unit-tests cmd gains the built-in lcov
|
|
320
|
+
reporters; the coverage-check gate is ADDED last (resolved, QUOTED path). Customized entries are
|
|
321
|
+
NEVER auto-touched — the preview names each with a paste-ready recovery, and the commit guard
|
|
322
|
+
must not be installed until they are resolved.`;
|
|
323
|
+
|
|
324
|
+
export const main = (argv = process.argv.slice(2), io = {}) => {
|
|
325
|
+
const out = io.log ?? console.log;
|
|
326
|
+
const err = io.error ?? console.error;
|
|
327
|
+
try {
|
|
328
|
+
const args = parseArgs(argv);
|
|
329
|
+
if (args.help) {
|
|
330
|
+
out(HELP);
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
if (!args.kitTools) throw usageFail('--kit-tools <dir> is required (the installed kit tools directory — the migration writes resolved paths, never guesses)');
|
|
334
|
+
const kitTools = isAbsolute(args.kitTools) ? args.kitTools : resolve(args.cwd, args.kitTools);
|
|
335
|
+
// The kit path lands INSIDE a double-quoted bash cmd line — a path bash would interpret there
|
|
336
|
+
// ($ ` " \) is a STOP before any plan is built or byte written (never a silently broken gate).
|
|
337
|
+
if (/[$"`\\\r\n]/.test(kitTools)) {
|
|
338
|
+
throw stop(`--kit-tools resolves to ${kitTools} — the path contains shell metacharacters unsafe inside a double-quoted gate cmd ($ \` " \\); relocate the kit to a safe path`);
|
|
339
|
+
}
|
|
340
|
+
if (!existsSync(join(kitTools, 'coverage-check.mjs'))) {
|
|
341
|
+
throw stop(`--kit-tools ${kitTools} does not contain coverage-check.mjs — point it at the installed kit's tools directory`);
|
|
342
|
+
}
|
|
343
|
+
const lstatFn = io.lstat ?? lstatSync;
|
|
344
|
+
const parents = checkRealParents(args.cwd, lstatFn);
|
|
345
|
+
const retiredStores = findRetiredStores(args.cwd);
|
|
346
|
+
const declaration = parents.missing ? { outcome: 'missing' } : loadDeclaration(args.cwd, lstatFn);
|
|
347
|
+
if (declaration.outcome === 'missing' && !retiredStores.length) {
|
|
348
|
+
out(`[agent-workflow] no ${GATES_REL} and no retired stores — nothing to migrate.`);
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
const parsed = declaration.outcome === 'loaded' ? declaration.parsed : { gates: [] };
|
|
352
|
+
const analysis = { ...buildMigrationPlan(parsed.gates, kitTools), retiredStores };
|
|
353
|
+
if (analysis.collision) {
|
|
354
|
+
throw stop(
|
|
355
|
+
`id collision — a NON-canonical entry already uses id "${analysis.collision}"; resolve it by hand first ` +
|
|
356
|
+
'(two rows under the checker id would be ambiguous — the canonical checker is never added alongside a customized twin)',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const applyHint = `node "${fileURLToPath(import.meta.url)}" --kit-tools "${kitTools}" --cwd "${args.cwd}" --apply`;
|
|
360
|
+
if (!args.apply) {
|
|
361
|
+
out(formatPreview(analysis, applyHint));
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
const acted = analysis.plan.filter((r) => r.action !== 'keep');
|
|
365
|
+
if (acted.length && declaration.outcome === 'loaded') {
|
|
366
|
+
const merged = { ...parsed, gates: resultingGates(analysis.plan) };
|
|
367
|
+
const writtenPath = writeAtomic(args.cwd, `${JSON.stringify(merged, null, 2)}\n`, lstatFn);
|
|
368
|
+
out(`[agent-workflow] migrated ${writtenPath}: ${acted.map((r) => `${r.action} ${r.entry.id}`).join(', ')}`);
|
|
369
|
+
}
|
|
370
|
+
for (const p of retiredStores) {
|
|
371
|
+
try {
|
|
372
|
+
unlinkSync(p);
|
|
373
|
+
out(` cleaned ${p} (a retired kit-owned store)`);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
if (e.code !== 'ENOENT') out(` could not clean ${p} (${e.code ?? e.message}) — dead data, remove it by hand; the migration itself is unaffected`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// The warnings survive EVERY apply outcome — a no-op apply must still say what needs a hand.
|
|
379
|
+
for (const line of warningLines(analysis)) out(line);
|
|
380
|
+
if (!acted.length && !retiredStores.length) {
|
|
381
|
+
out(
|
|
382
|
+
analysis.finalCapable
|
|
383
|
+
? '[agent-workflow] nothing to migrate — the declaration is already final-run-capable and no retired stores remain.'
|
|
384
|
+
: '[agent-workflow] nothing to migrate mechanically — no canonical legacy entries and no retired stores remain; the warnings above still need a hand.',
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return 0;
|
|
388
|
+
} catch (e) {
|
|
389
|
+
err(e.message);
|
|
390
|
+
return e.exitCode ?? 1;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
395
|
+
if (isDirectRun) process.exitCode = main();
|