@sabaiway/agent-workflow-kit 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +52 -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 +1 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -0
  11. package/bridges/codex-cli-bridge/bin/codex-review.sh +90 -19
  12. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
  13. package/bridges/codex-cli-bridge/capability.json +3 -2
  14. package/bridges/codex-cli-bridge/references/driving-codex.md +3 -2
  15. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  16. package/capability.json +1 -1
  17. package/package.json +1 -1
  18. package/references/modes/bootstrap.md +1 -1
  19. package/references/modes/grounding.md +8 -7
  20. package/references/modes/recommendations.md +14 -0
  21. package/references/modes/review-state.md +1 -1
  22. package/references/modes/sandbox-masks.md +15 -0
  23. package/references/modes/upgrade.md +10 -7
  24. package/references/modes/velocity.md +13 -2
  25. package/references/shared/composition-handoff.md +21 -16
  26. package/references/shared/report-footer.md +5 -1
  27. package/references/templates/AGENTS.md +2 -1
  28. package/references/templates/autonomy.json +3 -0
  29. package/tools/autonomy-config.mjs +13 -3
  30. package/tools/commands.mjs +15 -1
  31. package/tools/delegation.mjs +9 -5
  32. package/tools/detect-backends.mjs +2 -2
  33. package/tools/doc-parity.mjs +8 -0
  34. package/tools/engine-source.mjs +6 -0
  35. package/tools/family-registry.mjs +21 -7
  36. package/tools/fold-completeness-run.mjs +5 -0
  37. package/tools/grounding.mjs +139 -22
  38. package/tools/inject-methodology.mjs +117 -28
  39. package/tools/manifest/schema.md +20 -0
  40. package/tools/manifest/validate.mjs +26 -0
  41. package/tools/procedures.mjs +61 -8
  42. package/tools/recipes.mjs +94 -15
  43. package/tools/recommendations.mjs +469 -0
  44. package/tools/review-ledger-write.mjs +14 -0
  45. package/tools/review-ledger.mjs +3 -2
  46. package/tools/review-state.mjs +101 -24
  47. package/tools/run-gates.mjs +3 -0
  48. package/tools/sandbox-masks.mjs +370 -0
  49. package/tools/set-recipe.mjs +13 -1
  50. package/tools/velocity-profile.mjs +228 -22
