@sabaiway/agent-workflow-kit 5.7.0 → 5.9.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.
@@ -11,6 +11,9 @@
11
11
  // it is not a heading in canon) and `## Verification` (REQUIRED — STOP if
12
12
  // missing), plus `## Decisions (locked)` (optional-if-absent, the engine §7
13
13
  // heading this release adds); a DUPLICATE heading is always a STOP.
14
+ // --extra <text|@file> append orchestrator-supplied facts verbatim AFTER the mechanical halves
15
+ // (repeatable; @file reads are confined to the work tree + the system temp
16
+ // surface — the merge happens INSIDE the tool, corpus #88/#95).
14
17
  //
15
18
  // Byte budget: the output honors the same AGY_MAX_PROMPT_BYTES contract the agy wrapper enforces
16
19
  // (default 120000; the override may only TIGHTEN — above the OS single-argv ceiling ~131000 is
@@ -31,6 +34,7 @@ import { tmpdir } from 'node:os';
31
34
  import { pathToFileURL } from 'node:url';
32
35
  import { spawnSync } from 'node:child_process';
33
36
  import { fail } from './orchestration-config.mjs';
37
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
34
38
  // (f) --autonomy (AD-044 Plan 3): the effective per-project autonomy policy for the facts payload.
35
39
  // READ core only — never autonomy-write.mjs (the import-split invariant).
36
40
  import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
