@sabaiway/agent-workflow-kit 1.44.0 → 1.45.1

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 (52) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -3
  3. package/SKILL.md +9 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +87 -16
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +4 -2
  8. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  9. package/bridges/codex-cli-bridge/SKILL.md +7 -7
  10. package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -3
  11. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +4 -1
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +93 -22
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
  14. package/bridges/codex-cli-bridge/capability.json +4 -3
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +5 -4
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +5 -4
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/bootstrap.md +1 -1
  20. package/references/modes/bridge-settings.md +1 -1
  21. package/references/modes/grounding.md +8 -7
  22. package/references/modes/recommendations.md +14 -0
  23. package/references/modes/review-state.md +1 -1
  24. package/references/modes/sandbox-masks.md +15 -0
  25. package/references/modes/upgrade.md +10 -7
  26. package/references/modes/velocity.md +13 -2
  27. package/references/shared/composition-handoff.md +21 -16
  28. package/references/shared/report-footer.md +5 -1
  29. package/references/templates/AGENTS.md +2 -1
  30. package/references/templates/autonomy.json +3 -0
  31. package/tools/autonomy-config.mjs +13 -3
  32. package/tools/commands.mjs +15 -1
  33. package/tools/delegation.mjs +9 -5
  34. package/tools/detect-backends.mjs +2 -2
  35. package/tools/doc-parity.mjs +8 -0
  36. package/tools/engine-source.mjs +6 -0
  37. package/tools/family-registry.mjs +21 -7
  38. package/tools/fold-completeness-run.mjs +5 -0
  39. package/tools/grounding.mjs +139 -22
  40. package/tools/inject-methodology.mjs +117 -28
  41. package/tools/manifest/schema.md +20 -0
  42. package/tools/manifest/validate.mjs +26 -0
  43. package/tools/procedures.mjs +61 -8
  44. package/tools/recipes.mjs +94 -15
  45. package/tools/recommendations.mjs +469 -0
  46. package/tools/review-ledger-write.mjs +14 -0
  47. package/tools/review-ledger.mjs +3 -2
  48. package/tools/review-state.mjs +101 -24
  49. package/tools/run-gates.mjs +3 -0
  50. package/tools/sandbox-masks.mjs +370 -0
  51. package/tools/set-recipe.mjs +13 -1
  52. package/tools/velocity-profile.mjs +228 -22
@@ -19,14 +19,15 @@
19
19
  // in-band marker + stderr report (never a silent cut).
20
20
  //
21
21
  // Catalog honesty: this is a WRITER — `--out <path>` writes a file. Invariant: `--out` accepts
22
- // only gitignored / out-of-repo scratch destinations and REFUSES a tracked path AND an in-repo
22
+ // only system-temp destinations or a FRESH gitignored in-repo path and REFUSES a tracked/in-repo
23
23
  // not-ignored path (a new untracked file would itself move the review fingerprint the facts are
24
24
  // about to ground). stdout is the default. It never commits and never runs a subscription CLI.
25
25
  //
26
26
  // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
27
27
 
28
28
  import { readFileSync, writeFileSync, lstatSync, realpathSync } from 'node:fs';
29
- import { resolve, join, relative, isAbsolute, dirname, basename } from 'node:path';
29
+ import { resolve, join, relative, isAbsolute, dirname, basename, sep } from 'node:path';
30
+ import { tmpdir } from 'node:os';
30
31
  import { pathToFileURL } from 'node:url';
31
32
  import { spawnSync } from 'node:child_process';
32
33
  import { fail } from './orchestration-config.mjs';
@@ -35,6 +36,9 @@ import { fail } from './orchestration-config.mjs';
35
36
  // computeTelemetry give the counts; the segment filter gives the scope.
36
37
  import { ORIGINS, computeTelemetry, filterSegmentRecords, readLedger, resolveLedgerPath, resolveBase } from './review-ledger.mjs';
37
38
  import { plansInFlight } from './review-state.mjs';
39
+ // (f) --autonomy (AD-044 Plan 3): the effective per-project autonomy policy for the facts payload.
40
+ // READ core only — never autonomy-write.mjs (the import-split invariant).
41
+ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
38
42
 
39
43
  const PLAN_EXECUTION = 'plan-execution';
40
44
 
