@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/SKILL.md +3 -3
  3. package/bridges/antigravity-cli-bridge/SKILL.md +6 -4
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +84 -7
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +113 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +55 -6
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +4 -2
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +5 -0
  10. package/bridges/codex-cli-bridge/SKILL.md +8 -4
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +129 -11
  12. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +211 -1
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +76 -13
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +47 -0
  15. package/bridges/codex-cli-bridge/capability.json +19 -4
  16. package/bridges/codex-cli-bridge/references/driving-codex.md +11 -0
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/bootstrap.md +3 -2
  20. package/references/modes/upgrade.md +9 -4
  21. package/references/modes/velocity.md +8 -2
  22. package/references/shared/command-shapes.md +22 -0
  23. package/references/shared/composition-handoff.md +7 -2
  24. package/references/templates/agent_rules.md +10 -1
  25. package/tools/bridge-settings-read.mjs +25 -5
  26. package/tools/delegation.mjs +2 -2
  27. package/tools/detect-backends.mjs +10 -0
  28. package/tools/lens-region.mjs +113 -36
  29. package/tools/recipes.mjs +5 -2
  30. package/tools/release-scan.mjs +147 -13
  31. package/tools/run-gates.mjs +43 -2
  32. package/tools/velocity-profile.mjs +229 -20
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // Heading-anchored lens-region reconcile — the composition root's only mutation of a deployed
3
- // `docs/ai/agent_rules.md`. The planning/review/process-fidelity lens block (`### 2.x. Planning,
4
- // review & process-fidelity invariants`) has ONE canonical home the installed engine's
5
- // `references/agent-rules-lens.md` and every deployed/template copy is a RENDER of it (the
6
- // file's own section number substituted into the number-neutral heading). This tool refreshes a
7
- // deployed region to the current canon under the AD-025 discipline: refresh IFF the region's
8
- // normalized body matches the current fragment (already current → zero-diff) or a KNOWN PRIOR
9
- // canonical body (the engine's append-only `references/agent-rules-lens-priors.md`, read live
10
- // beside the fragment no kit-side prior constants, so a canon wording change is an engine-only
11
- // release). Anything else is preserved byte-for-byte + a one-line advisory.
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
- export const extractLensRegion = (text) => {
97
+ const extractRegionBy = (text, headingRe) => {
76
98
  const lines = String(text).split('\n');
77
- const start = lines.findIndex((line) => LENS_HEADING_RE.test(stripCr(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(LENS_HEADING_RE)[1];
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
- export const reconcileLensText = (text, fragment, priors) => {
123
- const region = extractLensRegion(text);
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 = normalizeLensBody(region.body);
126
- const canon = normalizeLensBody(fragment);
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) => normalizeLensBody(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, renderLens(fragment, region.number)) };
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 refusal), exit 1 ONLY on the
150
- // hard engine STOP or an unexpected fs error, exit 2 on usage.
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
- // 2. No matching heading → preserve + advise, engine never consulted (the outcome is preserve
181
- // regardless, so the lazy contract holds).
182
- if (!extractLensRegion(text).found) {
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
- // 4. The pure decision + the cap-guard + one atomic write.
219
- const result = reconcileLensText(text, fragment, priors);
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(text);
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. Nothing was changed.`);
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
- const tmp = join(dirname(targetPath), `.${basename(targetPath)}.tmp-${process.pid}-${Date.now()}`);
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
- export const composeAutonomyFacts = async (cwd) => {
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) {
@@ -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
- export const scanPaths = (paths, opts = {}) => {
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 p of paths) {
84
- if (!existsSync(p)) continue;
85
- for (const file of walk(resolve(p))) {
86
- const findings = scanText(readFileSync(file, 'utf8'), opts);
87
- for (const f of findings) report.push({ file, ...f });
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
- process.exit(2);
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 = scanPaths(paths);
231
+ const report = findingsIn(targets);
101
232
  for (const r of report) console.log(`[${r.kind}] ${r.file}:${r.line} — ${r.detail}`);
102
- if (report.length === 0) {
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
- console.log(`\n[release-scan] ${report.length} finding(s).`);
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));
@@ -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, ...extraEnv };
200
+ const env = { ...process.env };
189
201
  delete env.NODE_TEST_CONTEXT;
190
- return spawnSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
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(