@@ -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})`);
@@ -36,6 +36,7 @@ import { plansInFlight, PLANS_REL } from './review-state.mjs';
36
36
  // (an import-split test pins it). CONFIG_REL is RE-EXPORTED so existing importers (procedures.test.mjs,
37
37
  // historically) keep their import site working.
38
38
  import { CONFIG_REL, fail, loadConfig, assertSlotRecipe } from './orchestration-config.mjs';
39
+ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
39
40
  export { CONFIG_REL };
40
41
 
41
42
  // ── argument + override parsing (usage errors → exit 2) ─────────────────────────────
@@ -224,7 +225,10 @@ const reviewLoopAdvice = (slots, activity) =>
224
225
  // (the review-state plan-in-flight detector): exactly ONE plan in flight → render it populated;
225
226
  // zero or several → the explicit `--plan <path>` placeholder + a one-line discovery caveat. The
226
227
  // suggested --out lives OUTSIDE the repo (/tmp) — grounding.mjs refuses a non-scratch destination.
227
- const GROUNDING_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'grounding.mjs');
228
+ // Exported for the bridge-tier byte-parity pin (AD-044 Plan 4): the velocity tier seeds the
229
+ // grounding rule in EXACTLY this rendered spelling — `node "${GROUNDING_TOOL}"` — so seeded and
230
+ // rendered forms can never drift apart.
231
+ export const GROUNDING_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'grounding.mjs');
228
232
  const GROUNDING_FACTS_OUT = '/tmp/review-facts.md';
229
233
  const groundingPreStepAdvice = (activity, slots, plans) => {
230
234
  if (!slots.some((s) => (s.backends ?? []).includes('agy-review'))) return [];
@@ -242,17 +246,42 @@ const groundingPreStepAdvice = (activity, slots, plans) => {
242
246
  // Path arguments are double-quoted — a skill dir or plan name with a space must stay copy-pasteable.
243
247
  const lines = [
244
248
  'Grounding pre-step (agy is dispatched — assemble the verified facts BEFORE the review; grounding.mjs slices verbatim, judgment additions stay yours):',
245
- ` run: node "${GROUNDING_TOOL}" --constraints ${planArg} --out ${GROUNDING_FACTS_OUT}`,
249
+ ` run: node "${GROUNDING_TOOL}" --constraints --autonomy ${planArg} --out ${GROUNDING_FACTS_OUT}`,
246
250
  ` then: ${reviewForm} --facts @${GROUNDING_FACTS_OUT}`,
247
251
  ];
248
252
  if (plans.length === 0) {
249
- lines.push(` ↳ plan discovery: no plan in flight under ${PLANS_REL} — substitute the plan file you are reviewing against, or drop --plan for constraints-only facts.`);
253
+ lines.push(` ↳ plan discovery: no plan in flight under ${PLANS_REL} — substitute the plan file you are reviewing against, or drop --plan for constraints+autonomy facts.`);
250
254
  } else if (plans.length > 1) {
251
255
  lines.push(` ↳ plan discovery: ${plans.length} plans in flight under ${PLANS_REL} (${plans.join(', ')}) — populate --plan with the one under review.`);
252
256
  }
253
257
  return lines;
254
258
  };
255
259
 
260
+ // The per-activity autonomy block (AD-044 Plan 4): the resolved level for THIS activity + what it
261
+ // implies, rendered from resolveAutonomy — never retyped constants. Read at session start beside
262
+ // the resolved recipes (the AGENTS.md autonomy pointer's read contract). A malformed policy
263
+ // surfaces LOUDLY here (the session-start read is exactly where "malformed → STOP, never guess"
264
+ // must be visible); an absent file states the computed-defaults origin honestly.
265
+ const autonomyAdvice = (activity, facts) => {
266
+ if (facts == null) return [];
267
+ if (facts.error) return [`Autonomy (${AUTONOMY_REL}): MALFORMED — ${facts.error} — STOP and fix the policy file, never guess around it.`];
268
+ const level = facts.activities?.[activity]?.autonomy;
269
+ if (!level) return [];
270
+ const origin = facts.source === 'none'
271
+ ? `computed defaults — no ${AUTONOMY_REL}`
272
+ : facts.defaultsEquivalent
273
+ ? `computed defaults — ${AUTONOMY_REL} is the sparse defaults-equivalent seed`
274
+ : `from ${facts.source}`;
275
+ const redlines = Object.entries(facts.redlines).map(([k, v]) => `${k}=${v}`).join(', ');
276
+ const implies = level === 'sandbox'
277
+ ? 'the OS sandbox confines and auto-allows confined commands — work runs to the next checkpoint without per-command prompts'
278
+ : 'every non-allowlisted command prompts (the sandbox, where enabled, still confines)';
279
+ return [
280
+ `Autonomy for "${activity}" (${origin}): ${level} — ${implies}.`,
281
+ ` red-lines (always): ${redlines} — commit/push/publish keep their maintainer asks regardless of level.`,
282
+ ];
283
+ };
284
+
256
285
  // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
257
286
  // at the point of use like reviewLoopAdvice paraphrases §9/§4). Rendered UNCONDITIONALLY for
258
287
  // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
@@ -267,6 +296,7 @@ const costLanesAdvice = () => [
267
296
  ' • L2 subscription bridge (codex / agy) — reviews per the resolved recipe above, on frontier bridge models (quality-first).',
268
297
  ' • L3 frontier — judgment: plan/fold/synthesis, ADR/handover/changelog-entry wording, persuasive copy, go/no-go, real code.',
269
298
  ' • A step with no named guardrail does not move down a lane; red lines never move down (council review models · real code · memory/copy wording · the maintainer approval asks).',
299
+ ' • Sandbox lanes (under an OS sandbox): the L0 surfaces are sandbox-safe — gates/ledger/state/fold checks, git reads, plain no-network tests; the bridge wrappers are genuinely unsandboxed (network); npm-cache-touching commands are COMMAND-SHAPE dependent — first try the sandbox-safe shape (cache under $TMPDIR, offline/notifier off). Move ONLY the failing command out of the sandbox, never its class; BATCH consecutive unsandboxed calls.',
270
300
  ];
271
301
 
272
302
  // The verbatim per-backend DRIVING CONTRACT block (M-contract): the exact invocation descriptor(s),
@@ -297,7 +327,7 @@ const contractLines = ({ cmd, contract, settings }) => {
297
327
  return lines;
298
328
  };
299
329
 
300
- const formatHuman = ({ activity, section, slots, warnings, plans }) => {
330
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy }) => {
301
331
  const lines = [
302
332
  section,
303
333
  '',
@@ -309,6 +339,8 @@ const formatHuman = ({ activity, section, slots, warnings, plans }) => {
309
339
  if (s.reason) lines.push(` ↳ ${s.reason}`);
310
340
  for (const c of s.contracts ?? []) lines.push(...contractLines(c));
311
341
  }
342
+ const autonomyBlock = autonomyAdvice(activity, autonomy);
343
+ if (autonomyBlock.length) lines.push('', ...autonomyBlock);
312
344
  const grounding = groundingPreStepAdvice(activity, slots, plans);
313
345
  if (grounding.length) lines.push('', ...grounding);
314
346
  const advice = reviewLoopAdvice(slots, activity);
@@ -321,7 +353,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans }) => {
321
353
  return lines.join('\n');
322
354
  };
323
355
 
324
- const buildJson = ({ activity, section, slots, configSource, warnings, plans }) => ({
356
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy }) => ({
325
357
  activity,
326
358
  section,
327
359
  slots: Object.fromEntries(
@@ -334,6 +366,8 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans })
334
366
  groundingPreStep: groundingPreStepAdvice(activity, slots, plans),
335
367
  // ADDITIVE (cost-tiered execution): the unconditional cost-lane advisory, structured.
336
368
  costLanes: costLanesAdvice(),
369
+ // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
370
+ autonomy: autonomyAdvice(activity, autonomy),
337
371
  configSource,
338
372
  warnings,
339
373
  });
@@ -354,7 +388,8 @@ ${CONFIG_REL} + the read-only backend detector, and prints both. A per-run
354
388
  Read-only: never writes, never commits, never runs a subscription CLI.
355
389
 
356
390
  Exit codes: 0 success (an unsatisfiable override degrades loudly, still 0);
357
- 2 usage (unknown activity / bad --override); 1 config or engine error.`;
391
+ 2 usage (unknown activity / bad --override); 1 config or engine error
392
+ (incl. a malformed ${AUTONOMY_REL} — the advisory still renders, the exit flips).`;
358
393
 
359
394
  // ── main ───────────────────────────────────────────────────────────────────────────
360
395
 
@@ -387,9 +422,27 @@ export const main = (argv, ctx = {}) => {
387
422
  const slots = resolveAllSlots({ activity, config, detection, overrides });
388
423
  const warnings = [...detectWarnings, ...collectWarnings(slots)];
389
424
  const plans = plansInFlight(cwd);
425
+ // The autonomy facts (AD-044 Plan 4): resolved levels + red-lines from the policy file. A
426
+ // malformed policy renders LOUDLY in the block AND flips the exit to 1 (config error) — the
427
+ // recipes and contracts still print, only the code changes.
428
+ const autonomy = (() => {
429
+ try {
430
+ const { config: autonomyConfig, source } = loadAutonomy(cwd, readFile, lstat);
431
+ const resolved = resolveAutonomy(autonomyConfig);
432
+ // Structural seed detection (shared predicate) — a declared-defaults policy reads as
433
+ // "from docs/ai/autonomy.json", never as the seed (codex, Segment B closing).
434
+ const defaultsEquivalent = source !== 'none' && isSparseSeedConfig(autonomyConfig);
435
+ return { source, defaultsEquivalent, ...resolved };
436
+ } catch (err) {
437
+ return { error: (err && err.message) || String(err) };
438
+ }
439
+ })();
390
440
  const stdout = json
391
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans }), null, 2)
392
- : formatHuman({ activity, section, slots, warnings, plans });
441
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy }), null, 2)
442
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy });
443
+ if (autonomy?.error) {
444
+ return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
445
+ }
393
446
  return { code: 0, stdout, stderr: '' };
394
447
  } catch (err) {
395
448
  return { code: err.exitCode ?? 1, stdout: '', stderr: `procedures: ${err.message}` };