@sabaiway/agent-workflow-kit 3.0.0 → 3.1.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 (33) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +1 -0
  3. package/SKILL.md +5 -1
  4. package/bin/install.mjs +10 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -7
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +76 -18
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
  8. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
  9. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +20 -7
  10. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +65 -17
  11. package/capability.json +1 -1
  12. package/package.json +1 -1
  13. package/references/modes/recommendations.md +1 -0
  14. package/references/modes/worktrees.md +120 -0
  15. package/references/scripts/archive-decisions.mjs +2 -2
  16. package/references/scripts/archive-decisions.test.mjs +5 -5
  17. package/references/scripts/check-docs-size-cli.test.mjs +41 -0
  18. package/references/scripts/check-docs-size.mjs +46 -29
  19. package/tools/autonomy-doctor.mjs +1 -1
  20. package/tools/changed-surface.mjs +8 -8
  21. package/tools/commands.mjs +9 -0
  22. package/tools/grounding.mjs +13 -13
  23. package/tools/inject-methodology.mjs +71 -40
  24. package/tools/migrate-adr-store.mjs +3 -3
  25. package/tools/procedures.mjs +1 -1
  26. package/tools/recipes.mjs +2 -2
  27. package/tools/recommendations.mjs +34 -7
  28. package/tools/release-scan.mjs +9 -2
  29. package/tools/review-state.mjs +3 -3
  30. package/tools/sandbox-masks.mjs +13 -13
  31. package/tools/set-recipe.mjs +1 -1
  32. package/tools/velocity-profile.mjs +7 -7
  33. package/tools/worktrees.mjs +2292 -0
@@ -0,0 +1,41 @@
1
+ // check-docs-size-cli.test.mjs — runCli branch pins the subprocess smokes cannot reach
2
+ // in-process (Phase-5 coverage fill; the main spec file is parity-frozen, so these ride a
3
+ // colocated file): the unknown-argument refusal and the written-empty-index guard.
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { runCli } from './check-docs-size.mjs';
10
+
11
+ const cli = async (argv) => {
12
+ const { code, stdout, stderr } = await runCli(argv);
13
+ return { code, stdout, stderr };
14
+ };
15
+
16
+ describe('check-docs-size runCli — refusal branches', () => {
17
+ it('an unknown argument exits 2 naming it', async () => {
18
+ const { code, stderr } = await cli(['--bogus']);
19
+ assert.equal(code, 2);
20
+ assert.match(stderr, /Unknown argument: --bogus/);
21
+ });
22
+
23
+ it('--write-index landing on a sink path (index stat size 0) is the loud written-empty refusal', async () => {
24
+ const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
25
+ try {
26
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
27
+ writeFileSync(
28
+ join(root, 'docs', 'ai', 'a.md'),
29
+ '---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# a\n',
30
+ );
31
+ // The index path is a symlink into /dev/null: the write lands, the stat reads size 0 —
32
+ // the guard must refuse loudly instead of reporting a written index.
33
+ symlinkSync('/dev/null', join(root, 'docs', 'ai', 'index.md'));
34
+ const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
35
+ assert.equal(code, 2);
36
+ assert.match(stderr, /index\.md was written empty/);
37
+ } finally {
38
+ rmSync(root, { recursive: true, force: true });
39
+ }
40
+ });
41
+ });
@@ -103,25 +103,19 @@ export const discoverMeta = async (root = ROOT) => {
103
103
  return { projectName, hierarchicalLinks, onDemandLinks };
104
104
  };
105
105
 
106
+ // Pure argv parser (no I/O, no exit): `help` / `error` ride out as data for runCli to render.
106
107
  const parseArgs = (argv) => {
107
108
  const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
108
109
  const opts = { today: null, root: null };
109
- for (const arg of argv.slice(2)) {
110
+ for (const arg of argv) {
110
111
  if (arg === '--report') flags.report = true;
111
112
  else if (arg === '--write-index') flags.writeIndex = true;
112
113
  else if (arg === '--check-index') flags.checkIndex = true;
113
114
  else if (arg === '--quiet') flags.quiet = true;
114
115
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
115
116
  else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
116
- else if (arg === '--help' || arg === '-h') {
117
- console.log(
118
- 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]',
119
- );
120
- process.exit(0);
121
- } else {
122
- console.error(`Unknown argument: ${arg}`);
123
- process.exit(2);
124
- }
117
+ else if (arg === '--help' || arg === '-h') return { flags, opts, help: true };
118
+ else return { flags, opts, error: `Unknown argument: ${arg}` };
125
119
  }
126
120
  return { flags, opts };
127
121
  };
@@ -218,7 +212,7 @@ const formatRow = (row) => {
218
212
  return { status, sizeCell, ...row };
219
213
  };
220
214
 
