@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
package/tools/recipes.mjs CHANGED
@@ -14,6 +14,8 @@
14
14
  // orchestrator (the main agent) executes it via the bridge skills and always makes the single commit;
15
15
  // a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 18.
16
16
 
17
+ import { existsSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
17
19
  import { pathToFileURL } from 'node:url';
18
20
  // The host-level bridge-settings snapshot (fact-only, best-effort). READ-ONLY core only — never the
19
21
  // writer — so this read-only advisor never pulls in the atomic-write core.
@@ -296,7 +298,7 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
296
298
  // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
297
299
  // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
298
300
  // Always exactly one line: no part may carry a newline (pinned by tests).
299
- export const composeStatusLine = (detection, recommendation, settings = null) => {
301
+ export const composeStatusLine = (detection, recommendation, settings = null, autonomy = null) => {
300
302
  const backends = [...detection]
301
303
  .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
302
304
  .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
@@ -308,7 +310,71 @@ export const composeStatusLine = (detection, recommendation, settings = null) =>
308
310
  const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
309
311
  const active = settings?.active ?? [];
310
312
  const suffix = active.length ? ` · settings: ${active.map((s) => `${oneLine(s.key)}=${oneLine(s.value)}`).join(' · ')}` : '';
311
- return base + suffix;
313
+ // The autonomy segment (AD-044 Plan 4): rendered ONLY when the caller supplies the computed
314
+ // facts (composeAutonomyFacts) — an omitted param keeps the line byte-identical (the settings-
315
+ // suffix precedent). Fact-only: effective per-activity levels + the render-sync state; an absent
316
+ // policy says "computed defaults" honestly; a malformed policy surfaces LOUDLY, never omitted.
317
+ const autonomySegment = autonomy == null ? '' : ` · autonomy: ${oneLine(formatAutonomySegment(autonomy))}`;
318
+ return base + suffix + autonomySegment;
319
+ };
320
+
321
+ // The one-segment autonomy renderer behind composeStatusLine's 4th param.
322
+ const formatAutonomySegment = (a) => {
323
+ if (a.error) return `MALFORMED policy — ${a.error}`;
324
+ const levels = Object.entries(a.activities ?? {}).map(([k, v]) => `${k}=${v.autonomy}`).join(', ');
325
+ const state = a.source === 'none'
326
+ ? 'computed defaults — no policy file; declare with /agent-workflow-kit set-autonomy'
327
+ : a.defaultsEquivalent
328
+ ? 'declared, defaults-equivalent — computed defaults apply; declare levels with /agent-workflow-kit set-autonomy'
329
+ : `declared; render ${a.renderState}`;
330
+ return `${levels} (${state})`;
331
+ };
332
+
333
+ // composeAutonomyFacts(cwd) → the fact object the status/active lines render (AD-044 Plan 4):
334
+ // { source, redlines, activities, renderState } or { error }. Lazy imports — autonomy-config
335
+ // statically imports THIS module (ACTIVITIES) and velocity-profile is heavy, so both load at call
336
+ // time only (the loadConfig precedent). Never throws: a malformed policy becomes { error } so the
337
+ // one-line surfaces render it loudly instead of dying.
338
+ // The policy lives at the PROJECT root; the report-footer invokes the paste surfaces without
339
+ // --cwd, so a subdirectory shell must still find it. Nearest-.git walk-up (dir or worktree file),
340
+ // fs-only — this advisor stays spawn-free; no repo found → the cwd itself (fixture behavior).
341
+ const projectTopOf = (cwd) => {
342
+ let dir = resolve(cwd);
343
+ for (;;) {
344
+ if (existsSync(join(dir, '.git'))) return dir;
345
+ const parent = dirname(dir);
346
+ if (parent === dir) return resolve(cwd);
347
+ dir = parent;
348
+ }
349
+ };
350
+
351
+ export const composeAutonomyFacts = async (cwd) => {
352
+ try {
353
+ const root = projectTopOf(cwd);
354
+ const { loadAutonomy, resolveAutonomy, isSparseSeedConfig } = await import('./autonomy-config.mjs');
355
+ const { config, source } = loadAutonomy(root);
356
+ const resolved = resolveAutonomy(config);
357
+ if (source === 'none') return { source, redlines: resolved.redlines, activities: resolved.activities, renderState: null };
358
+ // The STRUCTURAL seed (_README only) is a fresh-deployment NORMAL — treating it as "declared"
359
+ // would report "render DRIFT" on every fresh upgrade (codex, Segment B). An EXPLICIT policy
360
+ // declaring the default values is a real declaration — its render state IS computed
361
+ // (structural detection, codex Segment B closing).
362
+ if (isSparseSeedConfig(config)) {
363
+ return { source, defaultsEquivalent: true, redlines: resolved.redlines, activities: resolved.activities, renderState: null };
364
+ }
365
+ let renderState;
366
+ try {
367
+ const { checkAutonomyProfile } = await import('./velocity-profile.mjs');
368
+ renderState = checkAutonomyProfile({ cwd: root }).inSync
369
+ ? 'in sync'
370
+ : 'DRIFT — re-run the velocity --autonomy render';
371
+ } catch (err) {
372
+ renderState = `unchecked (${err?.message ?? err})`;
373
+ }
374
+ return { source, redlines: resolved.redlines, activities: resolved.activities, renderState };
375
+ } catch (err) {
376
+ return { error: err?.message ?? String(err) };
377
+ }
312
378
  };
313
379
 
314
380
  // ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
@@ -321,17 +387,21 @@ export const composeStatusLine = (detection, recommendation, settings = null) =>
321
387
  // session-start checklist + the handover "Active recipes:" slot paste it verbatim, so no agent composes
322
388
  // the configured-recipe facts by hand. `{ config, source }` is exactly what loadConfig returns (source
323
389
  // 'none' when no config file exists). Always exactly one line: no part may carry a newline (pinned).
324
- export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
390
+ export const composeActiveRecipeLine = ({ config, source } = {}, detection, autonomy = null) => {
325
391
  const cells = [];
326
392
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
393
+ // The per-activity autonomy level rides each cell when the caller supplies the facts (AD-044
394
+ // Plan 4) — an omitted param keeps the line byte-identical (the composeStatusLine precedent).
395
+ const level = autonomy?.activities?.[activity]?.autonomy;
396
+ const auto = level ? `; autonomy ${level}` : '';
327
397
  for (const slot of Object.keys(def.slots)) {
328
398
  const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
329
399
  const { dispatch } = planRecipe(r.recipe, detection);
330
400
  const wrappers = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
331
401
  const srcLabel = r.source === 'config' ? 'configured' : 'computed default';
332
402
  const head = r.degradedFrom
333
- ? `${activity}.${slot} = ${r.degradedFrom} (${srcLabel}; degrades here to ${r.recipe} — ${r.reason})`
334
- : `${activity}.${slot} = ${r.recipe} (${srcLabel})`;
403
+ ? `${activity}.${slot} = ${r.degradedFrom} (${srcLabel}; degrades here to ${r.recipe} — ${r.reason}${auto})`
404
+ : `${activity}.${slot} = ${r.recipe} (${srcLabel}${auto})`;
335
405
  const suffix =
336
406
  wrappers.length >= 2
337
407
  ? ` → every backend every round: ${wrappers.join(' + ')}`
@@ -343,14 +413,19 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
343
413
  }
344
414
  const rec = recommendRecipe(detection);
345
415
  const origin = source === 'none' || config == null ? 'no config file — computed defaults apply' : `from ${source}`;
346
- return `active recipes (${origin}): ${cells.join(' · ')} the configured recipes above are what runs; readiness-recommended here: ${rec.recipe} (informational)`;
416
+ // A MALFORMED policy must surface LOUDLY on this paste surface too silently rendering cells
417
+ // without levels would hide the required STOP signal (codex R1, Segment B). One line always.
418
+ const malformed = autonomy?.error
419
+ ? ` · autonomy: MALFORMED policy — ${String(autonomy.error).replace(/[\s]+/g, ' ').trim()}`
420
+ : '';
421
+ return `active recipes (${origin}): ${cells.join(' · ')} — the configured recipes above are what runs; readiness-recommended here: ${rec.recipe} (informational)${malformed}`;
347
422
  };
348
423
 
349
424
  // ── report + CLI ─────────────────────────────────────────────────────────────────
350
425
 
351
426
  // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
352
427
  // (additive) the pasteable one-line backend status composed from the same detection.
353
- export const buildReport = (detection, settings = null) => {
428
+ export const buildReport = (detection, settings = null, autonomy = null) => {
354
429
  const recommendation = recommendRecipe(detection);
355
430
  return {
356
431
  recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
@@ -363,7 +438,9 @@ export const buildReport = (detection, settings = null) => {
363
438
  })),
364
439
  recommendation,
365
440
  plans: RECIPES.map((r) => planRecipe(r.id, detection)),
366
- statusLine: composeStatusLine(detection, recommendation, settings),
441
+ // The SAME autonomy facts the --status-line surface renders — the --json envelope must never
442
+ // expose a stale machine-composed status line (codex R1, Segment B).
443
+ statusLine: composeStatusLine(detection, recommendation, settings, autonomy),
367
444
  };
368
445
  };
369
446
 
@@ -404,10 +481,12 @@ Usage:
404
481
 
405
482
  Lists the four recipes (Solo / Reviewed / Council / Delegated) and, from the read-only backend
406
483
  detector, plans + recommends one for the current environment. --status-line prints exactly ONE
407
- line — the machine-composed backend-status summary the bootstrap/upgrade reports paste verbatim.
408
- --active-line prints exactly ONE line the CONFIGURED recipe per activity/slot, resolved from the
409
- per-project docs/ai/orchestration.json (read from the current directory) + live readiness, with
410
- degradation stated; paste it verbatim at session start / into the handover "Active recipes:" slot.
484
+ line — the machine-composed backend-status summary the bootstrap/upgrade reports paste verbatim
485
+ (incl. the per-activity autonomy segment: effective levels + render-sync state, honest
486
+ computed-defaults wording when no policy file exists). --active-line prints exactly ONE line — the
487
+ CONFIGURED recipe per activity/slot, resolved from the per-project docs/ai/orchestration.json (read
488
+ from the current directory) + live readiness, with degradation stated and each activity's autonomy
489
+ level beside its cells; paste it verbatim at session start / into the handover "Active recipes:" slot.
411
490
  --json emits the structured report (incl. the same line as \`statusLine\`); the three are mutually
412
491
  exclusive. Detection only — never writes, never commits, never runs a subscription CLI.`);
413
492
  return;
@@ -428,13 +507,13 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
428
507
  // so the config reader is pulled in at run time only — no static import cycle.
429
508
  const { loadConfig } = await import('./orchestration-config.mjs');
430
509
  try {
431
- console.log(composeActiveRecipeLine(loadConfig(process.cwd()), detection));
510
+ console.log(composeActiveRecipeLine(loadConfig(process.cwd()), detection, await composeAutonomyFacts(process.cwd())));
432
511
  } catch (err) {
433
512
  console.error(`[agent-workflow-kit] ${err.message}`);
434
513
  return err.exitCode ?? 1;
435
514
  }
436
- } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection), settingsSnapshot()));
437
- else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection, settingsSnapshot()), null, 2));
515
+ } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection), settingsSnapshot(), await composeAutonomyFacts(process.cwd())));
516
+ else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection, settingsSnapshot(), await composeAutonomyFacts(process.cwd())), null, 2));
438
517
  else console.log(formatRecipes(detection));