@@ -81,7 +85,7 @@ export const sliceSection = (text, heading, { optional = false, label = 'documen
81
85
 
82
86
  // ── assembly ───────────────────────────────────────────────────────────────────────
83
87
 
84
- export const assembleGrounding = ({ constraintsText = null, autonomyText = null, planText = null, planLabel = 'plan' } = {}) => {
88
+ export const assembleGrounding = ({ constraintsText = null, autonomyText = null, planText = null, planLabel = 'plan', extraTexts = [] } = {}) => {
85
89
  const parts = [];
86
90
  if (constraintsText != null) {
87
91
  parts.push(sliceSection(constraintsText, CONSTRAINTS_HEADING, { label: 'AGENTS.md' }));
@@ -96,6 +100,10 @@ export const assembleGrounding = ({ constraintsText = null, autonomyText = null,
96
100
  if (section != null) parts.push(section);
97
101
  }
98
102
  }
103
+ // Orchestrator extras ride LAST, verbatim in argv order — live judgment facts read after the
104
+ // mechanical slices, and the merge happens INSIDE the tool (corpus #88/#95: a shell append onto
105
+ // the emitted facts file was the recurring un-covered lane).
106
+ for (const t of extraTexts) parts.push(t);
99
107
  return parts.join('\n');
100
108
  };
101
109
 
@@ -151,10 +159,23 @@ const resolveAutonomyFacts = ({ cwd }) => {
151
159
  return renderAutonomyFacts(config, source);
152
160
  };
153
161
 
162
+ // The realpath'd system temp surface ($TMPDIR / os.tmpdir() / /tmp) — the shared scratch boundary
163
+ // for the --out write guard and the --extra read guard.
164
+ const systemTempRoots = () => [...new Set([tmpdir(), process.env.TMPDIR, '/tmp'].filter(Boolean).map((p) => {
165
+ try {
166
+ return realpathSync(p);
167
+ } catch {
168
+ return null;
169
+ }
170
+ }).filter(Boolean))];
171
+
154
172
  // ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
155
173
 
156
174
  const gitLine = (args, cwd) => {
157
- const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
175
+ // Ambient GIT_* location vars (GIT_DIR / GIT_WORK_TREE / …) would let rev-parse prove a FOREIGN
176
+ // tree — every location answer must come from cwd alone, for every gitLine consumer.
177
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k)));
178
+ const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', windowsHide: true });
158
179
  return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
159
180
  };
160
181
 
@@ -199,13 +220,7 @@ export const assertScratchDestination = (outPath, cwd) => {
199
220
  // the repo is scratch" would let an unattended run overwrite e.g. ~/.bashrc promptless.
200
221
  // $TMPDIR / os.tmpdir() / /tmp are the scratch surface; everything else refuses loudly.
201
222
  const assertTempScratch = () => {
202
- const tempRoots = [...new Set([tmpdir(), process.env.TMPDIR, '/tmp'].filter(Boolean).map((p) => {
203
- try {
204
- return realpathSync(p);
205
- } catch {
206
- return null;
207
- }
208
- }).filter(Boolean))];
223
+ const tempRoots = systemTempRoots();
209
224
  if (!tempRoots.some((t) => full === t || full.startsWith(`${t}${sep}`))) {
210
225
  throw fail(1, `--out refuses an outside-repo destination that is not under a system temp root (${full}) — grounding output is scratch: use $TMPDIR//tmp, or a fresh gitignored in-repo path (temp roots checked: ${tempRoots.join(', ')})`);
211
226
  }
@@ -243,7 +258,8 @@ export const assertScratchDestination = (outPath, cwd) => {
243
258
  const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
244
259
 
245
260
  Usage:
246
- node grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--reserve-bytes <n>] [--out <path>]
261
+ node grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--extra <text|@file>]...
262
+ [--reserve-bytes <n>] [--out <path>]
247
263
 
248
264
  --constraints slice the root AGENTS.md "Hard Constraints" section verbatim
249
265
  (exactly one matching heading, else a loud STOP)
@@ -255,6 +271,14 @@ Usage:
255
271
  --plan <path> extract the plan's decision-bearing sections verbatim + whole:
256
272
  "## Approach" + "## Verification" (REQUIRED — STOP if missing),
257
273
  "## Decisions (locked)" when present; a duplicate heading is a STOP
274
+ --extra <text|@file> append orchestrator-supplied extra facts byte-verbatim AFTER the
275
+ mechanical sections (repeatable, argv order; the agy-review --facts
276
+ convention: literal text, or @path read whole through a race-free
277
+ descriptor). An @file must resolve inside the PROVEN git work tree
278
+ (rev-parse success; the git dir itself refused) or the system temp
279
+ surface — anything else refuses loudly, as does a missing, empty, or
280
+ non-regular file. The merge happens INSIDE the tool: no shell append
281
+ onto the emitted facts file
258
282
  --reserve-bytes <n> the artifact share agy-review will add around these facts — the output
259
283
  budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
260
284
  --out <path> write instead of stdout — system-temp scratch (rewritable), or a FRESH
@@ -274,11 +298,19 @@ const parseArgs = (argv) => {
274
298
  let plan = null;
275
299
  let out = null;
276
300
  let reserve = 0;
301
+ const extra = [];
277
302
  for (let i = 0; i < argv.length; i += 1) {
278
303
  const a = argv[i];
279
304
  if (a === '--constraints') constraints = true;
280
305
  else if (a === '--autonomy') autonomy = true;
281
- else if (a === '--plan') {
306
+ else if (a === '--extra') {
307
+ const val = argv[i + 1];
308
+ if (val == null || val === '' || val === '@' || val.startsWith('--')) {
309
+ throw fail(2, '--extra requires <text|@file> (repeatable)');
310
+ }
311
+ extra.push(val);
312
+ i += 1;
313
+ } else if (a === '--plan') {
282
314
  plan = argv[i + 1];
283
315
  if (!plan || plan.startsWith('--')) throw fail(2, '--plan requires a <path>');
284
316
  i += 1;
@@ -293,10 +325,10 @@ const parseArgs = (argv) => {
293
325
  i += 1;
294
326
  } else throw fail(2, `unknown argument: ${a}`);
295
327
  }
296
- if (!constraints && !autonomy && plan == null) {
297
- throw fail(2, 'nothing to assemble — pass --constraints, --autonomy, and/or --plan <path>');
328
+ if (!constraints && !autonomy && plan == null && extra.length === 0) {
329
+ throw fail(2, 'nothing to assemble — pass --constraints, --autonomy, --plan <path>, and/or --extra <text|@file>');
298
330
  }
299
- return { constraints, autonomy, plan, out, reserve };
331
+ return { constraints, autonomy, plan, out, reserve, extra };
300
332
  };
301
333
 
302
334
  const resolveBudget = (env, reserve) => {
@@ -316,7 +348,7 @@ export const main = (argv, ctx = {}) => {
316
348
  const env = ctx.env ?? process.env;
317
349
  try {
318
350
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
319
- const { constraints, autonomy, plan, out, reserve } = parseArgs(argv);
351
+ const { constraints, autonomy, plan, out, reserve, extra } = parseArgs(argv);
320
352
  const budget = resolveBudget(env, reserve);
321
353
 
322
354
  const readOrStop = (path, label) => {
@@ -348,8 +380,65 @@ export const main = (argv, ctx = {}) => {
348
380
  }
349
381
  const planText = plan != null ? readOrStop(plan, 'plan file') : null;
350
382
 
383
+ // --extra @file reads are CONFINED: the bridge tier auto-allows this tool with an args
384
+ // wildcard, so an unconfined @file would let an unattended run ship ANY readable file
385
+ // (~/.ssh, ~/.bashrc) into a prompt payload bound for a subscription CLI. The admitted read
386
+ // surface — computed ONCE per invocation — is the PROVEN git work tree (rev-parse success
387
+ // required; a cwd fallback would collapse the guard when cwd=$HOME) plus the system temp
388
+ // surface, MINUS the git dir(s) — repository internals never enter a facts payload. A non-@
389
+ // value is literal fact text (the agy-review --facts convention).
390
+ const extraReadSurface = () => {
391
+ const tempRoots = systemTempRoots();
392
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
393
+ if (top == null || top.status !== 0) return { tempRoots, topReal: null, gitDirsReal: [] };
394
+ const topReal = realpathSync(top.stdout.replace(/\r?\n$/, ''));
395
+ const gitDirsReal = ['--absolute-git-dir', '--git-common-dir'].map((flag) => {
396
+ const r = gitLine(['rev-parse', flag], cwd);
397
+ if (r == null || r.status !== 0) {
398
+ throw fail(1, `--extra cannot resolve the git dir (git rev-parse ${flag} failed) — refusing @file reads in an unmappable repo`);
399
+ }
400
+ return realpathSync(resolve(cwd, r.stdout.replace(/\r?\n$/, '')));
401
+ });
402
+ // The linked-worktree `.git` is a FILE inside the tree yet outside both answers above —
403
+ // repository metadata all the same.
404
+ gitDirsReal.push(join(topReal, '.git'));
405
+ return { tempRoots, topReal, gitDirsReal };
406
+ };
407
+ const surface = extra.some((v) => v.startsWith('@')) ? extraReadSurface() : null;
408
+ const resolveExtra = (value) => {
409
+ if (!value.startsWith('@')) return value;
410
+ const ref = value.slice(1);
411
+ const real = (() => {
412
+ try {
413
+ // Canonicalize the PARENT only — the leaf stays un-dereferenced so the no-follow open
414
+ // refuses a symlink leaf instead of silently reading its target.
415
+ const lexical = resolve(cwd, ref);
416
+ return join(realpathSync(dirname(lexical)), basename(lexical));
417
+ } catch (err) {
418
+ throw fail(1, `--extra file '${ref}' is unreadable (${(err && err.code) || err}) — STOP`);
419
+ }
420
+ })();
421
+ const within = (root) => real === root || real.startsWith(`${root}${sep}`);
422
+ const inTree = surface.topReal != null && within(surface.topReal);
423
+ if (!inTree && !surface.tempRoots.some(within)) {
424
+ throw fail(1, `--extra '@${ref}' resolves outside the work tree and the system temp surface (${real}) — refusing to read it into the facts payload`);
425
+ }
426
+ if (surface.gitDirsReal.some(within)) {
427
+ throw fail(1, `--extra '@${ref}' resolves inside the git dir (${real}) — repository internals never enter a facts payload`);
428
+ }
429
+ // Descriptor-bound read (the kit's ONE no-follow door): a FIFO cannot block the open, and a
430
+ // leaf swapped after the containment checks cannot change what the fd reads.
431
+ const r = readRegularFileNoFollow(real);
432
+ if (r.outcome === 'absent') throw fail(1, `--extra file '${ref}' is unreadable (ENOENT) — STOP`);
433
+ if (r.outcome === 'foreign') throw fail(1, `--extra file '${ref}' is not a regular file (${r.className}) — refusing; STOP`);
434
+ if (r.outcome !== 'ok') throw fail(1, `--extra file '${ref}' is unreadable (${r.code}) — STOP`);
435
+ if (r.content.trim() === '') throw fail(1, `--extra file '${ref}' is empty — nothing to append; STOP`);
436
+ return r.content; // byte-verbatim — no trailing-newline normalization
437
+ };
438
+ const extraTexts = extra.map(resolveExtra);
439
+
351
440
  const parts = [];
352
- const assembled = assembleGrounding({ constraintsText, autonomyText, planText, planLabel: plan ?? 'plan' });
441
+ const assembled = assembleGrounding({ constraintsText, autonomyText, planText, planLabel: plan ?? 'plan', extraTexts });
353
442
  if (assembled) parts.push(assembled);
354
443
  const payload = parts.join('\n');
355
444
  const { text, trimmedBytes } = trimToBudget(payload, budget);
@@ -36,6 +36,9 @@ export const stop = (message, fields = {}) =>
36
36
  // never a silent un-track. `/docs/plans/` + both `.claude/settings*.json` are listed because a pure
37
37
  // hidden deploy has no tracked `.gitignore`; the classifier drops any candidate a tracked `.gitignore`
38
38
  // already covers, so in a repo that DOES track those ignores they are never re-written.
39
+ // The enumeration must cover EVERY file the deploy copies into `scripts/` (bootstrap step 8 copies
40
+ // `references/scripts/*.mjs` + `*.test.mjs` wholesale): a name missing here is a file a hidden
41
+ // deployment leaves visible in `git status` — the exact leak this registry exists to prevent.
39
42
  export const KIT_OWN_PATHS = [
40
43
  '/AGENTS.md',
41
44
  '/CLAUDE.md',
@@ -49,11 +52,18 @@ export const KIT_OWN_PATHS = [
49
52
  '/scripts/archive-issues.mjs',
50
53
  '/scripts/archive-issues.test.mjs',
51
54
  '/scripts/archiver-structure.test.mjs',
55
+ '/scripts/check-docs-size-cli.test.mjs',
56
+ '/scripts/check-docs-size-ensure.test.mjs',
52
57
  '/scripts/check-docs-size.mjs',
53
58
  '/scripts/check-docs-size.test.mjs',
59
+ '/scripts/install-git-hooks-repo-exec.test.mjs',
54
60
  '/scripts/install-git-hooks.mjs',
61
+ '/scripts/install-git-hooks.test.mjs',
55
62
  '/scripts/markdown-blocks.mjs',
56
63
  '/scripts/markdown-blocks.test.mjs',
64
+ '/scripts/migrate-gates-branches.test.mjs',
65
+ '/scripts/migrate-gates.mjs',
66
+ '/scripts/migrate-gates.test.mjs',
57
67
  '/docs/plans/',
58
68
  '/.claude/settings.local.json',
59
69
  '/.claude/settings.json',
@@ -38,6 +38,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
38
38
  [
39
39
  'seeded',
40
40
  'note-refreshed',
41
+ 'regenerated',
41
42
  'already-current',
42
43
  'customized-preserved',
43
44
  'malformed-preserved',