221
- const printReport = (rows, quiet) => {
215
+ const printReport = (rows, quiet, log = console.log) => {
222
216
  const widths = {
223
217
  status: 2,
224
218
  path: Math.max(4, ...rows.map((r) => r.path.length)),
@@ -228,15 +222,15 @@ const printReport = (rows, quiet) => {
228
222
  };
229
223
  const printable = quiet ? rows.filter((r) => r.errors.length || r.warnings.length) : rows;
230
224
  if (printable.length > 0) {
231
- console.log(
225
+ log(
232
226
  `${'S'.padEnd(widths.status)} ${'PATH'.padEnd(widths.path)} ${'SIZE/MAX'.padEnd(widths.size)} ${'TYPE'.padEnd(widths.type)} ${'UPDATED'.padEnd(widths.updated)}`,
233
227
  );
234
228
  for (const row of printable) {
235
- console.log(
229
+ log(
236
230
  `${row.status.padEnd(widths.status)} ${row.path.padEnd(widths.path)} ${row.sizeCell.padEnd(widths.size)} ${(row.frontmatter?.type ?? '').padEnd(widths.type)} ${(row.frontmatter?.lastUpdated ?? '').padEnd(widths.updated)}`,
237
231
  );
238
- for (const err of row.errors) console.log(` - ERROR ${err}`);
239
- for (const warn of row.warnings) console.log(` - WARN ${warn}`);
232
+ for (const err of row.errors) log(` - ERROR ${err}`);
233
+ for (const warn of row.warnings) log(` - WARN ${warn}`);
240
234
  }
241
235
  }
242
236
  };
@@ -361,9 +355,29 @@ export const regenerateIndex = async (root, todayStr = null) => {
361
355
  return { indexPath, files: rows.length };
362
356
  };
363
357
 
364
- const main = async () => {
365
- const { flags, opts } = parseArgs(process.argv);
366
- const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : ROOT);
358
+ // The return-code entry point (no process.argv / process.exit / console inside): argv[]
359
+ // { code, stdout, stderr }. The thin shell at the bottom is the only process-coupled code.
360
+ export const runCli = async (argv, deps = {}) => {
361
+ const stdoutLines = [];
362
+ const stderrLines = [];
363
+ const log = (line) => stdoutLines.push(line);
364
+ const logError = (line) => stderrLines.push(line);
365
+ const result = (code) => ({
366
+ code,
367
+ stdout: stdoutLines.length > 0 ? `${stdoutLines.join('\n')}\n` : '',
368
+ stderr: stderrLines.length > 0 ? `${stderrLines.join('\n')}\n` : '',
369
+ });
370
+
371
+ const { flags, opts, help, error } = parseArgs(argv);
372
+ if (help) {
373
+ log('Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
374
+ return result(0);
375
+ }
376
+ if (error) {
377
+ logError(error);
378
+ return result(2);
379
+ }
380
+ const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : (deps.root ?? ROOT));
367
381
  const today = computeToday(opts.today);
368
382
  const files = (await walkMarkdownFiles(docsDir)).sort();
369
383
  const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
@@ -373,11 +387,11 @@ const main = async () => {
373
387
 
374
388
  if (flags.writeIndex) {
375
389
  await writeIndex(rows, today, meta, indexPath);
376
- console.log(`Wrote ${relative(root, indexPath)}`);
390
+ log(`Wrote ${relative(root, indexPath)}`);
377
391
  const after = await stat(indexPath);
378
392
  if (after.size === 0) {
379
- console.error('index.md was written empty');
380
- process.exit(2);
393
+ logError('index.md was written empty');
394
+ return result(2);
381
395
  }
382
396
  }
383
397
 
@@ -385,28 +399,31 @@ const main = async () => {
385
399
  const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
386
400
  const { fresh } = checkIndexFreshness(rows, onDisk, meta);
387
401
  if (!fresh) {
388
- console.error(
402
+ logError(
389
403
  `[check-docs-size] FAIL: ${relative(root, indexPath)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
390
404
  );
391
- process.exit(1);
405
+ return result(1);
392
406
  }
393
- console.log(
407
+ log(
394
408
  `[check-docs-size] OK — ${relative(root, indexPath)} is in sync with source frontmatter.`,
395
409
  );
396
- return;
410
+ return result(0);
397
411
  }
398
412
 
399
- printReport(rows, flags.quiet);
413
+ printReport(rows, flags.quiet, log);
400
414
  const errorCount = rows.reduce((n, r) => n + r.errors.length, 0);
401
415
  const warnCount = rows.reduce((n, r) => n + r.warnings.length, 0);
402
- console.log(
416
+ log(
403
417
  `\n${rows.length} files inspected — ${errorCount} error(s), ${warnCount} warning(s)`,
404
418
  );
405
419
 
406
- if (errorCount > 0 && !flags.report) process.exit(1);
420
+ return result(errorCount > 0 && !flags.report ? 1 : 0);
407
421
  };
408
422
 
409
423
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
410
424
  if (isDirectRun) {
411
- await main();
425
+ const { code, stdout, stderr } = await runCli(process.argv.slice(2));
426
+ if (stdout) process.stdout.write(stdout);
427
+ if (stderr) process.stderr.write(stderr);
428
+ process.exitCode = code;
412
429
  }
@@ -254,7 +254,7 @@ const renderDiagnosis = ({ plan, probeResult, log }) => {
254
254
  }
255
255
  };
256
256
 
257
- // The untrusted degrade every lane runs FIRST (codex R1): while any sandbox binary resolves only
257
+ // The untrusted degrade every lane runs FIRST: while any sandbox binary resolves only
258
258
  // outside the trusted dirs, no offer, no consent invitation, and no privileged path may proceed —
259
259
  // an install cannot make such a host verify-ready.
260
260
  const refuseUntrusted = ({ plan, log }) => {
@@ -37,7 +37,7 @@ export const classifyChangedPath = (rel) => {
37
37
 
38
38
  // Strip the quotes and decode escapes BYTE-wise (octal escapes are UTF-8 bytes) — an unparsed
39
39
  // quoted path compares unequal to its classifier/testId form and would silently escape the
40
- // coverage/cap surface (agy R5, AD-047).
40
+ // coverage/cap surface (AD-047).
41
41
  const CQUOTE_SIMPLE = { n: 10, t: 9, r: 13, f: 12, v: 11, b: 8, a: 7, '"': 34, '\\': 92 };
42
42
  export const unquoteDiffPath = (p) => {
43
43
  if (!(p.length >= 2 && p.startsWith('"') && p.endsWith('"'))) return p;
@@ -47,7 +47,7 @@ export const unquoteDiffPath = (p) => {
47
47
  const c = inner[i];
48
48
  if (c !== '\\') {
49
49
  // Consume a full CODE POINT — 16-bit-unit iteration would split a surrogate pair (an
50
- // unescaped non-BMP char, reachable under core.quotepath=false) into replacement bytes (agy R6).
50
+ // unescaped non-BMP char, reachable under core.quotepath=false) into replacement bytes.
51
51
  const ch = String.fromCodePoint(inner.codePointAt(i));
52
52
  for (const b of Buffer.from(ch, 'utf8')) bytes.push(b);
53
53
  i += ch.length - 1;
@@ -89,7 +89,7 @@ export const parseUnifiedDiff = (diffText) => {
89
89
  }
90
90
  if (inHeader && line.startsWith('--- ')) continue;
91
91
  if (inHeader && line.startsWith('+++ ')) {
92
- const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes (agy R6)
92
+ const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes
93
93
  current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
94
94
  inHeader = false;
95
95
  continue;
@@ -114,7 +114,7 @@ export const parseUnifiedDiff = (diffText) => {
114
114
  // ── the changed surface (git-driven; the ONE computation both consumers read) ─────────────────────
115
115
 
116
116
  // The one diff-invocation shape every surface pass uses. The a/ b/ prefixes are pinned EXPLICITLY
117
- // (agy R6): a user's global diff.noprefix=true would otherwise drop them and the parsers would eat
117
+ // A user's global diff.noprefix=true would otherwise drop them and the parsers would eat
118
118
  // a real directory named "a" — user git config must never bend the parse.
119
119
  export const DIFF_FLAGS = ['--unified=0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/'];
120
120
 
@@ -134,7 +134,7 @@ export const readFileSafe = (path) => {
134
134
  };
135
135
  // A changed LEAF is read only if it is a REGULAR file. lstat (no-follow): a symlinked or non-regular
136
136
  // leaf must NEVER be read/followed — following it could read outside the work tree or HANG on a
137
- // FIFO/device (codex R2, AD-046). A non-regular leaf fails closed (routed to `unsupported`).
137
+ // FIFO/device (AD-046). A non-regular leaf fails closed (routed to `unsupported`).
138
138
  export const isRegularLeaf = (abs) => {
139
139
  try {
140
140
  return lstatSync(abs).isFile();
@@ -150,13 +150,13 @@ export const isRegularLeaf = (abs) => {
150
150
  // pure deletions cost nothing, subtractive folds stay free); an untracked file is wholly new, so all
151
151
  // its lines are "changed". `unsupportedLines` is the D4 cap's view of the SAME pass: unsupported
152
152
  // SOURCE files carry their new-side lines too (excluding them would gift a large-TS-fold bypass,
153
- // codex R2 / BUGFREE-2 D4); an unreadable/non-regular leaf counts 0 lines but stays LISTED (the
153
+ // BUGFREE-2 D4); an unreadable/non-regular leaf counts 0 lines but stays LISTED (the
154
154
  // coverage gate still fails closed on it). excluded-test and out-of-domain never carry lines.
155
155
  export const computeChangedSurface = (root) => {
156
156
  // Unborn branch (no HEAD yet): the plain diff alone misses files STAGED for the initial commit —
157
157
  // they sit in the index, so they are neither worktree-vs-index changes nor untracked. Merge the
158
158
  // --cached diff (index vs the empty tree) with the plain one; parseUnifiedDiff unions per-file
159
- // lines across concatenated diffs (codex R1).
159
+ // lines across concatenated diffs.
160
160
  const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
161
161
  ?? `${gitStdout(['diff', '--cached', ...DIFF_FLAGS], root) ?? ''}\n${gitStdout(['diff', ...DIFF_FLAGS], root) ?? ''}`;
162
162
  const trackedLines = parseUnifiedDiff(trackedDiff);
@@ -207,7 +207,7 @@ export const computeChangedSurface = (root) => {
207
207
  // ── the shared fail-closed positive-integer knob parser (AD-047 precedent) ───────────────────────
208
208
 
209
209
  // Zero / negative / fractional / non-numeric values are refusals by name — the parseInt(...)||default
210
- // idiom would silently accept bad truthy values (codex R2, AD-046). Unset → the default; set → a
210
+ // idiom would silently accept bad truthy values (AD-046). Unset → the default; set → a
211
211
  // positive integer, exactly. `makeError` lets each caller throw its OWN typed STOP.
212
212
  export const parsePositiveIntKnob = (env, name, fallback, makeError = (m) => new Error(m)) => {
213
213
  const raw = env[name];
@@ -231,6 +231,15 @@ const CATALOG = [
231
231
  kind: READ_ONLY,
232
232
  oneLine: 'Check that the documented contract tokens still match the live code constants they describe — a read-only lint that fails closed on drift; --check turns it into a gate exit code.',
233
233
  },
234
+ {
235
+ // NEVER `guarded` — that kind promises dry-run-first, which these writers do not have; the
236
+ // honest strongest caution is `writer` with the destructive arm named in the line itself.
237
+ key: 'worktrees',
238
+ invocation: invocationOf('worktrees'),
239
+ group: 'Orchestrate',
240
+ kind: WRITER,
241
+ oneLine: 'Run features in parallel git worktrees: provision an isolated sibling copy, list them, stage a finished one back onto clean main (the commit still asks in dialogue), and remove a live-verified landed one. No preview step; list is read-only; cleanup --abandon destroys unlanded work.',
242
+ },
234
243
  ];
235
244
 
236
245
  // Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
@@ -102,7 +102,7 @@ export const assembleGrounding = ({ constraintsText = null, autonomyText = null,
102
102
  // Trim `payload` to `budget` bytes, tail-first, with a loud in-band marker. The budget is a HARD
103
103
  // ceiling: when it is smaller than the marker itself, the marker is truncated too (the output may
104
104
  // never exceed `budget` — a facts file over the reserved share would push the final wrapper prompt
105
- // past the argv ceiling; codex R1 finding). Returns { text, trimmedBytes }. Never a silent cut.
105
+ // past the argv ceiling). Returns { text, trimmedBytes }. Never a silent cut.
106
106
  export const trimToBudget = (payload, budget) => {
107
107
  const bytes = Buffer.byteLength(payload, 'utf8');
108
108
  if (bytes <= budget) return { text: payload, trimmedBytes: 0 };
@@ -164,17 +164,17 @@ const gitLine = (args, cwd) => {
164
164
  // EXISTING in-repo file, even gitignored, is a project file — the .env clobber class). Refused:
165
165
  // tracked paths, in-repo not-ignored paths (a new untracked file would move the fingerprint),
166
166
  // every other outside-repo destination, symlink and existing non-regular leaves, and any
167
- // unverifiable lstat. The check runs on the REAL destination, never the lexical path (codex R1):
167
+ // unverifiable lstat. The check runs on the REAL destination, never the lexical path:
168
168
  // the parent directory is realpath-resolved first, so a symlink can never route the write onto a
169
169
  // tracked/in-repo file. Returns { path, kind: 'temp' | 'repo-fresh' } — a repo-fresh caller MUST
170
- // write with the exclusive flag ('wx'), sealing the guard→write race (codex R11).
170
+ // write with the exclusive flag ('wx'), sealing the guard→write race.
171
171
  export const assertScratchDestination = (outPath, cwd) => {
172
172
  const lexical = isAbsolute(outPath) ? outPath : resolve(cwd, outPath);
173
173
  let leaf = null;
174
174
  try {
175
175
  leaf = lstatSync(lexical);
176
176
  } catch (err) {
177
- // ONLY an absent leaf is a fresh file; any other lstat failure fails CLOSED (codex R9 — this
177
+ // ONLY an absent leaf is a fresh file; any other lstat failure fails CLOSED (this
178
178
  // writer is bridge-tier auto-allowable, so an unverifiable leaf must never be written).
179
179
  if (err?.code !== 'ENOENT') {
180
180
  throw fail(1, `--out cannot inspect the destination (${outPath}: ${err?.code ?? err?.message ?? err}) — refusing to write an unverifiable leaf`);
@@ -185,7 +185,7 @@ export const assertScratchDestination = (outPath, cwd) => {
185
185
  throw fail(1, `--out refuses a symlink destination (${outPath}) — the write would follow it onto another file; name the real scratch path`);
186
186
  }
187
187
  if (leaf != null && !leaf.isFile()) {
188
- throw fail(1, `--out refuses an existing non-regular destination (${outPath}) — a FIFO/device/directory write would hang or land on a non-file (codex R9); name a fresh or regular scratch path`);
188
+ throw fail(1, `--out refuses an existing non-regular destination (${outPath}) — a FIFO/device/directory write would hang or land on a non-file; name a fresh or regular scratch path`);
189
189
  }
190
190
  let realParent;
191
191
  try {
@@ -194,8 +194,8 @@ export const assertScratchDestination = (outPath, cwd) => {
194
194
  throw fail(1, `--out parent directory does not exist (${dirname(lexical)}) — create the scratch dir first`);
195
195
  }
196
196
  const full = join(realParent, basename(lexical));
197
- // An out-of-repo (or no-repo) destination is scratch ONLY under a system temp root (codex R8
198
- // blocker): the bridge tier auto-allows this tool with an args wildcard, so "anything outside
197
+ // An out-of-repo (or no-repo) destination is scratch ONLY under a system temp root: the bridge
198
+ // tier auto-allows this tool with an args wildcard, so "anything outside
199
199
  // the repo is scratch" would let an unattended run overwrite e.g. ~/.bashrc promptless.
200
200
  // $TMPDIR / os.tmpdir() / /tmp are the scratch surface; everything else refuses loudly.
201
201
  const assertTempScratch = () => {
@@ -215,7 +215,7 @@ export const assertScratchDestination = (outPath, cwd) => {
215
215
  if (top == null || top.status !== 0) return { path: assertTempScratch().path, kind: 'temp' }; // no git tree → temp-only scratch
216
216
  const root = top.stdout.replace(/\r?\n$/, '');
217
217
  const rel = relative(root, full);
218
- // Segment-safe outside test (the Issue-004 class, codex R5): an in-repo file literally named
218
+ // Segment-safe outside test (the Issue-004 class): an in-repo file literally named
219
219
  // `..facts` has rel `..facts` — `startsWith('..')` would misread it as outside and BYPASS the
220
220
  // tracked/ignored refusals below. Only `..` exactly or a `..${sep}`-prefixed rel is outside.
221
221
  if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { path: assertTempScratch().path, kind: 'temp' };
@@ -227,11 +227,11 @@ export const assertScratchDestination = (outPath, cwd) => {
227
227
  if (ignored == null || ignored.status !== 0) {
228
228
  throw fail(1, `--out refuses an in-repo path that is not gitignored (${rel}) — a new untracked file would move the review fingerprint; use a gitignored path or a location outside the repo`);
229
229
  }
230
- // An in-repo destination must be a FRESH file (codex R10): even a gitignored existing file is a
230
+ // An in-repo destination must be a FRESH file: even a gitignored existing file is a
231
231
  // project file (.env is the canonical victim) — an auto-allowable writer must never clobber one.
232
232
  // The rewritable scratch surface is the system temp; in-repo gitignored writes are create-only —
233
233
  // the CALLER must open with the exclusive flag ('wx'): the kind seals the pre-check against the
234
- // guard→write race (codex R11).
234
+ // guard→write race.
235
235
  if (leaf != null) {
236
236
  throw fail(1, `--out refuses to OVERWRITE an existing in-repo file (${rel}) — even gitignored, it is a project file (the .env class); write rewritable scratch under $TMPDIR//tmp, or remove the file first`);
237
237
  }
@@ -328,7 +328,7 @@ export const main = (argv, ctx = {}) => {
328
328
  };
329
329
  const constraintsText = constraints ? readOrStop('AGENTS.md', 'root AGENTS.md') : null;
330
330
  const autonomyText = autonomy ? resolveAutonomyFacts({ cwd }) : null;
331
- // --plan is CONFINED to the git work tree (codex R4): the bridge tier auto-allows this tool
331
+ // --plan is CONFINED to the git work tree: the bridge tier auto-allows this tool
332
332
  // with an args wildcard, so an unattended invocation could otherwise point --plan at ANY
333
333
  // readable file and ship it into the facts payload. Plans are in-repo by contract
334
334
  // (docs/plans); an outside-tree path is a loud refusal, never a silent read.
@@ -360,7 +360,7 @@ export const main = (argv, ctx = {}) => {
360
360
  if (out != null) {
361
361
  const dest = assertScratchDestination(out, cwd);
362
362
  // repo-fresh writes are EXCLUSIVE ('wx'): the create-only pre-check would otherwise race a
363
- // file created between the guard and this write (codex R11); temp scratch stays rewritable.
363
+ // file created between the guard and this write; temp scratch stays rewritable.
364
364
  // ctx.writeFile is a TEST seam (the EEXIST race arm is not constructible without it).
365
365
  const writeFile = ctx.writeFile ?? writeFileSync;
366
366
  try {
@@ -380,7 +380,7 @@ export const main = (argv, ctx = {}) => {
380
380
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
381
381
  if (isDirectRun) {
382
382
  const r = main(process.argv.slice(2));
383
- // Exact writes + a natural exit (codex R2): console.log would append a stray newline to the
383
+ // Exact writes + a natural exit: console.log would append a stray newline to the
384
384
  // byte-exact facts payload, and process.exit() can truncate large piped stdout before Node
385
385
  // flushes. The payload already ends with '\n' (sliceSection normalizes); only a non-payload
386
386
  // message (help / the --out report) gains the terminating newline it lacks.
@@ -314,11 +314,31 @@ const isCapRefusal = (errorMessage) => typeof errorMessage === 'string' && error
314
314
  export const isFillCapRefusal = (errorMessage) =>
315
315
  isCapRefusal(errorMessage) && errorMessage.includes('injection would push');
316
316
 
317
- const main = async (argv) => {
317
+ const EXIT = Symbol('inject-methodology.exit');
318
+
319
+ // The return-code entry point (5.1): argv[] + injected env/home → { code, stdout, stderr }, no
320
+ // process.argv / process.exit / console inside — an in-process caller is as hermetic as a spawned
321
+ // one. The thin shell at the bottom is the only process-coupled code.
322
+ export const runCli = async (argv, deps = {}) => {
318
323
  const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
319
324
  const { dirname, basename, join, resolve } = await import('node:path');
320
325
  const { homedir } = await import('node:os');
321
326
  const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL, AUTONOMY_FRAGMENT_REL } = await import('./engine-source.mjs');
327
+ const env = deps.env ?? process.env;
328
+ const home = deps.home ?? homedir();
329
+
330
+ const stdoutLines = [];
331
+ const stderrLines = [];
332
+ const log = (line) => stdoutLines.push(line);
333
+ const logError = (line) => stderrLines.push(line);
334
+ const result = (code) => ({
335
+ code,
336
+ stdout: stdoutLines.length > 0 ? `${stdoutLines.join('\n')}\n` : '',
337
+ stderr: stderrLines.length > 0 ? `${stderrLines.join('\n')}\n` : '',
338
+ });
339
+ // Nested sourcing helpers end the run from arbitrary depth — a tagged throw the catch below
340
+ // translates into the result code (the process.exit of the pre-entry-point CLI).
341
+ const stop = (code) => Object.assign(new Error(`exit ${code}`), { [EXIT]: code });
322
342
 
323
343
  // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade) for
324
344
  // ALL THREE slots; `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-(methodology)-slot mode.
@@ -326,10 +346,11 @@ const main = async (argv) => {
326
346
  const rest = mode === 'reconcile' ? argv.slice(1) : argv;
327
347
  const agentsPath = rest[0];
328
348
  if (!agentsPath) {
329
- console.error('usage: inject-methodology.mjs [reconcile] <path/to/AGENTS.md> [fragment.md]');
330
- process.exit(2);
349
+ logError('usage: inject-methodology.mjs [reconcile] <path/to/AGENTS.md> [fragment.md]');
350
+ return result(2);
331
351
  }
332
352
  const explicitFragmentArg = rest[1];
353
+ try {
333
354
  const text = await readFile(resolve(agentsPath), 'utf8');
334
355
 
335
356
  // Source a bounded fragment LAZILY, per slot. An explicit [fragment.md] arg (tests + manual) wins and
@@ -339,7 +360,7 @@ const main = async (argv) => {
339
360
  // the install command. The caller only invokes this when a fill is actually needed (the laziness).
340
361
  const sourceFragment = async (rel) => {
341
362
  if (explicitFragmentArg) return readFile(resolve(explicitFragmentArg), 'utf8');
342
- const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
363
+ const { dir, source } = resolveEngineDir({ env, home });
343
364
  return readEngineFragment(dir, { source, rel }); // sync; throws loudly when the engine is absent/invalid
344
365
  };
345
366
  const sourceFragmentOrStop = async (label, rel) => {
@@ -348,8 +369,8 @@ const main = async (argv) => {
348
369
  } catch (err) {
349
370
  // Engine needed-but-absent → a hard STOP, distinct from the soft cap-skip. The "methodology
350
371
  // engine not found/invalid" prefix lets the agent classify this exit (SKILL.md).
351
- console.error(`[inject-methodology] ${label} — ${err.message}`);
352
- process.exit(1);
372
+ logError(`[inject-methodology] ${label} — ${err.message}`);
373
+ throw stop(1);
353
374
  }
354
375
  };
355
376
 
@@ -362,16 +383,16 @@ const main = async (argv) => {
362
383
  // an unreadable fragment). Returns { fragment } on success, { skip } for the soft case, or
363
384
  // process.exit(1) for the hard STOP.
364
385
  const sourceChainedFragment = async (rel) => {
365
- const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
366
- const stop = (err) => {
367
- console.error(`[inject-methodology] reconcile STOP — ${err.message}`);
368
- process.exit(1);
386
+ const { dir, source } = resolveEngineDir({ env, home });
387
+ const chainedStop = (err) => {
388
+ logError(`[inject-methodology] reconcile STOP — ${err.message}`);
389
+ throw stop(1);
369
390
  };
370
391
  if (detectEngine(dir, { source, rel }).ok) {
371
392
  try {
372
393
  return { fragment: readEngineFragment(dir, { source, rel }) };
373
394
  } catch (err) {
374
- stop(err);
395
+ chainedStop(err);
375
396
  }
376
397
  }
377
398
  if (detectEngine(dir, { source }).ok) {
@@ -380,7 +401,7 @@ const main = async (argv) => {
380
401
  try {
381
402
  readEngineFragment(dir, { source, rel }); // throws the canonical install-me error
382
403
  } catch (err) {
383
- stop(err);
404
+ chainedStop(err);
384
405
  }
385
406
  };
386
407
 
@@ -402,8 +423,8 @@ const main = async (argv) => {
402
423
  if (methResult.status === 'error') {
403
424
  // cap-refusal OR malformed/anchor STOP — preserve the single-slot classification (SKILL.md
404
425
  // distinguishes by the message); the file is byte-for-byte unchanged either way.
405
- console.error(`[inject-methodology] reconcile refused — ${methResult.error}`);
406
- process.exit(1);
426
+ logError(`[inject-methodology] reconcile refused — ${methResult.error}`);
427
+ return result(1);
407
428
  }
408
429
  const afterMeth = methResult.text; // === text when the methodology slot was already filled (custom)
409
430
  const describeMeth = {
@@ -421,18 +442,18 @@ const main = async (argv) => {
421
442
  const u = markerSlotUpgradeHint(afterMeth, METHODOLOGY_DESCRIPTOR); if (u) notes.push(u);
422
443
  }
423
444
  const reportNotes = () => {
424
- for (const n of notes) console.log(`[inject-methodology] note: ${n}`);
445
+ for (const n of notes) log(`[inject-methodology] note: ${n}`);
425
446
  };
426
447
 
427
448
  // ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration + autonomy reconciles ──
428
449
  if (explicitFragmentArg) {
429
450
  if (afterMeth === text) {
430
- console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
431
- return;
451
+ log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
452
+ return result(0);
432
453
  }
433
454
  await writeAtomic(afterMeth);
434
- console.log(`[inject-methodology] reconcile: ${describeMeth}.`);
435
- return;
455
+ log(`[inject-methodology] reconcile: ${describeMeth}.`);
456
+ return result(0);
436
457
  }
437
458
 
438
459
  // ── Slot 2: orchestration, reconciled on the methodology-reconciled text (the cap-check then guards
@@ -459,8 +480,8 @@ const main = async (argv) => {
459
480
  describeOrch = `orchestration-recipes pointer skipped — ${orchResult.error}`;
460
481
  } else {
461
482
  // Malformed orchestration slot/anchor → a hard STOP. No partial write.
462
- console.error(`[inject-methodology] reconcile refused (orchestration) — ${orchResult.error}`);
463
- process.exit(1);
483
+ logError(`[inject-methodology] reconcile refused (orchestration) — ${orchResult.error}`);
484
+ return result(1);
464
485
  }
465
486
  } else {
466
487
  finalText = orchResult.text;
@@ -493,8 +514,8 @@ const main = async (argv) => {
493
514
  // Malformed markers are a hard STOP on EVERY lane — validated before the soft-skip
494
515
  // short-circuits so a duplicate/reversed autonomy pair can never ride out as a "skip"
495
516
  // alongside a partial (methodology) write.
496
- console.error(`[inject-methodology] reconcile refused (autonomy) — ${autSlot.reason}`);
497
- process.exit(1);
517
+ logError(`[inject-methodology] reconcile refused (autonomy) — ${autSlot.reason}`);
518
+ return result(1);
498
519
  }
499
520
  if (orchSkipped) {
500
521
  // The chain is CAUSAL, not merely positional: a pointer never lands in a run that withheld
@@ -523,8 +544,8 @@ const main = async (argv) => {
523
544
  autSkipped = true;
524
545
  describeAut = `autonomy pointer skipped — ${autResult.error}`;
525
546
  } else {
526
- console.error(`[inject-methodology] reconcile refused (autonomy) — ${autResult.error}`);
527
- process.exit(1);
547
+ logError(`[inject-methodology] reconcile refused (autonomy) — ${autResult.error}`);
548
+ return result(1);
528
549
  }
529
550
  } else {
530
551
  finalText = autResult.text;
@@ -545,34 +566,44 @@ const main = async (argv) => {
545
566
  // ── One atomic write of the final (three-slot) text ──
546
567
  if (finalText === text) {
547
568
  // Byte-unchanged. Still report a skip (it is not "nothing to do" — a pointer was withheld).
548
- if (orchSkipped || autSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
549
- else console.log('[inject-methodology] reconcile: all three pointers already present and filled — nothing to do (zero-diff).');
569
+ if (orchSkipped || autSkipped) log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
570
+ else log('[inject-methodology] reconcile: all three pointers already present and filled — nothing to do (zero-diff).');
550
571
  reportNotes();
551
- return;
572
+ return result(0);
552
573
  }
553
574
  await writeAtomic(finalText);
554
- console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
575
+ log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
555
576
  reportNotes();
556
- return;
577
+ return result(0);
557
578
  }
558
579
 
559
580
  // Legacy inject-into-existing-slot mode (METHODOLOGY only). injectMethodology no-ops on absent markers
560
581
  // and errors on a malformed slot WITHOUT reading the fragment, so resolve+read the engine only when
561
582
  // there is a present (ok) slot to fill — a markerless legacy AGENTS.md stays a no-op without the engine.
562
583
  const fragment = findSlot(text).state === 'ok' ? await sourceFragmentOrStop('STOP', ENGINE_FRAGMENT_REL) : '';
563
- const result = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
564
- if (result.status === 'error') {
565
- console.error(`[inject-methodology] malformed slot — refusing to edit: ${result.error}`);
566
- process.exit(1);
584
+ const injected = injectMethodology(text, fragment, { maxLines: AGENTS_MD_CAP });
585
+ if (injected.status === 'error') {
586
+ logError(`[inject-methodology] malformed slot — refusing to edit: ${injected.error}`);
587
+ return result(1);
588
+ }
589
+ if (injected.status === 'noop-absent') {
590
+ log('[inject-methodology] no methodology markers found — nothing to inject (legacy AGENTS.md).');
591
+ return result(0);
567
592
  }
568
- if (result.status === 'noop-absent') {
569
- console.log('[inject-methodology] no methodology markers found nothing to inject (legacy AGENTS.md).');
570
- return;
593
+ await writeAtomic(injected.text);
594
+ log('[inject-methodology] injected the bounded methodology fragment into the slot.');
595
+ return result(0);
596
+ } catch (err) {
597
+ if (err[EXIT] !== undefined) return result(err[EXIT]);
598
+ throw err;
571
599
  }
572
- await writeAtomic(result.text);
573
- console.log('[inject-methodology] injected the bounded methodology fragment into the slot.');
574
600
  };
575
601
 
576
602
  const { pathToFileURL } = await import('node:url');
577
603
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
578
- if (isDirectRun) await main(process.argv.slice(2));
604
+ if (isDirectRun) {
605
+ const { code, stdout, stderr } = await runCli(process.argv.slice(2));
606
+ if (stdout) process.stdout.write(stdout);
607
+ if (stderr) process.stderr.write(stderr);
608
+ process.exitCode = code;
609
+ }
@@ -125,7 +125,7 @@ const isUnder = (child, parent) => {
125
125
 
126
126
  // The ordered snapshot bases that are provably NOT stageable: the git dir first (always safe), then the
127
127
  // fallback base ONLY when its snapshot dir lands OUTSIDE cwd (else it is in the work tree and could be
128
- // committed — reject it; codex R1 minor). Returns [{ base, dir, viaGitDir }] (possibly empty).
128
+ // committed — reject it). Returns [{ base, dir, viaGitDir }] (possibly empty).
129
129
  const snapshotBases = (cwd, stamp, gitDir, fallbackBase) => {
130
130
  const bases = [];
131
131
  if (gitDir) bases.push({ base: gitDir, dir: resolve(gitDir, `${SNAPSHOT_PREFIX}-${stamp}`), viaGitDir: true });
@@ -226,14 +226,14 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
226
226
  log(` snapshot → ${preview.dir ? `${preview.dir} (${preview.viaGitDir ? 'git dir' : 'out-of-tree fallback'})` : 'NONE — no out-of-tree location; run inside a git repo (apply would refuse otherwise)'}`);
227
227
  log(` refresh ${refresh.length} enforcement script(s) to this kit's version${drifted.length ? ` (${drifted.length} locally differ: ${drifted.map((r) => r.name).join(', ')})` : ''}`);
228
228
  log(' then the conservation-checked rotation:');
229
- // Surface the rotation's own exit code (codex R1 major): a failed dry-run must NOT print the
229
+ // Surface the rotation's own exit code: a failed dry-run must NOT print the
230
230
  // "run with --apply" go-ahead nor exit 0 — it would send the user to --apply on an unsafe tree.
231
231
  const code = runMigrate(['--migrate'], { root: cwd, log: (m) => log(` ${m}`), logError: (m) => error(` ${m}`) });
232
232
  if (code !== EXIT_OK) {
233
233
  throw stop(`the dry-run rotation would not conserve every ADR (exit ${code}) — NOT safe to --apply; fix the reported problem, then re-run.`);
234
234
  }
235
235
  // A null preview means --apply would refuse (no out-of-tree snapshot base) — never green-light it
236
- // (codex R2 minor: a dry-run go-ahead must not send the user to an apply that will STOP).
236
+ // A dry-run go-ahead must not send the user to an apply that will STOP.
237
237
  if (preview.dir === null) {
238
238
  throw stop('no out-of-tree snapshot location — --apply would refuse; run inside a git repo (or point the fallback outside the project), then re-run.');
239
239
  }
@@ -234,7 +234,7 @@ const groundingPreStepAdvice = (activity, slots, plans) => {
234
234
  if (!slots.some((s) => (s.backends ?? []).includes('agy-review'))) return [];
235
235
  const planArg = plans.length === 1 ? `--plan "${PLANS_REL}/${plans[0]}"` : '--plan <path>';
236
236
  // plan-authoring reviews the plan FILE — when exactly one plan is in flight, the review command
237
- // is populated with the same discovered path (codex R3: a known path never renders a placeholder).
237
+ // is populated with the same discovered path (a known path never renders a placeholder).
238
238
  const reviewForm =
239
239
  activity === 'plan-authoring'
240
240
  ? plans.length === 1