@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
@@ -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
+ }
@@ -414,6 +414,8 @@ Usage:
414
414
  node review-ledger-write.mjs record --json '<round-payload>' [--from-receipts] [--cwd <dir>]
415
415
  node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
416
416
  node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
417
+ (every verb also accepts --json @<file> — the payload read from a file, keeping the command
418
+ line PLAIN: an inline JSON argv falls outside plain-invocation allow heuristics and prompts)
417
419
 
418
420
  Every verb operates on the current SEGMENT — (loop, base = git rev-parse HEAD): round numbers,
419
421
  caps, and teeth reset only when a gated commit moves base (schema 4; records carry base).
@@ -461,6 +463,18 @@ const parseArgs = (argv) => {
461
463
  } else if (a === '--json') {
462
464
  opts.json = argv[i + 1];
463
465
  if (opts.json === undefined) throw usageFail('--json needs a JSON payload');
466
+ // `--json @<file>` reads the payload from a file: a large inline JSON argv (quotes, braces)
467
+ // falls outside every plain-invocation allow heuristic and prompts, and hand-composing it on
468
+ // a command line is exactly the error-prone class --from-receipts exists to shrink — the
469
+ // file form keeps the COMMAND plain while the payload stays explicit (AD-044 Plan 4).
470
+ if (opts.json.startsWith('@')) {
471
+ const payloadPath = opts.json.slice(1);
472
+ try {
473
+ opts.json = readFileSync(payloadPath, 'utf8');
474
+ } catch (err) {
475
+ throw usageFail(`--json @${payloadPath}: unreadable payload file (${err.code ?? err.message})`);
476
+ }
477
+ }
464
478
  i += 1;
465
479
  } else if (a === '--from-receipts') {
466
480
  opts.fromReceipts = true;
@@ -569,8 +569,9 @@ gated commit, so the round-counter reset is earned, never declared.
569
569
  observed-red receipts, quarantined probes. Counts only — interpretation stays with you.
570
570
 
571
571
  The writer is a SEPARATE tool (review-ledger-write.mjs record/classify/override) — this read-only
572
- checker never imports it. Human residual: git commit --no-verify, ledger-file editing, and forged
573
- counts remain possible a self-discipline mechanism, not a security boundary.
572
+ checker never imports it. Sandbox-safe: runs fully inside an OS sandbox (fs + git reads, no
573
+ network) the D4 sandbox lane. Human residual: git commit --no-verify, ledger-file editing, and
574
+ forged counts remain possible — a self-discipline mechanism, not a security boundary.
574
575
 
575
576
  Exit codes: 0 pass (or plain report); 1 check failed or config error (loud); 2 usage.`;
576
577