@@ -82,11 +86,15 @@ export const sliceSection = (text, heading, { optional = false, label = 'documen
82
86
 
83
87
  // ── assembly ───────────────────────────────────────────────────────────────────────
84
88
 
85
- export const assembleGrounding = ({ constraintsText = null, planText = null, planLabel = 'plan' } = {}) => {
89
+ export const assembleGrounding = ({ constraintsText = null, autonomyText = null, planText = null, planLabel = 'plan' } = {}) => {
86
90
  const parts = [];
87
91
  if (constraintsText != null) {
88
92
  parts.push(sliceSection(constraintsText, CONSTRAINTS_HEADING, { label: 'AGENTS.md' }));
89
93
  }
94
+ // The autonomy block is COMPUTED (resolveAutonomy over the policy file), not sliced — it rides
95
+ // between the constraints and the plan sections: the natural read order (what always binds →
96
+ // how autonomous this session is → what this plan decides).
97
+ if (autonomyText != null) parts.push(autonomyText);
90
98
  if (planText != null) {
91
99
  for (const { heading, optional } of PLAN_SECTIONS) {
92
100
  const section = sliceSection(planText, heading, { optional, label: planLabel });
@@ -178,6 +186,38 @@ export const resolveLedgerSummary = ({ cwd, env }) => {
178
186
  return renderLedgerSummary(records, { loop, base });
179
187
  };
180
188
 
189
+ // ── (f) --autonomy: the effective autonomy policy as a computed facts block (AD-044 Plan 3) ─────
190
+ // The ONE shared resolver (resolveAutonomy) renders the FULL effective policy — every red-line and
191
+ // every activity resolved, no key undefined — with a stated source line. The policy file resolves
192
+ // from the GIT TOP of cwd (the --ledger-summary gitTop precedent): a cwd-relative read from a
193
+ // subdir would silently emit computed defaults for a policy-carrying repo, the exact silent failure
194
+ // grounding exists to kill. Malformed/unreadable policy fails CLOSED (loadAutonomy throws exit 1);
195
+ // an ABSENT file is NOT an error — the computed defaults ARE the effective policy (deliberately
196
+ // diverging from `velocity-profile --autonomy`'s refuse-on-absent: that surface WRITES settings and
197
+ // demands a declared policy; this one emits informational facts). Enforcement honesty: these facts
198
+ // INFORM a review; enforcement stays the OS sandbox + the orchestrator process (AD-044 D1).
199
+
200
+ export const renderAutonomyFacts = (config, source) => {
201
+ const resolved = resolveAutonomy(config);
202
+ // The STRUCTURAL seed (_README only) reads as computed defaults; an EXPLICIT declared-defaults
203
+ // policy reads as a declared policy (the shared predicate — same contract on every surface).
204
+ const defaultsEquivalent = source !== 'none' && isSparseSeedConfig(config);
205
+ const head =
206
+ source === 'none'
207
+ ? `## Autonomy policy — ${AUTONOMY_REL} absent; the computed defaults ARE the effective policy`
208
+ : defaultsEquivalent
209
+ ? `## Autonomy policy — ${AUTONOMY_REL} present but defaults-equivalent (the sparse seed); the computed defaults ARE the effective policy`
210
+ : `## Autonomy policy — ${AUTONOMY_REL}`;
211
+ const redlines = Object.entries(resolved.redlines).map(([k, v]) => `${k}:${v}`).join(' ');
212
+ const activities = Object.entries(resolved.activities).map(([a, v]) => `${a}:${v.autonomy}`).join(' ');
213
+ return `${head}\n\nred-lines — ${redlines}\nactivities — ${activities}\n`;
214
+ };
215
+
216
+ const resolveAutonomyFacts = ({ cwd }) => {
217
+ const { config, source } = loadAutonomy(gitTop(cwd));
218
+ return renderAutonomyFacts(config, source);
219
+ };
220
+
181
221
  // ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
182
222
 
183
223
  const gitLine = (args, cwd) => {
@@ -185,24 +225,35 @@ const gitLine = (args, cwd) => {
185
225
  return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
186
226
  };
187
227
 
188
- // STOP unless `outPath` is a safe scratch destination: outside any git work tree, or gitignored
189
- // inside one. A TRACKED path and an in-repo NOT-ignored path are both refused (a facts file must
190
- // never enter the tree it grounds it would move the fingerprint and could be committed). The
191
- // check runs on the REAL destination, never the lexical path (codex R1 finding): a symlink leaf is
192
- // refused outright, and the parent directory is realpath-resolved first, so a gitignored or
193
- // out-of-repo symlink can never route the write onto a tracked/in-repo file. Returns the resolved
194
- // real path the caller must write to.
228
+ // STOP unless `outPath` is a safe scratch destination the CLOSED surface (this writer is
229
+ // bridge-tier auto-allowable): (a) under a SYSTEM TEMP root outside the repo ($TMPDIR /
230
+ // os.tmpdir() / /tmprewritable), or (b) a FRESH gitignored in-repo path (create-only; an
231
+ // EXISTING in-repo file, even gitignored, is a project file the .env clobber class). Refused:
232
+ // tracked paths, in-repo not-ignored paths (a new untracked file would move the fingerprint),
233
+ // every other outside-repo destination, symlink and existing non-regular leaves, and any
234
+ // unverifiable lstat. The check runs on the REAL destination, never the lexical path (codex R1):
235
+ // the parent directory is realpath-resolved first, so a symlink can never route the write onto a
236
+ // tracked/in-repo file. Returns { path, kind: 'temp' | 'repo-fresh' } — a repo-fresh caller MUST
237
+ // write with the exclusive flag ('wx'), sealing the guard→write race (codex R11).
195
238
  export const assertScratchDestination = (outPath, cwd) => {
196
239
  const lexical = isAbsolute(outPath) ? outPath : resolve(cwd, outPath);
197
240
  let leaf = null;
198
241
  try {
199
242
  leaf = lstatSync(lexical);
200
- } catch {
243
+ } catch (err) {
244
+ // ONLY an absent leaf is a fresh file; any other lstat failure fails CLOSED (codex R9 — this
245
+ // writer is bridge-tier auto-allowable, so an unverifiable leaf must never be written).
246
+ if (err?.code !== 'ENOENT') {
247
+ throw fail(1, `--out cannot inspect the destination (${outPath}: ${err?.code ?? err?.message ?? err}) — refusing to write an unverifiable leaf`);
248
+ }
201
249
  leaf = null; // absent → a fresh file; the parent still gets realpath-checked below
202
250
  }
203
251
  if (leaf?.isSymbolicLink()) {
204
252
  throw fail(1, `--out refuses a symlink destination (${outPath}) — the write would follow it onto another file; name the real scratch path`);
205
253
  }
254
+ if (leaf != null && !leaf.isFile()) {
255
+ 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`);
256
+ }
206
257
  let realParent;
207
258
  try {
208
259
  realParent = realpathSync(dirname(lexical));
@@ -210,11 +261,31 @@ export const assertScratchDestination = (outPath, cwd) => {
210
261
  throw fail(1, `--out parent directory does not exist (${dirname(lexical)}) — create the scratch dir first`);
211
262
  }
212
263
  const full = join(realParent, basename(lexical));
264
+ // An out-of-repo (or no-repo) destination is scratch ONLY under a system temp root (codex R8
265
+ // blocker): the bridge tier auto-allows this tool with an args wildcard, so "anything outside
266
+ // the repo is scratch" would let an unattended run overwrite e.g. ~/.bashrc promptless.
267
+ // $TMPDIR / os.tmpdir() / /tmp are the scratch surface; everything else refuses loudly.
268
+ const assertTempScratch = () => {
269
+ const tempRoots = [...new Set([tmpdir(), process.env.TMPDIR, '/tmp'].filter(Boolean).map((p) => {
270
+ try {
271
+ return realpathSync(p);
272
+ } catch {
273
+ return null;
274
+ }
275
+ }).filter(Boolean))];
276
+ if (!tempRoots.some((t) => full === t || full.startsWith(`${t}${sep}`))) {
277
+ 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(', ')})`);
278
+ }
279
+ return { path: full, kind: 'temp' };
280
+ };
213
281
  const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
214
- if (top == null || top.status !== 0) return full; // no git tree → plain scratch, fine
282
+ if (top == null || top.status !== 0) return { path: assertTempScratch().path, kind: 'temp' }; // no git tree → temp-only scratch
215
283
  const root = top.stdout.replace(/\r?\n$/, '');
216
284
  const rel = relative(root, full);
217
- if (rel.startsWith('..') || isAbsolute(rel)) return full; // really outside the work tree → scratch
285
+ // Segment-safe outside test (the Issue-004 class, codex R5): an in-repo file literally named
286
+ // `..facts` has rel `..facts` — `startsWith('..')` would misread it as outside and BYPASS the
287
+ // tracked/ignored refusals below. Only `..` exactly or a `..${sep}`-prefixed rel is outside.
288
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { path: assertTempScratch().path, kind: 'temp' };
218
289
  const tracked = gitLine(['ls-files', '--error-unmatch', '--', rel], root);
219
290
  if (tracked != null && tracked.status === 0) {
220
291
  throw fail(1, `--out refuses a TRACKED path (${rel}) — grounding output is scratch; write it to a gitignored path or outside the repo`);
@@ -223,7 +294,15 @@ export const assertScratchDestination = (outPath, cwd) => {
223
294
  if (ignored == null || ignored.status !== 0) {
224
295
  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`);
225
296
  }
226
- return full;
297
+ // An in-repo destination must be a FRESH file (codex R10): even a gitignored existing file is a
298
+ // project file (.env is the canonical victim) — an auto-allowable writer must never clobber one.
299
+ // The rewritable scratch surface is the system temp; in-repo gitignored writes are create-only —
300
+ // the CALLER must open with the exclusive flag ('wx'): the kind seals the pre-check against the
301
+ // guard→write race (codex R11).
302
+ if (leaf != null) {
303
+ 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`);
304
+ }
305
+ return { path: full, kind: 'repo-fresh' };
227
306
  };
228
307
 
229
308
  // ── CLI ────────────────────────────────────────────────────────────────────────────
@@ -231,10 +310,15 @@ export const assertScratchDestination = (outPath, cwd) => {
231
310
  const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
232
311
 
233
312
  Usage:
234
- node grounding.mjs [--constraints] [--plan <path>] [--ledger-summary] [--reserve-bytes <n>] [--out <path>]
313
+ node grounding.mjs [--constraints] [--autonomy] [--plan <path>] [--ledger-summary] [--reserve-bytes <n>] [--out <path>]
235
314
 
236
315
  --constraints slice the root AGENTS.md "Hard Constraints" section verbatim
237
316
  (exactly one matching heading, else a loud STOP)
317
+ --autonomy append the COMPUTED effective autonomy policy (resolveAutonomy over the
318
+ git-top docs/ai/autonomy.json): every red-line + per-activity level, with a
319
+ stated source line; absent file → the computed defaults ARE the policy
320
+ (exit 0); malformed/unreadable → fail-closed STOP (exit 1); informational —
321
+ enforcement stays the sandbox + the orchestrator
238
322
  --plan <path> extract the plan's decision-bearing sections verbatim + whole:
239
323
  "## Approach" + "## Verification" (REQUIRED — STOP if missing),
240
324
  "## Decisions (locked)" when present; a duplicate heading is a STOP
@@ -244,7 +328,8 @@ Usage:
244
328
  loud STOP unless exactly one plan is in flight
245
329
  --reserve-bytes <n> the artifact share agy-review will add around these facts — the output
246
330
  budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
247
- --out <path> write instead of stdout — gitignored / out-of-repo scratch ONLY
331
+ --out <path> write instead of stdout — system-temp scratch (rewritable), or a FRESH
332
+ gitignored in-repo path (create-only); everything else refuses
248
333
  (a tracked or in-repo not-ignored path is refused)
249
334
 
250
335
  Feed the output to the review wrapper: agy-review code --facts @<out>. AGY_MAX_PROMPT_BYTES
@@ -256,6 +341,7 @@ Exit codes: 0 success; 2 usage; 1 STOP (missing/duplicate section, unreadable fi
256
341
 
257
342
  const parseArgs = (argv) => {
258
343
  let constraints = false;
344
+ let autonomy = false;
259
345
  let plan = null;
260
346
  let out = null;
261
347
  let reserve = 0;
@@ -263,6 +349,7 @@ const parseArgs = (argv) => {
263
349
  for (let i = 0; i < argv.length; i += 1) {
264
350
  const a = argv[i];
265
351
  if (a === '--constraints') constraints = true;
352
+ else if (a === '--autonomy') autonomy = true;
266
353
  else if (a === '--ledger-summary') ledgerSummary = true;
267
354
  else if (a === '--plan') {
268
355
  plan = argv[i + 1];
@@ -279,8 +366,10 @@ const parseArgs = (argv) => {
279
366
  i += 1;
280
367
  } else throw fail(2, `unknown argument: ${a}`);
281
368
  }
282
- if (!constraints && plan == null && !ledgerSummary) throw fail(2, 'nothing to assemble — pass --constraints, --plan <path>, and/or --ledger-summary');
283
- return { constraints, plan, out, reserve, ledgerSummary };
369
+ if (!constraints && !autonomy && plan == null && !ledgerSummary) {
370
+ throw fail(2, 'nothing to assemble — pass --constraints, --autonomy, --plan <path>, and/or --ledger-summary');
371
+ }
372
+ return { constraints, autonomy, plan, out, reserve, ledgerSummary };
284
373
  };
285
374
 
286
375
  const resolveBudget = (env, reserve) => {
@@ -300,7 +389,7 @@ export const main = (argv, ctx = {}) => {
300
389
  const env = ctx.env ?? process.env;
301
390
  try {
302
391
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
303
- const { constraints, plan, out, reserve, ledgerSummary } = parseArgs(argv);
392
+ const { constraints, autonomy, plan, out, reserve, ledgerSummary } = parseArgs(argv);
304
393
  const budget = resolveBudget(env, reserve);
305
394
 
306
395
  const readOrStop = (path, label) => {
@@ -311,10 +400,29 @@ export const main = (argv, ctx = {}) => {
311
400
  }
312
401
  };
313
402
  const constraintsText = constraints ? readOrStop('AGENTS.md', 'root AGENTS.md') : null;
403
+ const autonomyText = autonomy ? resolveAutonomyFacts({ cwd }) : null;
404
+ // --plan is CONFINED to the git work tree (codex R4): the bridge tier auto-allows this tool
405
+ // with an args wildcard, so an unattended invocation could otherwise point --plan at ANY
406
+ // readable file and ship it into the facts payload. Plans are in-repo by contract
407
+ // (docs/plans); an outside-tree path is a loud refusal, never a silent read.
408
+ if (plan != null) {
409
+ const top = gitTop(cwd);
410
+ const planReal = (() => {
411
+ try {
412
+ return realpathSync(resolve(cwd, plan));
413
+ } catch (err) {
414
+ throw fail(1, `plan file '${plan}' is unreadable (${(err && err.code) || err}) — STOP`);
415
+ }
416
+ })();
417
+ const topReal = realpathSync(top);
418
+ if (planReal !== topReal && !planReal.startsWith(`${topReal}${sep}`)) {
419
+ throw fail(1, `--plan '${plan}' resolves outside the git work tree (${planReal}) — plans live in-repo (docs/plans); refusing to read outside-tree content into the facts payload`);
420
+ }
421
+ }
314
422
  const planText = plan != null ? readOrStop(plan, 'plan file') : null;
315
423
 
316
424
  const parts = [];
317
- const assembled = assembleGrounding({ constraintsText, planText, planLabel: plan ?? 'plan' });
425
+ const assembled = assembleGrounding({ constraintsText, autonomyText, planText, planLabel: plan ?? 'plan' });
318
426
  if (assembled) parts.push(assembled);
319
427
  if (ledgerSummary) {
320
428
  const summary = resolveLedgerSummary({ cwd, env });
@@ -327,8 +435,17 @@ export const main = (argv, ctx = {}) => {
327
435
  : '';
328
436
 
329
437
  if (out != null) {
330
- const realOut = assertScratchDestination(out, cwd);
331
- writeFileSync(realOut, text);
438
+ const dest = assertScratchDestination(out, cwd);
439
+ // repo-fresh writes are EXCLUSIVE ('wx'): the create-only pre-check would otherwise race a
440
+ // file created between the guard and this write (codex R11); temp scratch stays rewritable.
441
+ // ctx.writeFile is a TEST seam (the EEXIST race arm is not constructible without it).
442
+ const writeFile = ctx.writeFile ?? writeFileSync;
443
+ try {
444
+ writeFile(dest.path, text, dest.kind === 'repo-fresh' ? { flag: 'wx' } : {});
445
+ } catch (err) {
446
+ if (err?.code === 'EEXIST') throw fail(1, `--out lost the create-only race: ${out} appeared between the guard and the write — an in-repo destination is never overwritten; re-run with a fresh path or temp scratch`);
447
+ throw err;
448
+ }
332
449
  return { code: 0, stdout: `[grounding] wrote ${Buffer.byteLength(text, 'utf8')} bytes to ${out} — pass it as: agy-review code --facts @${out}`, stderr };
333
450
  }
334
451
  return { code: 0, stdout: text, stderr };
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // Marker-slot injection + reconciliation — the composition root's only mutation of a deployed
3
- // AGENTS.md. A deployed entry point carries TWO reconciled marker slots, each filled LIVE from the
3
+ // AGENTS.md. A deployed entry point carries THREE reconciled marker slots, each filled LIVE from the
4
4
  // installed agent-workflow-engine (the family's one source of truth, no bundled mirror):
5
5
  // 1. workflow:methodology — the plan→execute→review pointer (references/methodology-slot.md).
6
6
  // 2. workflow:orchestration — the recipe-vocabulary pointer (references/orchestration-slot.md).
7
+ // 3. workflow:autonomy — the autonomy-policy read contract (references/autonomy-slot.md).
7
8
  //
8
- // Both share ONE generic marker engine (a slot DESCRIPTOR parameterizes the markers / anchor /
9
+ // All share ONE generic marker engine (a slot DESCRIPTOR parameterizes the markers / anchor /
9
10
  // empty-slot / leading-blank). The methodology exports (findSlot / injectMethodology / ensureSlot /
10
11
  // reconcileSlot / slotNeedsFill / extractSlot) delegate to it byte-for-byte, so the methodology
11
- // contract is unchanged; the orchestration slot is the SAME engine with a different descriptor.
12
+ // contract is unchanged; the later slots are the SAME engine with different descriptors.
12
13
  //
13
14
  // Contract per slot, strictly enforced:
14
15
  // exactly one ordered start→end pair → replace only the bytes between them;
@@ -31,6 +32,8 @@ export const START_MARKER = '<!-- workflow:methodology:start -->';
31
32
  export const END_MARKER = '<!-- workflow:methodology:end -->';
32
33
  export const ORCH_START_MARKER = '<!-- workflow:orchestration:start -->';
33
34
  export const ORCH_END_MARKER = '<!-- workflow:orchestration:end -->';
35
+ export const AUTONOMY_START_MARKER = '<!-- workflow:autonomy:start -->';
36
+ export const AUTONOMY_END_MARKER = '<!-- workflow:autonomy:end -->';
34
37
  export const AGENTS_MD_CAP = 100; // the deployed AGENTS.md line budget (its own footer rule)
35
38
 
36
39
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -59,11 +62,16 @@ export const METHODOLOGY_ANCHOR = /^.*Read it before any code change\..*$/m;
59
62
  // The orchestration pair lands right BELOW the methodology end marker — so its anchor is that line.
60
63
  // A well-formed entry point carries exactly one methodology end marker → exactly one anchor.
61
64
  export const ORCH_ANCHOR = new RegExp(`^.*${escapeRegExp(END_MARKER)}.*$`, 'm');
65
+ // The autonomy pair chains right below the orchestration pair — its anchor is the orchestration end
66
+ // marker line. The anchor can legitimately be ABSENT (a legacy file whose orchestration pair was
67
+ // itself skipped) — the chained CLI classifies that as a soft skip, never a hard STOP (D4).
68
+ export const AUTONOMY_ANCHOR = new RegExp(`^.*${escapeRegExp(ORCH_END_MARKER)}.*$`, 'm');
62
69
 
63
70
  // The canonical empty slots (an ordered start→end pair, nothing between) — what a fresh template
64
71
  // ships and what ensureSlot inserts. LF form; ensureSlot rewrites the newline to match the document.
65
72
  export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
66
73
  export const ORCH_EMPTY_SLOT = `${ORCH_START_MARKER}\n${ORCH_END_MARKER}`;
74
+ export const AUTONOMY_EMPTY_SLOT = `${AUTONOMY_START_MARKER}\n${AUTONOMY_END_MARKER}`;
67
75
 
68
76
  // ── known-prior canonical fragments (drift-guarded, APPEND-ONLY per slot) ───────
69
77
  // The EXACT content a PREVIOUS release's engine fragment shipped (what a filled slot would carry today).
@@ -113,6 +121,21 @@ export const ORCHESTRATION_DESCRIPTOR = {
113
121
  upgradeAdvice:
114
122
  'the orchestration-recipes pointer predates the read-at-start clause — add "read `docs/ai/orchestration.json` at session start; set it with /agent-workflow-kit set-recipe", or set your preference now with /agent-workflow-kit set-recipe.',
115
123
  };
124
+ // The autonomy-policy slot (AD-044 Plan 3) — first canon, so no known priors yet: any future
125
+ // fragment change must append the outgoing content to a KNOWN_PRIOR_AUTONOMY_SLOT store first.
126
+ export const AUTONOMY_DESCRIPTOR = {
127
+ startMarker: AUTONOMY_START_MARKER,
128
+ endMarker: AUTONOMY_END_MARKER,
129
+ anchor: AUTONOMY_ANCHOR,
130
+ emptySlot: AUTONOMY_EMPTY_SLOT,
131
+ leadingBlank: false,
132
+ markerName: 'autonomy',
133
+ anchorLabel: 'autonomy anchor (the orchestration end-marker line)',
134
+ knownPriorCanonicals: [],
135
+ upgradeSignature: 'docs/ai/autonomy.json',
136
+ upgradeAdvice:
137
+ 'the autonomy pointer does not name the per-project policy file — add "read `docs/ai/autonomy.json` at session start; set it with /agent-workflow-kit set-autonomy", or refresh the slot to the current canon.',
138
+ };
116
139
 
117
140
  // ── generic marker-slot engine (descriptor-parameterized) ──────────────────────
118
141
 
@@ -283,15 +306,22 @@ export const markerSlotUpgradeHint = (text, descriptor) => {
283
306
  // stable "(cap N)" substring both cap messages carry, so the dual-slot reconcile can skip the
284
307
  // orchestration pointer (loud) while keeping the methodology fill, instead of aborting both.
285
308
  const isCapRefusal = (errorMessage) => typeof errorMessage === 'string' && errorMessage.includes('(cap ');
309
+ // The autonomy lane distinguishes the TWO cap messages "(cap " conflates (D4 cap-lane honesty):
310
+ // only a FILL-overflow from an in-cap input (injectIntoSlot's "injection would push …") is the soft
311
+ // skip — the pointer was genuinely withheld. An ALREADY-over-cap custom file (reconcileMarkerSlot's
312
+ // "AGENTS.md is N lines … trim the file") keeps its distinct over-cap report as a hard refusal,
313
+ // never a mislabeled "skipped". Exported so the split is pinned against the real messages.
314
+ export const isFillCapRefusal = (errorMessage) =>
315
+ isCapRefusal(errorMessage) && errorMessage.includes('injection would push');
286
316
 
287
317
  const main = async (argv) => {
288
318
  const { readFile, writeFile, rename, rm } = await import('node:fs/promises');
289
319
  const { dirname, basename, join, resolve } = await import('node:path');
290
320
  const { homedir } = await import('node:os');
291
- const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL } = await import('./engine-source.mjs');
321
+ const { resolveEngineDir, readEngineFragment, detectEngine, ENGINE_FRAGMENT_REL, ORCHESTRATION_FRAGMENT_REL, AUTONOMY_FRAGMENT_REL } = await import('./engine-source.mjs');
292
322
 
293
323
  // `reconcile <AGENTS.md> [fragment.md]` = ensure-slot + inject-if-empty + cap (bootstrap/upgrade) for
294
- // BOTH slots; `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-(methodology)-slot mode.
324
+ // ALL THREE slots; `<AGENTS.md> [fragment.md]` = the legacy inject-into-existing-(methodology)-slot mode.
295
325
  const mode = argv[0] === 'reconcile' ? 'reconcile' : 'inject';
296
326
  const rest = mode === 'reconcile' ? argv.slice(1) : argv;
297
327
  const agentsPath = rest[0];
@@ -323,36 +353,32 @@ const main = async (argv) => {
323
353
  }
324
354
  };
325
355
 
326
- // The orchestration fragment is the SECOND (less-critical) pointer. Source it lazily, but DISTINGUISH
327
- // two failures keyed off the engine's own detection (not message text): an engine that is VALID but
328
- // simply TOO OLD to ship `orchestration-slot.md` (e.g. <1.2.0) is a SOFT skip — the methodology fill
329
- // is kept and the recipes pointer is reported as withheld (parallel to the cap-skip); only a FULLY
330
- // absent/invalid engine (it cannot supply EITHER pointer) is a hard STOP. Returns
331
- // { fragment } on success, { skip } for the soft case, or process.exit(1) for the hard STOP.
332
- const sourceOrchestrationFragment = async () => {
356
+ // The orchestration + autonomy fragments are the CHAINED (less-critical) pointers. Source each
357
+ // lazily, but DISTINGUISH two failures keyed off the engine's own detection (not message text): an
358
+ // engine that is VALID but simply TOO OLD to ship the requested fragment is a SOFT skip — the prior
359
+ // fills are kept and the pointer is reported as withheld (parallel to the cap-skip); only a FULLY
360
+ // absent/invalid engine (it cannot supply ANY pointer) is a hard STOP. A read error on a PRESENT
361
+ // fragment is a real corruption STOP, NEVER a "too old" skip (don't mislabel a current engine with
362
+ // an unreadable fragment). Returns { fragment } on success, { skip } for the soft case, or
363
+ // process.exit(1) for the hard STOP.
364
+ const sourceChainedFragment = async (rel) => {
333
365
  const { dir, source } = resolveEngineDir({ env: process.env, home: homedir() });
334
366
  const stop = (err) => {
335
367
  console.error(`[inject-methodology] reconcile STOP — ${err.message}`);
336
368
  process.exit(1);
337
369
  };
338
- // The orchestration fragment FILE is present + the engine is valid → read it. A read error on a
339
- // PRESENT fragment is a real corruption STOP, NEVER a "too old" skip (don't mislabel a current
340
- // engine with an unreadable fragment).
341
- if (detectEngine(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }).ok) {
370
+ if (detectEngine(dir, { source, rel }).ok) {
342
371
  try {
343
- return { fragment: readEngineFragment(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }) };
372
+ return { fragment: readEngineFragment(dir, { source, rel }) };
344
373
  } catch (err) {
345
374
  stop(err);
346
375
  }
347
376
  }
348
- // The orchestration fragment is NOT a usable file. SOFT-skip ONLY when the engine is otherwise
349
- // valid (it can still supply the methodology fragment) — a genuine too-old (<1.2.0) engine. A
350
- // fully absent/invalid engine cannot supply EITHER fragment → hard STOP with the install command.
351
377
  if (detectEngine(dir, { source }).ok) {
352
378
  return { skip: 'the installed engine is too old to supply it — refresh with `npx @sabaiway/agent-workflow-engine@latest init`' };
353
379
  }
354
380
  try {
355
- readEngineFragment(dir, { source, rel: ORCHESTRATION_FRAGMENT_REL }); // throws the canonical install-me error
381
+ readEngineFragment(dir, { source, rel }); // throws the canonical install-me error
356
382
  } catch (err) {
357
383
  stop(err);
358
384
  }
@@ -398,7 +424,7 @@ const main = async (argv) => {
398
424
  for (const n of notes) console.log(`[inject-methodology] note: ${n}`);
399
425
  };
400
426
 
401
- // ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration reconcile ──
427
+ // ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration + autonomy reconciles ──
402
428
  if (explicitFragmentArg) {
403
429
  if (afterMeth === text) {
404
430
  console.log('[inject-methodology] methodology slot already present and filled — nothing to do (zero-diff).');
@@ -416,7 +442,7 @@ const main = async (argv) => {
416
442
  let orchSkipped = false;
417
443
 
418
444
  const orchSource = markerSlotNeedsFill(afterMeth, ORCHESTRATION_DESCRIPTOR)
419
- ? await sourceOrchestrationFragment()
445
+ ? await sourceChainedFragment(ORCHESTRATION_FRAGMENT_REL)
420
446
  : { fragment: '' };
421
447
  if (orchSource.skip) {
422
448
  // Engine too old to supply the recipes pointer → SOFT skip, parallel to the cap-skip: the
@@ -453,16 +479,79 @@ const main = async (argv) => {
453
479
  }
454
480
  }
455
481
 
456
- // ── One atomic write of the final (both-slot) text ──
482
+ // ── Slot 3: autonomy, chained on the orchestration-reconciled text (same combined ≤100 guard).
483
+ // D4 invariant: every failure lane SPECIFIC to this slot is a LOUD soft skip that preserves
484
+ // the prior fills and exits 0 — (i) a too-old engine (no autonomy fragment), (ii) a
485
+ // fill-overflow cap refusal, (iii) an ABSENT anchor (the orchestration pair is absent or was
486
+ // itself skipped above — a 0-anchors ensure error must not discard the methodology fill).
487
+ // Malformed autonomy markers, an already-over-cap custom file, a present-but-unreadable
488
+ // fragment, and a fully absent/invalid engine stay hard STOPs. ──
489
+ let describeAut;
490
+ let autSkipped = false;
491
+ const autSlot = findMarkerSlot(finalText, AUTONOMY_DESCRIPTOR);
492
+ if (autSlot.state === 'malformed') {
493
+ // Malformed markers are a hard STOP on EVERY lane — validated before the soft-skip
494
+ // short-circuits so a duplicate/reversed autonomy pair can never ride out as a "skip"
495
+ // alongside a partial (methodology) write.
496
+ console.error(`[inject-methodology] reconcile refused (autonomy) — ${autSlot.reason}`);
497
+ process.exit(1);
498
+ }
499
+ if (orchSkipped) {
500
+ // The chain is CAUSAL, not merely positional: a pointer never lands in a run that withheld
501
+ // the pointer it chains below (cap or too-old) — otherwise a partially-shipped engine could
502
+ // fill autonomy under an unfilled recipes pointer, and a near-cap file could spend its last
503
+ // lines on the less-critical pointer. LOUD soft skip. This branch also subsumes the D4 (iii)
504
+ // anchor-absent lane: after a non-skipped orchestration reconcile the orchestration pair —
505
+ // the autonomy anchor — is present by construction, so orchSkipped is the ONLY route to a
506
+ // missing anchor (a separate anchor check would be dead code; coverage proved it).
507
+ autSkipped = true;
508
+ describeAut =
509
+ 'autonomy pointer skipped — chained below the orchestration pointer, which was itself skipped this run; re-run once it lands';
510
+ } else {
511
+ const autSource = markerSlotNeedsFill(finalText, AUTONOMY_DESCRIPTOR)
512
+ ? await sourceChainedFragment(AUTONOMY_FRAGMENT_REL)
513
+ : { fragment: '' };
514
+ if (autSource.skip) {
515
+ autSkipped = true;
516
+ describeAut = `autonomy pointer skipped — ${autSource.skip}`;
517
+ } else {
518
+ const autResult = reconcileMarkerSlot(finalText, AUTONOMY_DESCRIPTOR, autSource.fragment, { maxLines: AGENTS_MD_CAP });
519
+ if (autResult.status === 'error') {
520
+ if (isFillCapRefusal(autResult.error)) {
521
+ // SOFT skip — only the genuine fill-overflow withholds the pointer; an already-over-cap
522
+ // custom file keeps its distinct over-cap report via the hard-STOP branch below.
523
+ autSkipped = true;
524
+ describeAut = `autonomy pointer skipped — ${autResult.error}`;
525
+ } else {
526
+ console.error(`[inject-methodology] reconcile refused (autonomy) — ${autResult.error}`);
527
+ process.exit(1);
528
+ }
529
+ } else {
530
+ finalText = autResult.text;
531
+ describeAut = {
532
+ 'reconciled-inserted': 'inserted the autonomy pointer below it and filled it',
533
+ 'reconciled-filled': 'filled the empty autonomy pointer',
534
+ 'reconciled-refreshed': 'refreshed the autonomy pointer to the current canon',
535
+ 'present-filled': 'autonomy pointer already present',
536
+ }[autResult.status];
537
+ if (autResult.status === 'present-filled') {
538
+ const u = markerSlotUpgradeHint(finalText, AUTONOMY_DESCRIPTOR);
539
+ if (u) notes.push(u);
540
+ }
541
+ }
542
+ }
543
+ }
544
+
545
+ // ── One atomic write of the final (three-slot) text ──
457
546
  if (finalText === text) {
458
- // Byte-unchanged. Still report a cap-skip (it is not "nothing to do" — a pointer was withheld).
459
- if (orchSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
460
- else console.log('[inject-methodology] reconcile: both pointers already present and filled — nothing to do (zero-diff).');
547
+ // 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).');
461
550
  reportNotes();
462
551
  return;
463
552
  }
464
553
  await writeAtomic(finalText);
465
- console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
554
+ console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}; ${describeAut}.`);
466
555
  reportNotes();
467
556
  return;
468
557
  }
@@ -18,6 +18,7 @@ expansion), never via the shell. The validator is [`validate.mjs`](./validate.mj
18
18
  | `roles` | object | yes | keys ⊆ `provides`; see *Roles* |
19
19
  | `detect` | object | no | `installed` (skill on the machine) + `deployed` (substrate set up in cwd) |
20
20
  | `settings` | array | no | the bridge's **settings-file surface** (typed; see *Settings*). Unlike `contract`, a malformed entry **fails** `--strict`. |
21
+ | `networkHosts` | string[] | no | the backend CLI's **observed egress host families** (see *Network hosts*). A malformed list **fails** `--strict`. |
21
22
  | `install` / `uninstall` | object | no | `install.npm` is a package name, not a path |
22
23
  | `cost` / `quota` / `provenance` | misc | no | informational |
23
24
  | `available` | boolean | no | `false` = a declared-but-not-installed stub; skips fs/version checks |
@@ -85,6 +86,25 @@ Wrappers never parse JSON at run time: each carries its own shell registry/valid
85
86
  drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (help section keys,
86
87
  `aw_settings_known` registry, `AW_SETTINGS_APPLIED` subset, `aw_settings_valid` typed arms).
87
88
 
89
+ ## Network hosts (AD-044 Plan 4, consult-locked)
90
+
91
+ `networkHosts` declares the hosts the backend CLI is **observed** to contact (synthetic examples:
92
+ `*.api.backend.example`, `accounts.backend.example`; the real observed lists live in each bridge's
93
+ `capability.json`) — the **single documentation
94
+ source** for a hand-applied sandbox/network allowlist (session sandbox config, or a hand-pasted
95
+ `sandbox.network.allowedDomains` entry). Rules:
96
+
97
+ - Each entry is a bare dotted hostname or a `*.family` wildcard — never a scheme/path/port.
98
+ Entries must be unique. A malformed list **fails** `--strict` (the entries are pasted verbatim
99
+ into allowlist lines by the Recommendations advisor).
100
+ - **The kit never seeds these into settings** (bridge council 2026-07-11, both backends concur):
101
+ a network pre-allow widens egress for **every** sandboxed command, so running the wrappers
102
+ **outside** the sandbox (`sandbox.excludedCommands`, the `--bridge-tier` wiring) stays the
103
+ primary lane; the hosts list exists for the **hand-apply** fallback under harness-managed
104
+ sandboxes where settings-level exclusions are inert.
105
+ - Observed-minimal, honestly incomplete: a blocked host names itself at run time — extend the
106
+ hand-applied list by hand; the manifest list records what was actually observed.
107
+
88
108
  ## Path-field rules (Windows-safe, traversal-safe)
89
109
 
90
110
  - **No absolute paths**, **no `..` traversal**, **no unresolved placeholders** (`{{…}}` / `${…}`)
@@ -63,6 +63,10 @@ export const settingValueValid = (entry, value) => {
63
63
  const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
64
64
  const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
65
65
 
66
+ // A `networkHosts` entry: a bare dotted hostname, optionally a `*.` family wildcard — never a
67
+ // scheme, a path, a port, or whitespace (the entry is pasted verbatim into an allowlist line).
68
+ export const NETWORK_HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
69
+
66
70
  // Cross-platform "absolute-like": POSIX root (/x), Windows drive (C:\ or C:/), UNC (\\host),
67
71
  // or a leading backslash. node:path.isAbsolute() alone is platform-dependent, so a Windows path
68
72
  // would slip through unchecked on a POSIX CI runner — Windows-safety requires rejecting both.
@@ -313,6 +317,28 @@ export const validateManifest = (skillDir) => {
313
317
  }
314
318
  }
315
319
 
320
+ // `networkHosts` (AD-044 Plan 4, consult-locked): the backend CLI's OBSERVED egress host
321
+ // families — a DOCUMENTATION source for hand-applied sandbox/network allowlists. The kit never
322
+ // seeds these into settings (exclusion stays primary; a network pre-allow widens egress for
323
+ // EVERY sandboxed command), but a malformed list FAILS --strict like `settings`: the
324
+ // Recommendations advisor renders it verbatim, so a bad entry would corrupt a pasted line.
325
+ const networkHosts = manifest.networkHosts;
326
+ if (networkHosts != null) {
327
+ if (!Array.isArray(networkHosts) || networkHosts.length === 0) {
328
+ errors.push('`networkHosts` must be a non-empty array of host strings');
329
+ } else {
330
+ const seenHosts = new Set();
331
+ networkHosts.forEach((host, i) => {
332
+ if (typeof host !== 'string' || !NETWORK_HOST_RE.test(host)) {
333
+ errors.push(`\`networkHosts[${i}]\` must be a bare hostname or a *.family wildcard (${JSON.stringify(host)})`);
334
+ return;
335
+ }
336
+ if (seenHosts.has(host)) errors.push(`duplicate networkHosts entry "${host}" (\`networkHosts[${i}]\`)`);
337
+ else seenHosts.add(host);
338
+ });
339
+ }
340
+ }
341
+
316
342
  if (!isStub) {
317
343
  const auth = readAuthoritativeVersion(skillDir);
318
344
  if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);