@sabaiway/agent-workflow-kit 3.1.0 → 3.3.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 +71 -0
- package/SKILL.md +3 -3
- package/bridges/antigravity-cli-bridge/SKILL.md +6 -4
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +84 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +113 -0
- package/bridges/antigravity-cli-bridge/bin/agy.sh +55 -6
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +4 -2
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +5 -0
- package/bridges/codex-cli-bridge/SKILL.md +8 -4
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +129 -11
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +211 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +76 -13
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +47 -0
- package/bridges/codex-cli-bridge/capability.json +19 -4
- package/bridges/codex-cli-bridge/references/driving-codex.md +11 -0
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +3 -2
- package/references/modes/upgrade.md +9 -4
- package/references/modes/velocity.md +8 -2
- package/references/shared/command-shapes.md +22 -0
- package/references/shared/composition-handoff.md +7 -2
- package/references/templates/agent_rules.md +10 -1
- package/tools/bridge-settings-read.mjs +25 -5
- package/tools/delegation.mjs +2 -2
- package/tools/detect-backends.mjs +10 -0
- package/tools/lens-region.mjs +113 -36
- package/tools/recipes.mjs +5 -2
- package/tools/release-scan.mjs +147 -13
- package/tools/run-gates.mjs +43 -2
- package/tools/velocity-profile.mjs +229 -20
package/tools/lens-region.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Heading-anchored
|
|
3
|
-
// `docs/ai/agent_rules.md`.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
2
|
+
// Heading-anchored region reconcile — the composition root's only mutation of a deployed
|
|
3
|
+
// `docs/ai/agent_rules.md`. TWO regions, one policy (refresh IFF the region's normalized body
|
|
4
|
+
// matches the current canon or a KNOWN PRIOR canonical body; anything else preserved
|
|
5
|
+
// byte-for-byte + a one-line advisory — the AD-025 discipline):
|
|
6
|
+
// - the planning/review/process-fidelity lens block (`### 2.x. Planning, review &
|
|
7
|
+
// process-fidelity invariants`) — canon home: the installed engine's
|
|
8
|
+
// `references/agent-rules-lens.md` + its append-only prior store, read live (no kit-side
|
|
9
|
+
// prior constants, so a lens wording change is an engine-only release);
|
|
10
|
+
// - the Communication block (`### 2.x. Communication (user-facing messages)`, AD-061) — canon
|
|
11
|
+
// home: the §2.5 region of the kit's OWN bundled `references/templates/agent_rules.md`
|
|
12
|
+
// (byte-identical to the memory twin by the template-region parity guard); priors are inline
|
|
13
|
+
// constants below (a templates/-side prior file would be deployed into projects by the
|
|
14
|
+
// template loop), appended whenever the template region changes (the vintage rule).
|
|
12
15
|
//
|
|
13
16
|
// The region has NO markers (unlike the AGENTS.md pointer slots): it is located by the heading
|
|
14
17
|
// through the next structural boundary (`---` / `##` / `###`) or EOF — the extraction rule the
|
|
@@ -31,6 +34,22 @@ import { normalizeCanonical } from './orchestration-config.mjs';
|
|
|
31
34
|
export const LENS_HEADING_RE = /^### 2\.(\d+)\. Planning, review & process-fidelity/;
|
|
32
35
|
const NEUTRAL_HEADING_RE = /^### 2\.x\. Planning, review & process-fidelity/;
|
|
33
36
|
const HEADING_LABEL = '### 2.x. Planning, review & process-fidelity invariants';
|
|
37
|
+
export const COMMS_HEADING_RE = /^### 2\.(\d+)\. Communication \(user-facing messages\)/;
|
|
38
|
+
const NEUTRAL_COMMS_RE = /^### 2\.x\. Communication \(user-facing messages\)/;
|
|
39
|
+
const COMMS_LABEL = '### 2.x. Communication (user-facing messages)';
|
|
40
|
+
|
|
41
|
+
// The known prior canonical bodies of the Communication region (neutral-headed, like the engine's
|
|
42
|
+
// lens prior store). Append the OUTGOING canon here in the same release that changes the template
|
|
43
|
+
// §2.5 region — the fragment-or-prior reconcile depends on it.
|
|
44
|
+
const COMMS_PRIOR_PRE_AD054 = `### 2.x. Communication (user-facing messages)
|
|
45
|
+
Apply this as part of §2 before any user-facing summary:
|
|
46
|
+
- **Deliver the artifact IN the message** — paste the prompt / diff / version / command inline; never "see §X / open the file / run it and you'll see" as a *substitute* for showing what was asked.
|
|
47
|
+
- **Lead with the result**, then the details; show exactly what was asked — no deflection, no "almost done" when the ask was the finished thing.
|
|
48
|
+
- **No condescension, no filler.** Own a miss plainly and fix it in the same message.
|
|
49
|
+
- **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.`;
|
|
50
|
+
const COMMS_PRIOR_AD054 = `${COMMS_PRIOR_PRE_AD054}
|
|
51
|
+
- **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
|
|
52
|
+
export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054];
|
|
34
53
|
|
|
35
54
|
const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
36
55
|
const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
|
|
@@ -66,15 +85,18 @@ export const renderLens = (fragment, number) =>
|
|
|
66
85
|
// (heading number → `2.x`, trim, CRLF→LF) every known-set match uses.
|
|
67
86
|
export const normalizeLensBody = (body) =>
|
|
68
87
|
normalizeCanonical(String(body).replace(/^### 2\.\d+\./, '### 2.x.'));
|
|
88
|
+
export const normalizeCommsBody = normalizeLensBody;
|
|
89
|
+
export const renderComms = (fragment, number) =>
|
|
90
|
+
normalizeCanonical(fragment).replace(NEUTRAL_COMMS_RE, (m) => m.replace('2.x', `2.${number}`));
|
|
69
91
|
|
|
70
92
|
// extractLensRegion(text) → { found: false } | { found, start, end, number, body }.
|
|
71
93
|
// `start`/`end` are line indices over text.split('\n') — heading line through (exclusive) the
|
|
72
94
|
// next structural boundary; EOF is a valid region end (no following boundary required). `body`
|
|
73
95
|
// is the CR-stripped block with trailing blank lines dropped (the comparison form); the raw
|
|
74
96
|
// region lines (including trailing blanks) are what replaceLensRegion preserves around a render.
|
|
75
|
-
|
|
97
|
+
const extractRegionBy = (text, headingRe) => {
|
|
76
98
|
const lines = String(text).split('\n');
|
|
77
|
-
const start = lines.findIndex((line) =>
|
|
99
|
+
const start = lines.findIndex((line) => headingRe.test(stripCr(line)));
|
|
78
100
|
if (start === -1) return { found: false };
|
|
79
101
|
let end = lines.length;
|
|
80
102
|
for (let i = start + 1; i < lines.length; i += 1) {
|
|
@@ -83,13 +105,15 @@ export const extractLensRegion = (text) => {
|
|
|
83
105
|
break;
|
|
84
106
|
}
|
|
85
107
|
}
|
|
86
|
-
const number = stripCr(lines[start]).match(
|
|
108
|
+
const number = stripCr(lines[start]).match(headingRe)[1];
|
|
87
109
|
const regionLines = lines.slice(start, end);
|
|
88
110
|
let bodyEnd = regionLines.length;
|
|
89
111
|
while (bodyEnd > 0 && stripCr(regionLines[bodyEnd - 1]).trim() === '') bodyEnd -= 1;
|
|
90
112
|
const body = regionLines.slice(0, bodyEnd).map(stripCr).join('\n');
|
|
91
113
|
return { found: true, start, end, number, body };
|
|
92
114
|
};
|
|
115
|
+
export const extractLensRegion = (text) => extractRegionBy(text, LENS_HEADING_RE);
|
|
116
|
+
export const extractCommsRegion = (text) => extractRegionBy(text, COMMS_HEADING_RE);
|
|
93
117
|
|
|
94
118
|
// replaceLensRegion(text, region, renderedBody) → the document with ONLY the region's block
|
|
95
119
|
// lines replaced; trailing blank lines inside the region and every byte outside it are
|
|
@@ -119,16 +143,20 @@ export const replaceLensRegion = (text, region, renderedBody) => {
|
|
|
119
143
|
// with the file's OWN number (cap-guard is the caller's, so the
|
|
120
144
|
// decision stays pure).
|
|
121
145
|
// { status: 'custom' } — anything else → preserved byte-for-byte + advisory.
|
|
122
|
-
|
|
123
|
-
const region =
|
|
146
|
+
const reconcileRegionText = (text, fragment, priors, { extract, normalize, render }) => {
|
|
147
|
+
const region = extract(text);
|
|
124
148
|
if (!region.found) return { status: 'no-region', text };
|
|
125
|
-
const current =
|
|
126
|
-
const canon =
|
|
149
|
+
const current = normalize(region.body);
|
|
150
|
+
const canon = normalize(fragment);
|
|
127
151
|
if (current === canon) return { status: 'current', text };
|
|
128
|
-
const known = priors.map((p) =>
|
|
152
|
+
const known = priors.map((p) => normalize(p));
|
|
129
153
|
if (!known.includes(current)) return { status: 'custom', text };
|
|
130
|
-
return { status: 'refreshed', text: replaceLensRegion(text, region,
|
|
154
|
+
return { status: 'refreshed', text: replaceLensRegion(text, region, render(fragment, region.number)) };
|
|
131
155
|
};
|
|
156
|
+
export const reconcileLensText = (text, fragment, priors) =>
|
|
157
|
+
reconcileRegionText(text, fragment, priors, { extract: extractLensRegion, normalize: normalizeLensBody, render: renderLens });
|
|
158
|
+
export const reconcileCommsText = (text, fragment, priors) =>
|
|
159
|
+
reconcileRegionText(text, fragment, priors, { extract: extractCommsRegion, normalize: normalizeCommsBody, render: renderComms });
|
|
132
160
|
|
|
133
161
|
// frontmatterMaxLines(text) → the file's own `maxLines:` frontmatter value, or null when the
|
|
134
162
|
// file has no frontmatter block or the block carries no maxLines (→ the cap-guard is skipped
|
|
@@ -146,8 +174,9 @@ export const frontmatterMaxLines = (text) => {
|
|
|
146
174
|
|
|
147
175
|
// ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
|
|
148
176
|
// Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
|
|
149
|
-
// every classified outcome (including the soft skips and the cap
|
|
150
|
-
// hard engine
|
|
177
|
+
// every classified outcome (including the soft skips and the cap refusals), exit 1 ONLY on a
|
|
178
|
+
// hard STOP — the absent/invalid engine, or the unreadable bundled template canon — or an
|
|
179
|
+
// unexpected fs error, exit 2 on usage.
|
|
151
180
|
export const runCli = async (argv, deps = {}) => {
|
|
152
181
|
const log = deps.log ?? console.log;
|
|
153
182
|
const logError = deps.logError ?? console.error;
|
|
@@ -155,6 +184,7 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
155
184
|
const fs = deps.fs ?? (await import('node:fs/promises'));
|
|
156
185
|
const { dirname, basename, join, resolve } = await import('node:path');
|
|
157
186
|
const { homedir } = await import('node:os');
|
|
187
|
+
const { fileURLToPath } = await import('node:url');
|
|
158
188
|
const { resolveEngineDir, detectEngine, readEngineFragment, LENS_FRAGMENT_REL, LENS_PRIORS_REL } = await import('./engine-source.mjs');
|
|
159
189
|
|
|
160
190
|
if (argv[0] !== 'reconcile' || !argv[1] || argv.length > 2) {
|
|
@@ -177,9 +207,63 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
177
207
|
return 0;
|
|
178
208
|
}
|
|
179
209
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
210
|
+
const atomicWrite = async (content) => {
|
|
211
|
+
const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
|
|
212
|
+
try {
|
|
213
|
+
await fs.writeFile(tmp, content, 'utf8');
|
|
214
|
+
await fs.rename(tmp, targetPath);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// 2. The Communication half (AD-061) — canon from the kit's OWN bundled template (the engine is
|
|
222
|
+
// never consulted for this region), same fragment-or-prior policy, own outcome lines.
|
|
223
|
+
const templatePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'references', 'templates', 'agent_rules.md');
|
|
224
|
+
const templateRegion = await (async () => {
|
|
225
|
+
try {
|
|
226
|
+
return extractCommsRegion(await fs.readFile(templatePath, 'utf8'));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return { found: false, error: err?.message ?? String(err) };
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
if (!templateRegion.found) {
|
|
232
|
+
logError(`[lens-region] reconcile STOP — the kit's bundled agent_rules.md template canon is unreadable${templateRegion.error ? ` (${templateRegion.error})` : ''}; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
const commsResult = reconcileCommsText(text, normalizeCommsBody(templateRegion.body), COMMS_PRIORS);
|
|
236
|
+
const currentText = await (async () => {
|
|
237
|
+
if (commsResult.status === 'no-region') {
|
|
238
|
+
log(`[lens-region] no "${COMMS_LABEL}" section in ${argv[1]} — left untouched.`);
|
|
239
|
+
log('[lens-region] note: the Communication section is absent or renamed — deployments seeded before it existed simply lack it; add it from the current template to enable refresh. Your file is never rewritten.');
|
|
240
|
+
return text;
|
|
241
|
+
}
|
|
242
|
+
if (commsResult.status === 'current') {
|
|
243
|
+
log('[lens-region] Communication section already current — nothing to do (zero-diff).');
|
|
244
|
+
return text;
|
|
245
|
+
}
|
|
246
|
+
if (commsResult.status === 'custom') {
|
|
247
|
+
log('[lens-region] Communication section carries a custom edit — preserved verbatim.');
|
|
248
|
+
log('[lens-region] note: the canonical Communication section has changed since this section was edited — compare it with the current template when convenient; your wording is never overwritten.');
|
|
249
|
+
return text;
|
|
250
|
+
}
|
|
251
|
+
const commsMax = frontmatterMaxLines(text);
|
|
252
|
+
if (commsMax === null) {
|
|
253
|
+
log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
|
|
254
|
+
}
|
|
255
|
+
if (commsMax !== null && lineCount(commsResult.text) > commsMax) {
|
|
256
|
+
log(`[lens-region] refused — refreshing the Communication section would push ${argv[1]} to ${lineCount(commsResult.text)} lines (cap ${commsMax}); trim the file and re-run. The Communication section was not changed.`);
|
|
257
|
+
return text;
|
|
258
|
+
}
|
|
259
|
+
await atomicWrite(commsResult.text);
|
|
260
|
+
log('[lens-region] refreshed the Communication section to the current canon.');
|
|
261
|
+
return commsResult.text;
|
|
262
|
+
})();
|
|
263
|
+
|
|
264
|
+
// 3. No matching lens heading → preserve + advise, engine never consulted (the outcome is
|
|
265
|
+
// preserve regardless, so the lazy contract holds).
|
|
266
|
+
if (!extractLensRegion(currentText).found) {
|
|
183
267
|
log(`[lens-region] no "${HEADING_LABEL}" section in ${argv[1]} — left untouched.`);
|
|
184
268
|
log('[lens-region] note: the planning/review lens section is missing or renamed — it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.');
|
|
185
269
|
return 0;
|
|
@@ -215,8 +299,8 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
215
299
|
return 1;
|
|
216
300
|
}
|
|
217
301
|
|
|
218
|
-
//
|
|
219
|
-
const result = reconcileLensText(
|
|
302
|
+
// 5. The pure decision + the cap-guard + one atomic write.
|
|
303
|
+
const result = reconcileLensText(currentText, fragment, priors);
|
|
220
304
|
if (result.status === 'current') {
|
|
221
305
|
log('[lens-region] lens section already current — nothing to do (zero-diff).');
|
|
222
306
|
return 0;
|
|
@@ -227,21 +311,14 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
227
311
|
return 0;
|
|
228
312
|
}
|
|
229
313
|
// refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
|
|
230
|
-
const maxLines = frontmatterMaxLines(
|
|
314
|
+
const maxLines = frontmatterMaxLines(currentText);
|
|
231
315
|
if (maxLines === null) {
|
|
232
316
|
log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
|
|
233
317
|
} else if (lineCount(result.text) > maxLines) {
|
|
234
|
-
log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run.
|
|
318
|
+
log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run. The planning/review lens section was not changed.`);
|
|
235
319
|
return 0;
|
|
236
320
|
}
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
await fs.writeFile(tmp, result.text, 'utf8');
|
|
240
|
-
await fs.rename(tmp, targetPath);
|
|
241
|
-
} catch (err) {
|
|
242
|
-
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
243
|
-
throw err;
|
|
244
|
-
}
|
|
321
|
+
await atomicWrite(result.text);
|
|
245
322
|
log('[lens-region] refreshed the planning/review lens section to the current canon.');
|
|
246
323
|
return 0;
|
|
247
324
|
};
|
package/tools/recipes.mjs
CHANGED
|
@@ -394,7 +394,10 @@ const projectTopOf = (cwd) => {
|
|
|
394
394
|
}
|
|
395
395
|
};
|
|
396
396
|
|
|
397
|
-
|
|
397
|
+
// `deps` is threaded to the render check because that check now PROBES the installed harness: without
|
|
398
|
+
// a seam the render state would vary with the machine composing the facts, and a caller could not
|
|
399
|
+
// pin it. Production passes nothing and probes for real.
|
|
400
|
+
export const composeAutonomyFacts = async (cwd, deps = {}) => {
|
|
398
401
|
try {
|
|
399
402
|
const root = projectTopOf(cwd);
|
|
400
403
|
const { loadAutonomy, resolveAutonomy, isSparseSeedConfig } = await import('./autonomy-config.mjs');
|
|
@@ -411,7 +414,7 @@ export const composeAutonomyFacts = async (cwd) => {
|
|
|
411
414
|
let renderState;
|
|
412
415
|
try {
|
|
413
416
|
const { checkAutonomyProfile } = await import('./velocity-profile.mjs');
|
|
414
|
-
renderState = checkAutonomyProfile({ cwd: root }).inSync
|
|
417
|
+
renderState = checkAutonomyProfile({ cwd: root }, deps).inSync
|
|
415
418
|
? 'in sync'
|
|
416
419
|
: 'DRIFT — re-run the velocity --autonomy render';
|
|
417
420
|
} catch (err) {
|
package/tools/release-scan.mjs
CHANGED
|
@@ -36,7 +36,10 @@ const ATTRIBUTION = [
|
|
|
36
36
|
{ re: /authored[- ]by[: ][^\n]{0,40}\b(claude|chatgpt|gemini|copilot)\b/i, label: 'AI authorship attribution' },
|
|
37
37
|
];
|
|
38
38
|
const REVIEWER_IDENTITY = [
|
|
39
|
+
// backend-then-round: a bridge name, a separator, then r<N> (with optional +/round suffixes).
|
|
39
40
|
{ re: /\b(?:agy|codex)(?:\s+|-)r\d+(?:(?:\+|\/)r?\d+)*(?:-[a-z0-9]+)*\b/i, label: 'reviewer-round identity' },
|
|
41
|
+
// round-then-backend (reverse order): r<N>, a separator, then a bridge name — the release-review gap.
|
|
42
|
+
{ re: /\br\d+(?:\s+|-)(?:agy|codex)\b/i, label: 'reviewer-round identity' },
|
|
40
43
|
];
|
|
41
44
|
|
|
42
45
|
const allowlistCovers = (matched, allowlist) =>
|
|
@@ -60,6 +63,81 @@ export const scanText = (text, { allowlist = ALLOWLIST } = {}) => {
|
|
|
60
63
|
return findings;
|
|
61
64
|
};
|
|
62
65
|
|
|
66
|
+
// ── the version-pin rung ─────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// A hardcoded harness version under tools/ is a documentation claim frozen in code: it goes stale by
|
|
68
|
+
// default and nothing reports it. This rung refuses a BARE pin and demands a runtime probe beside
|
|
69
|
+
// it — the probe is what converts silent staleness into a loud degrade.
|
|
70
|
+
//
|
|
71
|
+
// A line is a claim when it names the harness AND carries a version literal. The rung reports
|
|
72
|
+
// exactly those lines — it deliberately does NOT spread the claimed series across the rest of the
|
|
73
|
+
// file, which was measured to flag an unrelated dependency constant that merely shared a major.minor
|
|
74
|
+
// with the claim two lines above it. Nothing is lost by the narrower rule: the exemption is
|
|
75
|
+
// FILE-scoped, so a single refusal already forces the probe that makes every literal in the file
|
|
76
|
+
// honest. (This comment names the harness without a literal on the same line ON PURPOSE — the rung
|
|
77
|
+
// scans its own source, and a worked example here would make the scanner refuse itself.)
|
|
78
|
+
//
|
|
79
|
+
// STATED RESIDUAL, not an oversight: a claim split across lines — the harness named on one line, the
|
|
80
|
+
// literal on another — is NOT detected. Closing it needs whole-file arming, which trades this narrow
|
|
81
|
+
// miss for a broad false-positive class. Deliberate line splitting is left to human review; this rung
|
|
82
|
+
// is a guard against staleness that accumulates by default, not against an author evading it.
|
|
83
|
+
const HARNESS_CLAIM_RE = /\bclaude\b/i;
|
|
84
|
+
const SEMVER_LITERAL_RE = /\b\d+\.\d+\.\d+\b/g;
|
|
85
|
+
const TOOLS_SEGMENT = 'tools';
|
|
86
|
+
const PATH_SEP_RE = /[/\\]/;
|
|
87
|
+
const VERSION_PIN_REMEDY =
|
|
88
|
+
'pair it with a runtime probe (probeHarnessVersion) that compares the pin to the installed build '
|
|
89
|
+
+ 'and degrades loudly, or move the literal into a test fixture or changelog entry, where it '
|
|
90
|
+
+ 'records history instead of asserting a live platform fact';
|
|
91
|
+
|
|
92
|
+
// Three exemptions, each because the literal is NOT a live claim there. A test fixture and a
|
|
93
|
+
// changelog entry record what was true at a moment; a file that CALLS the probe already reports its
|
|
94
|
+
// own staleness, which is the entire behaviour the rung exists to demand.
|
|
95
|
+
//
|
|
96
|
+
// The probe exemption is a REFERENCE test, deliberately: it asks exactly what the decision asks —
|
|
97
|
+
// that a runtime probe sits beside the literal — and claims nothing more.
|
|
98
|
+
//
|
|
99
|
+
// It was briefly a CALL test. Separating a call from a declaration, a comment, a string and a regex
|
|
100
|
+
// literal is JS lexing, and hand-rolling that opened a new hole in three consecutive review rounds:
|
|
101
|
+
// a legitimate member call wrongly refused, then a per-character recursion that would crash this
|
|
102
|
+
// scanner inside the commit gate, then a regex literal read as a call. The repo does own a real
|
|
103
|
+
// lexical code mask — in root tooling a shipped kit tool cannot import — and extracting it is queued
|
|
104
|
+
// as its own work rather than approximated again here.
|
|
105
|
+
//
|
|
106
|
+
// STATED RESIDUAL, and why the precision was not worth its risk: even a perfect call detector proves
|
|
107
|
+
// only that the probe is REACHABLE in this file, never that its result is compared against the
|
|
108
|
+
// literal. That stronger guarantee was never on offer, so the rung stops where the decision stops
|
|
109
|
+
// and says so. The exemption stays scoped to files that could call anything at all — a `#` comment
|
|
110
|
+
// in a shell script and a line of Markdown prose earn nothing.
|
|
111
|
+
const PROBE_CALLABLE_EXT = new Set(['.mjs', '.js']);
|
|
112
|
+
const PROBE_SYMBOL = 'probeHarnessVersion';
|
|
113
|
+
const TEST_FILE_RE = /\.test\.mjs$/;
|
|
114
|
+
const CHANGELOG_FILE_RE = /^CHANGELOG\.md$/i;
|
|
115
|
+
|
|
116
|
+
const semverLiteralsIn = (line) => line.match(SEMVER_LITERAL_RE) ?? [];
|
|
117
|
+
const isUnderTools = (path) => String(path).split(PATH_SEP_RE).includes(TOOLS_SEGMENT);
|
|
118
|
+
const isRecordNotClaim = (path) => {
|
|
119
|
+
const name = basename(String(path));
|
|
120
|
+
return TEST_FILE_RE.test(name) || CHANGELOG_FILE_RE.test(name);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// PURE: classify one file's text. Path-aware — the same literal is a claim under tools/ and history
|
|
124
|
+
// in a changelog, so the verdict cannot be made from the bytes alone.
|
|
125
|
+
export const scanVersionPins = (text, path) => {
|
|
126
|
+
if (!isUnderTools(path) || isRecordNotClaim(path)) return [];
|
|
127
|
+
const probeCallable = PROBE_CALLABLE_EXT.has(extname(String(path)));
|
|
128
|
+
if (probeCallable && text.includes(PROBE_SYMBOL)) return [];
|
|
129
|
+
return text.split('\n').flatMap((line, idx) => {
|
|
130
|
+
const pinned = HARNESS_CLAIM_RE.test(line) ? semverLiteralsIn(line) : [];
|
|
131
|
+
return pinned.length === 0
|
|
132
|
+
? []
|
|
133
|
+
: [{
|
|
134
|
+
line: idx + 1,
|
|
135
|
+
kind: 'version-pin',
|
|
136
|
+
detail: `harness version pin ${pinned.join(', ')} with no runtime probe in this file — a version literal frozen in code goes stale silently; ${VERSION_PIN_REMEDY}`,
|
|
137
|
+
}];
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
63
141
|
const TEXT_EXT = new Set(['.md', '.mjs', '.js', '.json', '.sh', '.yml', '.yaml', '.txt', '']);
|
|
64
142
|
const EXCLUDE_DIR_NAMES = new Set(['node_modules', '.git', 'plans', 'scan-fixtures']);
|
|
65
143
|
// Only the TEST is self-excluded — it necessarily contains attribution fixtures. The scanner source
|
|
@@ -78,34 +156,90 @@ const walk = (path, acc = []) => {
|
|
|
78
156
|
return acc;
|
|
79
157
|
};
|
|
80
158
|
|
|
81
|
-
|
|
159
|
+
// Per-target outcomes. A caller-NAMED target that contributes no file must never fold into the
|
|
160
|
+
// clean verdict — an empty file list and a clean one print identically, so a mis-aimed scan would
|
|
161
|
+
// read as proof of cleanliness. `unmatchedGlob` is the one benign zero: the gate declaration lists
|
|
162
|
+
// shell globs, and bash passes an unmatched glob through literally, so a path that still carries a
|
|
163
|
+
// glob metacharacter is an optional target the shell found nothing for, not a mis-aim.
|
|
164
|
+
export const TARGET = Object.freeze({
|
|
165
|
+
scanned: 'scanned',
|
|
166
|
+
missing: 'missing',
|
|
167
|
+
unmatchedGlob: 'unmatched-glob',
|
|
168
|
+
excluded: 'excluded',
|
|
169
|
+
empty: 'empty',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const GLOB_META_RE = /[*?[\]]/;
|
|
173
|
+
|
|
174
|
+
// PURE-ish (one fs read): classify ONE caller-named target and collect the files it contributes.
|
|
175
|
+
// Every zero-contribution status names its own cause, so the refusal can state the remedy rather
|
|
176
|
+
// than a bare count (a count without a location is the defect class this family bans).
|
|
177
|
+
export const classifyTarget = (path) => {
|
|
178
|
+
if (!existsSync(path)) {
|
|
179
|
+
return GLOB_META_RE.test(path)
|
|
180
|
+
? { path, status: TARGET.unmatchedGlob, files: [] }
|
|
181
|
+
: { path, status: TARGET.missing, files: [] };
|
|
182
|
+
}
|
|
183
|
+
const st = statSync(path);
|
|
184
|
+
const name = basename(path);
|
|
185
|
+
if (st.isDirectory() && EXCLUDE_DIR_NAMES.has(name)) return { path, status: TARGET.excluded, files: [] };
|
|
186
|
+
if (st.isFile() && (EXCLUDE_FILE_NAMES.has(name) || !TEXT_EXT.has(extname(path)) || path.endsWith('.tgz'))) {
|
|
187
|
+
return { path, status: TARGET.excluded, files: [] };
|
|
188
|
+
}
|
|
189
|
+
const files = walk(path);
|
|
190
|
+
return { path, status: files.length > 0 ? TARGET.scanned : TARGET.empty, files };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Why each zero-contribution status refuses, and the one legal remedy for it.
|
|
194
|
+
const TARGET_REMEDY = Object.freeze({
|
|
195
|
+
[TARGET.missing]: 'the path does not exist — fix the target, or drop it from the target list',
|
|
196
|
+
[TARGET.excluded]: `the target itself is excluded (directory names: ${[...EXCLUDE_DIR_NAMES].join(', ')}; non-text or self-excluded files) — name the FILES to scan instead of the directory`,
|
|
197
|
+
[TARGET.empty]: 'the target exists but holds no scannable text file — fix the target, or drop it from the target list',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const findingsIn = (targets, opts) => {
|
|
82
201
|
const report = [];
|
|
83
|
-
for (const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
for (const
|
|
202
|
+
for (const target of targets) {
|
|
203
|
+
for (const file of target.files) {
|
|
204
|
+
const text = readFileSync(file, 'utf8');
|
|
205
|
+
for (const finding of scanText(text, opts)) report.push({ file, ...finding });
|
|
206
|
+
for (const finding of scanVersionPins(text, file)) report.push({ file, ...finding });
|
|
88
207
|
}
|
|
89
208
|
}
|
|
90
209
|
return report;
|
|
91
210
|
};
|
|
92
211
|
|
|
212
|
+
export const scanPaths = (paths, opts = {}) => findingsIn(paths.map((p) => classifyTarget(resolve(p))), opts);
|
|
213
|
+
|
|
93
214
|
export const main = (argv) => {
|
|
94
215
|
const reportOnly = argv.includes('--report');
|
|
95
216
|
const paths = argv.filter((a) => a !== '--report');
|
|
96
217
|
if (paths.length === 0) {
|
|
97
218
|
console.error('usage: release-scan.mjs [--report] <path>...');
|
|
98
|
-
|
|
219
|
+
return 2;
|
|
220
|
+
}
|
|
221
|
+
const targets = paths.map((p) => classifyTarget(resolve(p)));
|
|
222
|
+
for (const target of targets) {
|
|
223
|
+
if (target.status === TARGET.unmatchedGlob) {
|
|
224
|
+
console.log(`[release-scan] skipped ${target.path} — no file matched this glob (an optional target, stated not silent).`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const misaimed = targets.filter((target) => TARGET_REMEDY[target.status] !== undefined);
|
|
228
|
+
for (const target of misaimed) {
|
|
229
|
+
console.log(`[release-scan] target ${target.path} contributed NO file (${target.status}) — ${TARGET_REMEDY[target.status]}`);
|
|
99
230
|
}
|
|
100
|
-
const report =
|
|
231
|
+
const report = findingsIn(targets);
|
|
101
232
|
for (const r of report) console.log(`[${r.kind}] ${r.file}:${r.line} — ${r.detail}`);
|
|
102
|
-
if (
|
|
233
|
+
if (misaimed.length > 0) {
|
|
234
|
+
console.log(`\n[release-scan] ${misaimed.length} mis-aimed target(s) — this run proves nothing about them, so the clean verdict is refused.`);
|
|
235
|
+
}
|
|
236
|
+
if (report.length > 0) console.log(`\n[release-scan] ${report.length} finding(s).`);
|
|
237
|
+
if (report.length === 0 && misaimed.length === 0) {
|
|
103
238
|
console.log('[release-scan] clean — no AI attribution or reviewer-round identity found.');
|
|
104
|
-
return;
|
|
239
|
+
return 0;
|
|
105
240
|
}
|
|
106
|
-
|
|
107
|
-
if (!reportOnly) process.exit(1);
|
|
241
|
+
return reportOnly ? 0 : 1;
|
|
108
242
|
};
|
|
109
243
|
|
|
110
244
|
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
111
|
-
if (isDirectRun) main(process.argv.slice(2));
|
|
245
|
+
if (isDirectRun) process.exitCode = main(process.argv.slice(2));
|
package/tools/run-gates.mjs
CHANGED
|
@@ -77,6 +77,12 @@ const USAGE = [
|
|
|
77
77
|
'',
|
|
78
78
|
`Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
|
|
79
79
|
'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
|
|
80
|
+
'',
|
|
81
|
+
'Producer env: AW_GIT_DIR (inside a git tree) and AW_LCOV_FILE (--final only) are computed and',
|
|
82
|
+
'exported to every gate child, and STRIPPED from the inherited environment first — a host-set',
|
|
83
|
+
'value never stands in for a computed one. A selected gate referencing a producer variable this',
|
|
84
|
+
'run will not set is refused BEFORE anything spawns (exit 1), naming the gate, the variable, and',
|
|
85
|
+
'the remedy — never left to expand to empty and fail far from its cause.',
|
|
80
86
|
'Sandbox-safe: the runner itself needs no network and writes only repo-local state — the D4 sandbox',
|
|
81
87
|
'lane; each DECLARED gate command is the project\'s own, so ITS sandbox-safety is command-shape',
|
|
82
88
|
'dependent (first try the sandbox-safe shape — cache under $TMPDIR, offline/notifier off).',
|
|
@@ -184,12 +190,37 @@ export const selectGates = (gates, onlyIds) => {
|
|
|
184
190
|
// NODE_TEST_CONTEXT is stripped: a `node --test` gate spawned while run-gates is itself running
|
|
185
191
|
// under a parent test context would otherwise inherit it, hit Node's recursive-run guard, silently
|
|
186
192
|
// skip every file, and exit 0 — a vacuous false green.
|
|
193
|
+
// The variables this runner PRODUCES for gate children. They are stripped from the inherited
|
|
194
|
+
// environment before every spawn: composing the child env as {...process.env, ...injected} would
|
|
195
|
+
// let a stale or hostile HOST value stand in whenever this run injects nothing, so a gate would
|
|
196
|
+
// silently attest against the wrong git dir or lcov instead of the computed one.
|
|
197
|
+
export const RESERVED_PRODUCER_ENV = Object.freeze(['AW_GIT_DIR', 'AW_LCOV_FILE']);
|
|
198
|
+
|
|
187
199
|
export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
|
|
188
|
-
const env = { ...process.env
|
|
200
|
+
const env = { ...process.env };
|
|
189
201
|
delete env.NODE_TEST_CONTEXT;
|
|
190
|
-
|
|
202
|
+
for (const name of RESERVED_PRODUCER_ENV) delete env[name];
|
|
203
|
+
return spawnSync('bash', ['-c', cmd], { cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
|
|
191
204
|
};
|
|
192
205
|
|
|
206
|
+
// A `$VAR` / `${VAR}` reference to a producer variable. A `${VAR:-default}` form is deliberately NOT
|
|
207
|
+
// matched — a cmd carrying its own fallback does not depend on the injection.
|
|
208
|
+
const referencesProducer = (cmd, name) => new RegExp(`\\$\\{${name}\\}|\\$${name}(?![A-Za-z0-9_])`).test(cmd);
|
|
209
|
+
|
|
210
|
+
const PRODUCER_RECOVERY = Object.freeze({
|
|
211
|
+
AW_GIT_DIR: 'run from inside a git work tree — the runner resolves the git dir there and exports it to every gate.',
|
|
212
|
+
AW_LCOV_FILE: 'AW_LCOV_FILE is produced only by --final — run with --final, or drop the reference from the gate cmd.',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Which SELECTED gates reference a producer variable this run will not set. Such a reference
|
|
216
|
+
// expands to empty and fails the gate somewhere far from its cause, so it is refused before
|
|
217
|
+
// anything spawns, naming the gate, the variable, and the one remedy.
|
|
218
|
+
export const findUnmetProducerRefs = (gates, injected) =>
|
|
219
|
+
gates.flatMap((gate) =>
|
|
220
|
+
RESERVED_PRODUCER_ENV
|
|
221
|
+
.filter((name) => !injected.includes(name) && referencesProducer(gate.cmd, name))
|
|
222
|
+
.map((name) => ({ id: gate.id, name })));
|
|
223
|
+
|
|
193
224
|
// The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
|
|
194
225
|
// so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
|
|
195
226
|
export const BASH_PROBE_CMD = 'true';
|
|
@@ -378,6 +409,16 @@ export const runCli = (argv, deps = {}) => {
|
|
|
378
409
|
// gate child env — a stray host AW_LCOV_FILE can never desync the checker from the receipt.
|
|
379
410
|
gateSpawn = (cmd, cwd2) => spawn(cmd, cwd2, { AW_GIT_DIR: gitDir, AW_LCOV_FILE: join(gitDir, LCOV_BASENAME) });
|
|
380
411
|
}
|
|
412
|
+
const injectedEnv = [...(gitDir === null ? [] : ['AW_GIT_DIR']), ...(opts.final ? ['AW_LCOV_FILE'] : [])];
|
|
413
|
+
const unmet = findUnmetProducerRefs(selected, injectedEnv);
|
|
414
|
+
if (unmet.length > 0) {
|
|
415
|
+
for (const { id, name } of unmet) {
|
|
416
|
+
logError(`[run-gates] gate "${id}" references $${name}, which this run will not set — it would expand to empty and fail far from its cause.`);
|
|
417
|
+
logError(` Recovery: ${PRODUCER_RECOVERY[name]}`);
|
|
418
|
+
}
|
|
419
|
+
log(composeSummaryLine({ status: 'fail' }));
|
|
420
|
+
return EXIT.fail;
|
|
421
|
+
}
|
|
381
422
|
const probe = spawn(BASH_PROBE_CMD, projectDir);
|
|
382
423
|
if (probe.error && probe.error.code === 'ENOENT') {
|
|
383
424
|
logError(
|