@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.
- package/CHANGELOG.md +49 -1
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/bin/install.mjs +10 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +20 -7
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +76 -18
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +20 -7
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +65 -17
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recommendations.md +1 -0
- package/references/modes/worktrees.md +120 -0
- package/references/scripts/archive-decisions.mjs +2 -2
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/changed-surface.mjs +8 -8
- package/tools/commands.mjs +9 -0
- package/tools/grounding.mjs +13 -13
- package/tools/inject-methodology.mjs +71 -40
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/procedures.mjs +1 -1
- package/tools/recipes.mjs +2 -2
- package/tools/recommendations.mjs +34 -7
- package/tools/release-scan.mjs +9 -2
- package/tools/review-state.mjs +3 -3
- package/tools/sandbox-masks.mjs +13 -13
- package/tools/set-recipe.mjs +1 -1
- package/tools/velocity-profile.mjs +7 -7
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
239
|
-
for (const warn of row.warnings)
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
390
|
+
log(`Wrote ${relative(root, indexPath)}`);
|
|
377
391
|
const after = await stat(indexPath);
|
|
378
392
|
if (after.size === 0) {
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
+
return result(1);
|
|
392
406
|
}
|
|
393
|
-
|
|
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
|
-
|
|
416
|
+
log(
|
|
403
417
|
`\n${rows.length} files inspected — ${errorCount} error(s), ${warnCount} warning(s)`,
|
|
404
418
|
);
|
|
405
419
|
|
|
406
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
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 (
|
|
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];
|
package/tools/commands.mjs
CHANGED
|
@@ -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
|
package/tools/grounding.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
198
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
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
|
-
|
|
352
|
-
|
|
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
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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)
|
|
549
|
-
else
|
|
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
|
-
|
|
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
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|
package/tools/procedures.mjs
CHANGED
|
@@ -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 (
|
|
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
|