439
518
  return 0;
440
519
  };
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ // recommendations.mjs — the read-only upgrade Recommendations advisor behind
3
+ // `/agent-workflow-kit recommendations` (AD-044 Plan 4, Phase 3).
4
+ //
5
+ // A consumer who upgrades the kit never learns their deployment is configured sub-optimally (no
6
+ // bridge allowlist, autonomy render drifted, sandbox not provisioned, gates undeclared) — every
7
+ // `upgrade` run therefore ends with a mandatory, deterministic Recommendations section: what is
8
+ // sub-optimal · the benefit in ONE plain line · the exact consent-gated apply one-liner. The agent
9
+ // pastes the section VERBATIM (the composeStatusLine precedent) and then OFFERS to apply; the user
10
+ // picks items in plain language; the agent runs EXACTLY the rendered one-liners — no improvisation,
11
+ // each writer's own consent semantics intact.
12
+ //
13
+ // Contract:
14
+ // node recommendations.mjs --cwd <project-root> [--json]
15
+ // --cwd is REQUIRED (subdir-proof: the target project is explicit, never inferred from the shell's
16
+ // current directory). The section renders PRESENT-EVEN-WHEN-EMPTY (the exact empty-state line
17
+ // below). Benefit lines are frozen tool DATA, fact-true: the dual velocity+security wording rides
18
+ // ONLY items with a real security delta (sandbox/autonomy render); the bridge-wrappers item claims
19
+ // velocity only. A probe failure is a stated skipped-item line — never a crash, never a fabricated
20
+ // item. The network-allowlist item is HAND-APPLY by design (bridge council 2026-07-11, both
21
+ // backends concur): the kit never seeds sandbox.network.allowedDomains / filesystem.allowWrite.
22
+ //
23
+ // Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
24
+ // exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
25
+ // registry/sandbox-masks); the sandbox-masks and settings probes may run read-only git queries.
26
+ // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
27
+
28
+ import { readFileSync, readdirSync, lstatSync } from 'node:fs';
29
+ import { dirname, join, resolve } from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import {
32
+ preflightVelocityProfile,
33
+ planVelocityProfile,
34
+ checkAutonomyProfile,
35
+ probeSandboxAvailability,
36
+ isExecutableFile,
37
+ readSettingsFile,
38
+ BRIDGE_REVIEW_WRAPPERS,
39
+ BRIDGE_REVIEW_MODE,
40
+ SETTINGS_FILE,
41
+ SETTINGS_LOCAL_FILE,
42
+ } from './velocity-profile.mjs';
43
+ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
44
+ import { deriveDoctorPlan } from './autonomy-doctor.mjs';
45
+ import { detectBackends, findOnPath } from './detect-backends.mjs';
46
+ import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
47
+ import { surveyFamily, surveyGateHook } from './family-registry.mjs';
48
+ import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
49
+ import { shellQuoteArg } from './review-state.mjs';
50
+ import { loadConfig } from './orchestration-config.mjs';
51
+ import { DEFAULT_BUNDLE_ROOT, SETTINGS_FILENAME, settingsPath, parseSettings, duplicateKeys } from './bridge-settings-read.mjs';
52
+
53
+ const HERE = dirname(fileURLToPath(import.meta.url));
54
+ const toolPath = (rel) => join(HERE, rel);
55
+ const q = shellQuoteArg;
56
+
57
+ // ── the section contract tokens (doc-parity-bound in upgrade.md + the mode doc) ────────────────
58
+ export const RECOMMENDATIONS_SECTION_HEADER = '## Recommendations (agent-workflow)';
59
+ export const RECOMMENDATIONS_EMPTY_LINE = 'no recommendations — flow optimal.';
60
+ // The one dual-wording security clause — rides ONLY the items with a real security delta.
61
+ export const DUAL_SECURITY_BENEFIT = 'safer — blast radius bounded by the OS sandbox, not human attention';
62
+
63
+ // ── the frozen benefit registry (fact-true; pinned by tests) ────────────────────────────────────
64
+ export const BENEFITS = Object.freeze({
65
+ 'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away',
66
+ 'kit-tools-tier': "velocity — the kit's own read-only tools run promptless (audited, resolved-absolute tier)",
67
+ 'bridge-tier':
68
+ 'velocity — unattended council review runs: the placed review wrappers’ code mode stops prompting (delegated execution and the plan/diff modes keep their prompt)',
69
+ 'autonomy-policy': 'clarity — the per-activity autonomy policy becomes an explicit, versioned declaration instead of implicit computed defaults',
70
+ 'autonomy-render': `velocity — the sandbox auto-allows confined commands per your declared policy; ${DUAL_SECURITY_BENEFIT}`,
71
+ 'sandbox-provision': `velocity — confined ad-hoc commands stop prompting once the OS sandbox is available; ${DUAL_SECURITY_BENEFIT}`,
72
+ 'review-recipe': 'review coverage — the review recipe you configured actually runs instead of silently degrading',
73
+ 'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
74
+ 'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
75
+ 'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
76
+ 'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
77
+ 'agy-adddir':
78
+ 'large reviews — an oversized agy code review offloads to a staging dir instead of refusing; CAVEAT: re-enables the Issue-001 stall risk (the hard timeout bounds it)',
79
+ 'network-allowlist':
80
+ 'unblocks the NETWORK half of in-sandbox bridge runs where the sandbox honors settings keys (settings-native harnesses; the network gate ONLY — no filesystem allowance is recommended); RISK stated plainly: pre-allows egress to these hosts for EVERY sandboxed command (informed hand-consent only). Live-observed 2026-07-12: an IDE-managed session sandbox ignores these settings keys too — there the durable lanes are the harness’s own per-host network consents / session sandbox config, or the per-run consented bypass; codex additionally needs a writable HOME (EROFS ~/.codex)',
81
+ });
82
+
83
+ // A typed usage failure (exit 2) — the codebase's typed-error idiom (no classes).
84
+ const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
85
+
86
+ // ── item probes ──────────────────────────────────────────────────────────────────────────────────
87
+ // Each probe is independent and wrapped: a throw becomes a stated skipped-item line (never a crash,
88
+ // never a fabricated item); returning without adding means "nothing sub-optimal here".
89
+
90
+ const probeVelocityItems = ({ root, deps, add, skip }) => {
91
+ const applyLine = (extra) => `node ${q(toolPath('velocity-profile.mjs'))} --apply${extra} --cwd ${q(root)}`;
92
+ let preflight;
93
+ try {
94
+ preflight = preflightVelocityProfile({ cwd: root }, deps);
95
+ } catch (err) {
96
+ // One preflight failure (unsafe mode, malformed settings, symlinked .claude) skips all three
97
+ // velocity items with the same stated reason.
98
+ for (const key of ['velocity-core', 'kit-tools-tier', 'bridge-tier']) skip(key, err);
99
+ return;
100
+ }
101
+ // The flagless core plan is pure filters over the successful preflight — it cannot throw, so it
102
+ // deliberately carries NO defensive catch (a dead branch is not honesty; the preflight catch
103
+ // above owns the real failure modes).
104
+ const core = planVelocityProfile(preflight, {});
105
+ if (core.toAdd.length > 0) {
106
+ add('velocity-core', `routine read-only commands still prompt — ${core.toAdd.length} audited read-only allowlist entr(ies) not seeded`, applyLine(''));
107
+ }
108
+ try {
109
+ const kt = planVelocityProfile(preflight, { kitTools: true });
110
+ if (kt.tierToAdd.length > 0) {
111
+ add('kit-tools-tier', `the kit's own read-only tools still prompt — ${kt.tierToAdd.length} kit-tools tier entr(ies) not seeded`, applyLine(' --kit-tools'));
112
+ }
113
+ } catch (err) {
114
+ skip('kit-tools-tier', err);
115
+ }
116
+ try {
117
+ const bt = planVelocityProfile(preflight, { bridgeTier: true, findWrapper: deps.findWrapper });
118
+ const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length;
119
+ if (delta > 0) {
120
+ add('bridge-tier', `council review runs prompt per bridge invocation — ${delta} bridge-wrappers tier entr(ies) not seeded (placed bridges only, code mode only)`, applyLine(' --bridge-tier'));
121
+ }
122
+ } catch (err) {
123
+ skip('bridge-tier', err);
124
+ }
125
+ };
126
+
127
+ const probeAutonomyItems = ({ root, deps, add, skip }) => {
128
+ let source = null;
129
+ try {
130
+ let config = null;
131
+ ({ config, source } = loadAutonomy(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync));
132
+ // The STRUCTURAL seed (_README-only) declares nothing yet — a render item here would
133
+ // overclaim (codex, Segment B). An EXPLICIT policy declaring the default values is a real
134
+ // declaration: its render check still runs below (codex, Segment B closing).
135
+ if (source !== 'none' && isSparseSeedConfig(config)) return;
136
+ if (source === 'none') {
137
+ add('autonomy-policy', `no ${AUTONOMY_REL} — the computed defaults apply implicitly (red-lines ask/deny; every activity floors at prompt)`, '/agent-workflow-kit set-autonomy (run IN the target project — the conversational writer previews, then writes its docs/ai/autonomy.json)');
138
+ }
139
+ } catch (err) {
140
+ skip('autonomy-policy', err);
141
+ return; // a malformed policy also blocks the render check below — one stated reason is enough
142
+ }
143
+ if (source === 'none') return; // nothing to render-check without a declared policy (not a skip)
144
+ try {
145
+ const check = checkAutonomyProfile({ cwd: root }, deps);
146
+ if (!check.inSync) {
147
+ add('autonomy-render', `the declared autonomy policy is not rendered into .claude/settings.json — drift: ${check.drift[0]}${check.drift.length > 1 ? ` (+${check.drift.length - 1} more)` : ''}`, `node ${q(toolPath('velocity-profile.mjs'))} --autonomy --apply --cwd ${q(root)}`);
148
+ }
149
+ } catch (err) {
150
+ skip('autonomy-render', err);
151
+ }
152
+ };
153
+
154
+ const probeSandboxProvision = ({ root, deps, add, skip }) => {
155
+ try {
156
+ const p = probeSandboxAvailability(deps);
157
+ if (p.available) return;
158
+ const plan = deriveDoctorPlan({ probeResult: p, env: deps.env ?? process.env, isExec: deps.isExecutable ?? isExecutableFile });
159
+ const installNote = plan.tuple ? ` — installable via the doctor (consent tuple ${plan.tuple})` : '';
160
+ // The doctor reads process.cwd() (deployment-gated) and takes no --cwd flag — the one-liner
161
+ // pins the target project via a cd prefix (codex R2, Segment B).
162
+ add('sandbox-provision', `the OS sandbox is unavailable: ${p.reason}${installNote}`, `cd ${q(root)} && node ${q(toolPath('autonomy-doctor.mjs'))}`);
163
+ } catch (err) {
164
+ skip('sandbox-provision', err);
165
+ }
166
+ };
167
+
168
+ const probeReviewRecipe = ({ root, deps, add, skip }) => {
169
+ try {
170
+ // The VALIDATED reader (codex R2, Segment B): a schema-invalid config (unknown activity/slot,
171
+ // bad recipe) throws here and becomes a stated skip — raw JSON.parse would silently ignore it.
172
+ const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
173
+ const detection = detectBackends(deps);
174
+ const degraded = [];
175
+ for (const [activity, def] of Object.entries(ACTIVITIES)) {
176
+ for (const slot of Object.keys(def.slots)) {
177
+ const r = resolveActivityRecipe({ config, readiness: detection, activity, slot });
178
+ if (r.degradedFrom) degraded.push(`${activity}.${slot}: configured ${r.degradedFrom} degrades to ${r.recipe} (${r.reason})`);
179
+ }
180
+ }
181
+ if (degraded.length > 0) {
182
+ add('review-recipe', degraded.join('; '), '/agent-workflow-kit backends');
183
+ }
184
+ } catch (err) {
185
+ skip('review-recipe', err);
186
+ }
187
+ };
188
+
189
+ const probeGates = ({ root, deps, add, skip }) => {
190
+ try {
191
+ const sg = surveyGateHook(root, deps);
192
+ if (sg.error) throw new Error(sg.error);
193
+ if (sg.declarationPresent && sg.declaredGates === null) throw new Error(sg.declarationError ?? 'gates.json present but unreadable');
194
+ // An ABSENT file and the seeded-EMPTY list are equally undeclared (codex R2, Segment B); the
195
+ // apply is the consent-gated seeder PREVIEW (it proposes entries from the project's own
196
+ // scripts and writes only on an explicit yes) — never the runner.
197
+ if (!sg.declarationPresent || sg.declaredGates === 0) {
198
+ // The seeder writes ONLY with --apply and consent is per-entry (--only) — the apply field
199
+ // stays a PURE executable command (run-exactly-verbatim feeds it to the shell); the
200
+ // two-step preview semantics live in WHAT, never as prose appended to the command.
201
+ add('gates-declaration', 'no declared gate matrix (docs/ai/gates.json absent or empty) — gate commands run ad hoc and prompt one by one; the apply line is the PREVIEW (writes nothing) — it prints the exact consent-gated --apply [--only <id>] line to run next', `node ${q(toolPath('seed-gates.mjs'))} --cwd ${q(root)}`);
202
+ return;
203
+ }
204
+ if (sg.declaredGates > 0 && !sg.wired) {
205
+ add('gate-hook', `${sg.declaredGates} declared gate(s) prompt per run — the gate-approval hook is not wired`, `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`);
206
+ }
207
+ } catch (err) {
208
+ skip('gate-hook', err);
209
+ }
210
+ };
211
+
212
+ const probeFamilyFreshness = ({ deps, add, skip }) => {
213
+ try {
214
+ const survey = deps.surveyFamily ?? surveyFamily;
215
+ const rows = survey(deps);
216
+ const behind = rows.filter((r) => r.freshness === 'behind');
217
+ const caveated = rows.filter((r) => (r.caveats ?? []).length > 0 && r.freshness !== 'behind');
218
+ // freshness 'unknown' with NO caveat = a compare probe FAILED silently (the memory
219
+ // template-probe lane) — dropping the row would let the flow-optimal claim ride a failed
220
+ // check; it becomes a stated skip. 'not-checked' is a deliberately unprobed surface, not a failure.
221
+ const unknownUncaveated = rows.filter((r) => r.freshness === 'unknown' && (r.caveats ?? []).length === 0);
222
+ if (unknownUncaveated.length > 0) {
223
+ skip('family-freshness', new Error(`freshness unknown for ${unknownUncaveated.map((r) => r.name).join(', ')} — the compare probe failed; npx @sabaiway/agent-workflow-kit@latest init refreshes/repairs the install`));
224
+ }
225
+ if (behind.length === 0 && caveated.length === 0) return;
226
+ const parts = [
227
+ ...behind.map((r) => `${r.name} ${r.version ?? '?'} is behind its bundled copy`),
228
+ // ALL caveats per row — a memory missing BOTH templates must not drop the second (codex).
229
+ ...caveated.map((r) => `${r.name}: ${r.caveats.join('; ')}`),
230
+ ];
231
+ add('family-freshness', parts.join('; '), 'npx @sabaiway/agent-workflow-kit@latest init');
232
+ } catch (err) {
233
+ skip('family-freshness', err);
234
+ }
235
+ };
236
+
237
+ const probeMasksItem = ({ root, deps, add, skip }) => {
238
+ try {
239
+ const p = probeSandboxMasks({ cwd: root, ...deps });
240
+ if (p == null) return; // not a git work tree — the lane is N/A, not sub-optimal
241
+ if (!needsMasksApply(p)) return;
242
+ const stale = p.staleReal.length ? `; ${p.staleReal.length} fenced entr(ies) became REAL paths (a fresh apply drops them by construction)` : '';
243
+ // A stale-real-only fence (EMPTY derivation over a non-empty block) makes the plain --apply
244
+ // REFUSE — the exact one-liner must carry --clear there (codex R1, Segment B).
245
+ const apply = p.masks.length === 0 && p.staleReal.length > 0 ? `${p.applyCmd} --clear` : p.applyCmd;
246
+ add('sandbox-masks', `${p.masks.length} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale${stale}`, apply);
247
+ } catch (err) {
248
+ skip('sandbox-masks', err);
249
+ }
250
+ };
251
+
252
+ const probeAgyAdddir = ({ deps, add, skip }) => {
253
+ try {
254
+ const probePlaced = deps.findWrapper ?? ((cmd) => findOnPath(cmd, deps).state === 'present');
255
+ if (!probePlaced('agy-review')) return;
256
+ // Configured means a VALID boolean value (the wrapper validates and falls back to the default
257
+ // on garbage — presence alone proves nothing; codex R3). An explicit valid 0 is a user CHOICE
258
+ // (refuse mode) — respected, never nagged. env > file, the wrappers' own precedence.
259
+ const isValidBool = (v) => v === '0' || v === '1';
260
+ const env = deps.getenv ?? process.env;
261
+ if (env.AGY_REVIEW_ALLOW_ADDDIR != null) {
262
+ if (isValidBool(env.AGY_REVIEW_ALLOW_ADDDIR)) return; // an explicit valid env choice — respected
263
+ // A SET-BUT-EMPTY env var is the wrapper's opt-out shape (${!key+x}: it shadows the file
264
+ // and falls back to the built-in refuse default) — a user CHOICE, never nagged (codex).
265
+ if (env.AGY_REVIEW_ALLOW_ADDDIR === '') return;
266
+ // env > file: while ANY env value is set the wrapper ignores the settings file, so the file
267
+ // writer cannot fix an invalid env — the honest apply is to fix/unset the env var (codex).
268
+ add('agy-adddir', `AGY_REVIEW_ALLOW_ADDDIR is set to an INVALID value (${JSON.stringify(env.AGY_REVIEW_ALLOW_ADDDIR)}) — the wrapper falls back to refuse-mode and the settings file is shadowed while the env var is set`, 'HAND-APPLY: unset AGY_REVIEW_ALLOW_ADDDIR in the environment (or export it as 1), THEN configure it durably via the bridge-settings writer');
269
+ return;
270
+ }
271
+ const confPath = settingsPath({ getenv: env, home: deps.home });
272
+ const readFile = deps.readFile ?? readFileSync;
273
+ const text = (() => {
274
+ try {
275
+ return readFile(confPath, 'utf8');
276
+ } catch (err) {
277
+ if (err?.code === 'ENOENT') return '';
278
+ throw err;
279
+ }
280
+ })();
281
+ const parsed = parseSettings(text);
282
+ const fileEntries = parsed.byKey.get('AGY_REVIEW_ALLOW_ADDDIR');
283
+ const fileValue = fileEntries?.length ? fileEntries[fileEntries.length - 1].value : null;
284
+ if (fileValue != null && isValidBool(fileValue)) return; // env is absent here — a valid file value governs
285
+ // The settings writer REFUSES a duplicate-carrying file — rendering its command would hand
286
+ // the user a guaranteed failure; the honest apply is fix-duplicates-first (codex terminal).
287
+ const dups = duplicateKeys(parsed);
288
+ const what = `agy-review is placed but AGY_REVIEW_ALLOW_ADDDIR is not set (${SETTINGS_FILENAME}) — an oversized code review refuses instead of offloading`;
289
+ if (dups.length > 0) {
290
+ add('agy-adddir', what, `HAND-APPLY: ${SETTINGS_FILENAME} carries duplicate key(s) (${dups.join(', ')}) and the settings writer refuses to edit it — remove the duplicate lines by hand, THEN run: node ${q(toolPath('bridge-settings.mjs'))} --set AGY_REVIEW_ALLOW_ADDDIR=1 --apply`);
291
+ return;
292
+ }
293
+ add('agy-adddir', what, `node ${q(toolPath('bridge-settings.mjs'))} --set AGY_REVIEW_ALLOW_ADDDIR=1 --apply`);
294
+ } catch (err) {
295
+ skip('agy-adddir', err);
296
+ }
297
+ };
298
+
299
+ // networkHosts of every BUNDLED bridge whose review wrapper is in the placed set — derived from the
300
+ // manifests (the single documentation source), never a hardcoded host list here.
301
+ const bundledNetworkHosts = (placedWrappers, deps) => {
302
+ const readFile = deps.readFile ?? readFileSync;
303
+ const readDir = deps.readdir ?? readdirSync;
304
+ const bundleRoot = deps.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
305
+ const hosts = [];
306
+ for (const dir of readDir(bundleRoot)) {
307
+ // An unreadable/unparsable bundled manifest must NOT thin the paste list silently — a partial
308
+ // allowlist pasted as complete is worse than no item. The throw reaches the probe's catch and
309
+ // becomes a stated skip.
310
+ let manifest;
311
+ try {
312
+ manifest = JSON.parse(readFile(join(bundleRoot, dir, 'capability.json'), 'utf8'));
313
+ } catch (err) {
314
+ // ENOTDIR = the entry is a stray regular file (.DS_Store, a README), not a bridge bundle.
315
+ if (err?.code === 'ENOTDIR') continue;
316
+ throw new Error(`bundled manifest unreadable: ${join(dir, 'capability.json')} — ${err?.message ?? err}`);
317
+ }
318
+ const reviewCmd = manifest?.roles?.review?.cmd;
319
+ if (reviewCmd && placedWrappers.includes(reviewCmd) && Array.isArray(manifest.networkHosts)) {
320
+ for (const h of manifest.networkHosts) if (!hosts.includes(h)) hosts.push(h);
321
+ }
322
+ }
323
+ return hosts;
324
+ };
325
+
326
+ const probeNetworkAllowlist = ({ root, deps, add, skip }) => {
327
+ try {
328
+ const settings = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
329
+ const localSettings = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
330
+ const sandbox = settings.data?.sandbox;
331
+ const excluded = Array.isArray(sandbox?.excludedCommands) ? sandbox.excludedCommands : [];
332
+ const probePlaced = deps.findWrapper ?? ((cmd) => findOnPath(cmd, deps).state === 'present');
333
+ // Wired = the FULL two-surface tier proof (excludedCommands + the code-mode allow rule, either
334
+ // scope) — surfacing the risky egress hand-apply while the tier is half-configured would
335
+ // front-run the bridge-tier item (codex terminal). Byte-form from the tier's own constants.
336
+ const allowRules = [
337
+ ...(Array.isArray(settings.data?.permissions?.allow) ? settings.data.permissions.allow : []),
338
+ ...(Array.isArray(localSettings.data?.permissions?.allow) ? localSettings.data.permissions.allow : []),
339
+ ];
340
+ const wired = BRIDGE_REVIEW_WRAPPERS.filter(
341
+ (w) => excluded.includes(w) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
342
+ );
343
+ if (wired.length === 0) return; // the tier is not (fully) wired — the bridge-tier item covers first
344
+ // Convergence (codex R3): a hand-applied list must silence the item — compare the LIVE
345
+ // allowedDomains (project + local scope) against the manifests and render only what is missing.
346
+ const projectApplied = Array.isArray(sandbox?.network?.allowedDomains) ? sandbox.network.allowedDomains : [];
347
+ const localApplied = Array.isArray(localSettings.data?.sandbox?.network?.allowedDomains) ? localSettings.data.sandbox.network.allowedDomains : [];
348
+ // Local scope counts toward COVERAGE only — the paste targets the COMMITTED project file, so a
349
+ // local-only allowance must never be widened to the whole project (codex terminal).
350
+ const applied = [...projectApplied, ...localApplied];
351
+ const manifestHosts = bundledNetworkHosts(wired, deps);
352
+ const missing = manifestHosts.filter((h) => !applied.includes(h));
353
+ if (missing.length === 0 && applied.length > 0) return; // every manifest host is already hand-applied
354
+ // The pasted value is the FULL desired final list for the PROJECT scope (project ∪ missing) —
355
+ // a missing-only snippet pasted verbatim would DROP the already-applied domains and oscillate.
356
+ const finalList = [...projectApplied, ...missing];
357
+ const hostsJson = finalList.map((h) => JSON.stringify(h)).join(', ');
358
+ add(
359
+ 'network-allowlist',
360
+ 'IF plain wrapper runs still hit sandbox network prompts, this session’s sandbox is HARNESS-MANAGED — settings-level exclusions are inert there (live-observed 2026-07-11)',
361
+ `HAND-APPLY (the kit never writes this): in .claude/settings.json set the key sandbox.network.allowedDomains to [${hostsJson}] — a MERGE into the existing sandbox object (keep excludedCommands and every other sandbox key); hosts from the bridges' capability.json networkHosts (observed-minimal; a blocked host names itself at run time)`,
362
+ );
363
+ } catch (err) {
364
+ skip('network-allowlist', err);
365
+ }
366
+ };
367
+
368
+ // ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
369
+ const PROBES = Object.freeze([
370
+ probeVelocityItems,
371
+ probeAutonomyItems,
372
+ probeSandboxProvision,
373
+ probeReviewRecipe,
374
+ probeGates,
375
+ probeFamilyFreshness,
376
+ probeMasksItem,
377
+ probeAgyAdddir,
378
+ probeNetworkAllowlist,
379
+ ]);
380
+
381
+ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
382
+ const root = resolve(cwd);
383
+ const items = [];
384
+ const skips = [];
385
+ const add = (key, what, apply) => items.push({ key, what, benefit: BENEFITS[key], apply });
386
+ const skip = (key, err) => skips.push({ key, reason: err?.message ?? String(err) });
387
+ for (const probe of PROBES) probe({ root, deps, add, skip });
388
+ return { root, items, skips };
389
+ };
390
+
391
+ // ── rendering (the agent pastes this section VERBATIM) ──────────────────────────────────────────
392
+ export const formatRecommendations = ({ items, skips }) => {
393
+ const lines = [RECOMMENDATIONS_SECTION_HEADER, ''];
394
+ if (items.length === 0 && skips.length === 0) {
395
+ // The flow-optimal claim renders ONLY when every probe ran and none fired — an empty item
396
+ // list beside skipped checks would falsely attest optimality (codex R1, Segment B).
397
+ lines.push(RECOMMENDATIONS_EMPTY_LINE);
398
+ } else if (items.length === 0) {
399
+ lines.push(`no applicable items, but ${skips.length} probe check(s) were skipped — the flow is NOT attested optimal:`);
400
+ } else {
401
+ items.forEach((item, i) => {
402
+ lines.push(`${i + 1}. ${item.what}`);
403
+ lines.push(` benefit: ${item.benefit}`);
404
+ lines.push(` apply: ${item.apply}`);
405
+ });
406
+ }
407
+ for (const s of skips) {
408
+ lines.push(` ⚠ skipped item ${s.key} — probe failed: ${s.reason}`);
409
+ }
410
+ return lines.join('\n');
411
+ };
412
+
413
+ const HELP = `recommendations — the read-only upgrade Recommendations advisor (agent-workflow kit, AD-044).
414
+
415
+ Usage:
416
+ node recommendations.mjs --cwd <project-root> [--json]
417
+
418
+ Computes the deterministic Recommendations section every kit upgrade ends with: what is
419
+ sub-optimal in THIS deployment · the benefit in one plain line · the exact consent-gated apply
420
+ one-liner. --cwd is REQUIRED (the target project is explicit, never inferred from the shell's
421
+ current directory). The section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}");
422
+ a probe failure is a stated skipped-item line. Apply lines are cwd-independent (absolute tool
423
+ paths, a pinned --cwd; the doctor item pins via a cd prefix; the ONE exception is the set-autonomy
424
+ item — a conversational skill invocation labeled "run IN the target project") and preserve each
425
+ writer's own consent semantics; the network-allowlist item is HAND-APPLY by design — this tool and
426
+ the kit writers never seed sandbox network/filesystem allowances.
427
+
428
+ Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
429
+ rendered (items or empty); 1 error; 2 usage.`;
430
+
431
+ export const main = (argv, ctx = {}) => {
432
+ try {
433
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
434
+ let cwd = null;
435
+ let json = false;
436
+ for (let i = 0; i < argv.length; i += 1) {
437
+ const a = argv[i];
438
+ if (a === '--cwd') {
439
+ cwd = argv[i + 1];
440
+ if (!cwd || cwd.startsWith('--')) throw usageFail('--cwd requires a directory argument');
441
+ i += 1;
442
+ } else if (a === '--json') json = true;
443
+ else throw usageFail(`unknown argument: ${a} (see --help)`);
444
+ }
445
+ if (cwd == null) throw usageFail('--cwd <project-root> is required — the target project is explicit, never inferred');
446
+ const lstat = ctx.deps?.lstat ?? lstatSync;
447
+ const st = (() => {
448
+ try {
449
+ return lstat(resolve(cwd));
450
+ } catch {
451
+ return null;
452
+ }
453
+ })();
454
+ if (st == null || !st.isDirectory()) throw Object.assign(new Error(`--cwd is not a directory: ${cwd}`), { exitCode: 1 });
455
+ const result = buildRecommendations({ cwd, deps: ctx.deps ?? {} });
456
+ if (json) return { code: 0, stdout: JSON.stringify(result, null, 2), stderr: '' };
457
+ return { code: 0, stdout: formatRecommendations(result), stderr: '' };
458
+ } catch (err) {
459
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `recommendations: ${err.message}` };
460
+ }
461
+ };
462
+
463
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
464
+ if (isDirectRun) {
465
+ const r = main(process.argv.slice(2));
466
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
467
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
468
+ process.exitCode = r.code;
469
+ }