@sabaiway/agent-workflow-kit 14.27.0 → 14.28.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.
@@ -0,0 +1,196 @@
1
+ // bridge-wiring.mjs — the leaf that judges the project sandbox's coverage of ready bridges.
2
+ // Contract: bridges/velocity-profile, part bridge-wiring. Dependency-free, Node >= 22.
3
+ // No side effects on import.
4
+
5
+ import { closeSync, constants, fstatSync, openSync, readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { bundledSandboxRecipe, usedBridges } from './bridge-sandbox-recipe.mjs';
9
+ import { missingStateDirs, stateDirsOf } from './bridge-state-dirs.mjs';
10
+ import { loadConfig } from './orchestration-config.mjs';
11
+ import { BACKEND_PRIORITY } from './carriers.mjs';
12
+ import { READY } from './detect-backends.mjs';
13
+
14
+ export const HOST_HONORS_QUALIFIER = 'where the host honors the settings sandbox keys';
15
+ export const WIRED = 'wired';
16
+ export const UNWIRED = 'not wired';
17
+ export const UNCHECKED = 'unchecked';
18
+ export const WIRING_ROUTE = 'wire it on the chat yes of /agent-workflow-kit upgrade';
19
+ export const WIRING_ROUTE_UNUSED = 'no slot uses it for review — once one does, the chat yes of /agent-workflow-kit upgrade wires it';
20
+ export const SANDBOX_ENABLED_CONDITION = `enabled in the project settings — ready means wired ${HOST_HONORS_QUALIFIER}`;
21
+ export const SANDBOX_NOT_ENABLED_CONDITION = 'not enabled in the project settings — ready means installed';
22
+
23
+ const PROJECT_FILE = 'settings.json';
24
+ const LOCAL_FILE = 'settings.local.json';
25
+ const SETTINGS_DIR = '.claude';
26
+ const UTF8 = 'utf8';
27
+ const ABSENT = 'ENOENT';
28
+ const NOT_REGULAR = 'not a regular file';
29
+ const REVIEW = 'review';
30
+ const COVERED = 'covered';
31
+ const NOT_COVERED = 'not covered';
32
+ const ENABLED = 'enabled';
33
+ const SANDBOX = 'sandbox';
34
+ const SURFACE_KEYS = Object.freeze(['excludedCommands', 'hosts', 'dirs']);
35
+
36
+ const isPlainObject = (value) => value !== null && typeof value === 'object'
37
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
38
+
39
+ const assertObject = (value, key) => {
40
+ if (!isPlainObject(value)) throw new Error(`${key} must be a plain JSON object`);
41
+ return value;
42
+ };
43
+
44
+ const sandboxOf = (data, file) => {
45
+ if (data === undefined) return undefined;
46
+ assertObject(data, file);
47
+ if (!Object.hasOwn(data, SANDBOX)) return undefined;
48
+ return assertObject(data.sandbox, `${file}: sandbox`);
49
+ };
50
+
51
+ export const sandboxEnabled = (projectData, localData) => {
52
+ const project = sandboxOf(projectData, PROJECT_FILE);
53
+ const local = sandboxOf(localData, LOCAL_FILE);
54
+ const useLocal = local !== undefined && Object.hasOwn(local, ENABLED);
55
+ const judged = useLocal ? local : project;
56
+ const file = useLocal ? LOCAL_FILE : PROJECT_FILE;
57
+ if (judged === undefined || !Object.hasOwn(judged, ENABLED)) return false;
58
+ if (typeof judged.enabled !== 'boolean') throw new Error(`${file}: enabled must be a boolean`);
59
+ return judged.enabled === true;
60
+ };
61
+
62
+ const readObjectKey = (object, key) => {
63
+ if (!Object.hasOwn(object, key)) return {};
64
+ return assertObject(object[key], key);
65
+ };
66
+
67
+ const readArrayKey = (object, key) => {
68
+ if (!Object.hasOwn(object, key)) return [];
69
+ if (!Array.isArray(object[key])) throw new Error(`${key} must be an array`);
70
+ return object[key];
71
+ };
72
+
73
+ export const sandboxSurfaceDelta = (recipe, sandbox, { home, root }) => {
74
+ const base = sandbox === undefined ? {} : assertObject(sandbox, SANDBOX);
75
+ const exclusions = readArrayKey(base, 'excludedCommands');
76
+ const network = readObjectKey(base, 'network');
77
+ const filesystem = readObjectKey(base, 'filesystem');
78
+ const hosts = readArrayKey(network, 'allowedDomains');
79
+ const allowWrite = readArrayKey(filesystem, 'allowWrite');
80
+ if (allowWrite.some((entry) => typeof entry !== 'string' || entry.trim().length === 0)) {
81
+ throw new Error('allowWrite entries must be non-blank strings');
82
+ }
83
+ return {
84
+ excludedCommands: recipe.excludedCommands.filter((entry) => !exclusions.includes(entry)),
85
+ hosts: recipe.hosts.filter((entry) => !hosts.includes(entry)),
86
+ dirs: missingStateDirs(recipe.dirs, allowWrite, { home, root }),
87
+ };
88
+ };
89
+
90
+ const emptyDelta = () => ({ excludedCommands: [], hosts: [], dirs: [] });
91
+ const countDelta = (delta) => SURFACE_KEYS.reduce((total, key) => total + delta[key].length, 0);
92
+ const unionDeltas = (deltas) => Object.fromEntries(SURFACE_KEYS.map((key) => [
93
+ key, [...new Set(deltas.flatMap((delta) => delta[key]))],
94
+ ]));
95
+
96
+ // One non-blocking descriptor for the type check and the read: a FIFO swapped in at the path never blocks.
97
+ const readRegular = (path) => {
98
+ const descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
99
+ try {
100
+ if (!fstatSync(descriptor).isFile()) throw new Error(NOT_REGULAR);
101
+ return readFileSync(descriptor, UTF8);
102
+ } finally {
103
+ closeSync(descriptor);
104
+ }
105
+ };
106
+
107
+ const readSettings = (root, file) => {
108
+ const path = join(root, SETTINGS_DIR, file);
109
+ const raw = (() => {
110
+ try {
111
+ return readRegular(path);
112
+ } catch (error) {
113
+ if (error?.code === ABSENT) return undefined;
114
+ throw new Error(`${file}: ${error?.message ?? error}`);
115
+ }
116
+ })();
117
+ if (raw === undefined) return undefined;
118
+ try {
119
+ return JSON.parse(raw);
120
+ } catch (error) {
121
+ throw new Error(`${file}: ${error?.message ?? error}`);
122
+ }
123
+ };
124
+
125
+ const baseEntry = (bridge) => ({
126
+ bridge,
127
+ roles: [],
128
+ used: false,
129
+ state: WIRED,
130
+ review: COVERED,
131
+ missing: emptyDelta(),
132
+ count: 0,
133
+ reason: null,
134
+ route: null,
135
+ reviewRoute: null,
136
+ });
137
+
138
+ const judgeBridge = (bridge, used, sandbox, context, deps) => {
139
+ const roles = used.find((entry) => entry.bridge === bridge)?.roles ?? [];
140
+ const judgedRoles = roles.length === 0 ? [REVIEW] : roles;
141
+ const deltas = new Map([...new Set([...judgedRoles, REVIEW])].map((role) => {
142
+ const recipe = bundledSandboxRecipe([{ bridge, roles: [role] }], deps);
143
+ if (recipe.excludedCommands.length === 0) {
144
+ throw new Error(`bundled manifest ${join(bridge, 'capability.json')} missing or lacking roles.${role}.cmd`);
145
+ }
146
+ const dirs = stateDirsOf(recipe.dirEntries, context);
147
+ return [role, sandboxSurfaceDelta({ ...recipe, dirs }, sandbox, context)];
148
+ }));
149
+ const missing = unionDeltas(judgedRoles.map((role) => deltas.get(role)));
150
+ const count = countDelta(missing);
151
+ const isUsed = roles.length !== 0;
152
+ const reviewMissing = countDelta(deltas.get(REVIEW)) !== 0;
153
+ return {
154
+ ...baseEntry(bridge),
155
+ roles,
156
+ used: isUsed,
157
+ state: count === 0 ? WIRED : UNWIRED,
158
+ review: reviewMissing ? NOT_COVERED : COVERED,
159
+ missing,
160
+ count,
161
+ route: count === 0 ? null : (isUsed ? WIRING_ROUTE : WIRING_ROUTE_UNUSED),
162
+ reviewRoute: reviewMissing ? (roles.includes(REVIEW) ? WIRING_ROUTE : WIRING_ROUTE_UNUSED) : null,
163
+ };
164
+ };
165
+
166
+ export const composeBridgeWiring = ({ root, readiness }, deps = {}) => {
167
+ const ready = BACKEND_PRIORITY.filter((bridge) => readiness.some((row) => row.name === bridge && row.readiness === READY));
168
+ try {
169
+ const project = readSettings(root, PROJECT_FILE);
170
+ const local = readSettings(root, LOCAL_FILE);
171
+ if (!sandboxEnabled(project, local)) {
172
+ return {
173
+ enabled: false,
174
+ reason: null,
175
+ condition: SANDBOX_NOT_ENABLED_CONDITION,
176
+ bridges: ready.map(baseEntry),
177
+ };
178
+ }
179
+ if (deps.preflight) deps.preflight();
180
+ const config = (deps.loadConfig ?? loadConfig)(root).config;
181
+ const used = usedBridges(config, readiness);
182
+ const context = { root, env: deps.env ?? process.env, home: deps.home ?? homedir() };
183
+ const bridges = ready.map((bridge) => judgeBridge(bridge, used, project?.sandbox, context, deps));
184
+ return { enabled: true, reason: null, condition: SANDBOX_ENABLED_CONDITION, bridges };
185
+ } catch (error) {
186
+ const reason = String(error?.message ?? error).replace(/[\r\n]+/g, ' ');
187
+ return {
188
+ enabled: null,
189
+ reason,
190
+ condition: `${UNCHECKED} — ${reason}`,
191
+ bridges: ready.map((bridge) => ({
192
+ ...baseEntry(bridge), state: UNCHECKED, review: UNCHECKED, reason,
193
+ })),
194
+ };
195
+ }
196
+ };
@@ -100,20 +100,21 @@ drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (hel
100
100
  `networkHosts` declares the hosts the backend CLI is **observed** to contact (synthetic examples:
101
101
  `*.api.backend.example`, `accounts.backend.example`; the real observed lists live in each bridge's
102
102
  `capability.json`) — the **single source** of the hosts `velocity-profile --bridge-tier` seeds
103
- into the project's `sandbox.network.allowedDomains` for every used bridge, and of a hand-applied
104
- session sandbox allowlist. Rules:
103
+ into the project's `sandbox.network.allowedDomains` for every used bridge. Rules:
105
104
 
106
105
  - Each entry is a bare dotted hostname or a `*.family` wildcard — never a scheme/path/port.
107
- Entries must be unique. A malformed list **fails** `--strict` (the entries are pasted verbatim
108
- into allowlist lines by the Recommendations advisor).
106
+ Entries must be unique. A malformed list **fails** `--strict` (the tier writes the entries into
107
+ `sandbox.network.allowedDomains` and the Recommendations advisor renders them on its
108
+ `bridge-tier` item's `recipe:` line).
109
109
  - **The `--bridge-tier` seed is the primary lane** (AD-172): for every used bridge it merges
110
110
  hosts into the project's `sandbox.network.allowedDomains` beside the wrapper's `<wrapper> *`
111
111
  exclusion (`codex-exec *` for the execution wrapper). A settings-native harness that drops the
112
112
  exclusion still admits them; that pre-allow widens egress
113
- for **every** sandboxed command, which the tier's consent states. The **hand-apply** fallback
114
- stays for a harness-managed sandbox that ignores the settings keys.
113
+ for **every** sandboxed command, which the tier's consent states. A harness-managed sandbox
114
+ that ignores the settings keys keeps the harness's own controls — its scoped rules, a session
115
+ allowance, its consent at the point of action — and the kit writes no shape for it.
115
116
  - Observed-minimal, honestly incomplete: a blocked host names itself at run time — extend the
116
- hand-applied list by hand; the manifest list records what was actually observed.
117
+ manifest list where it was observed; the list records what was actually observed.
117
118
 
118
119
  ## Writable dirs (REC-UX-REWORK, D6)
119
120
 
@@ -124,8 +125,8 @@ declared path is only the **default under no override**. Rules:
124
125
 
125
126
  - Each entry: `env` is `null` or an **UPPER_SNAKE_CASE env-var name**; `default` is a
126
127
  **`~/`-anchored or absolute POSIX path** — no globs, no trailing slash, no `..` traversal.
127
- Defaults must be unique. A malformed list **fails** `--strict` (the resolved dir is rendered
128
- into a hand-applied recipe line by the Recommendations advisor).
128
+ Defaults must be unique. A malformed list **fails** `--strict` (the resolved dir is written into
129
+ `sandbox.filesystem.allowWrite` and rendered on the Recommendations advisor's one-line `recipe:` line).
129
130
  - **Resolution is the advisor's, at run time, mirroring the wrapper's byte-semantics:** a
130
131
  NON-EMPTY env value wins (an EMPTY env value ≡ unset — the `${VAR:-default}` form); the
131
132
  wrapper's exact case-arms apply — `~`, `~/…` and absolute forms ride as-given, EVERY other
@@ -135,8 +136,8 @@ declared path is only the **default under no override**. Rules:
135
136
  (the wrapper itself anchors to its invocation `$PWD`); else the `default` applies.
136
137
  - **The `--bridge-tier` seeds every used bridge's state dirs**, home-relative under the home
137
138
  (absolute outside it), into `sandbox.filesystem.allowWrite` beside the wrapper's `<wrapper> *`
138
- exclusion (`codex-exec *` for the execution wrapper) — the entries feed the
139
- sandbox-lane discoverability item (session/host sandbox config is hand-applied territory).
139
+ exclusion (`codex-exec *` for the execution wrapper) — the advisor's `bridge-tier` item counts
140
+ and names each missing dir.
140
141
 
141
142
  ## Mode catalog (BRIDGE-MODES-CATALOG)
142
143
 
@@ -638,10 +638,10 @@ export const validateManifest = (skillDir) => {
638
638
  }
639
639
 
640
640
  // `networkHosts` (AD-044 Plan 4, consult-locked): the backend CLI's OBSERVED egress host
641
- // families. `velocity-profile --bridge-tier` seeds them into sandbox.network.allowedDomains for
642
- // placed bridges (AD-172; the pre-allow widens egress for EVERY sandboxed command, stated at that
643
- // consent), and a malformed list FAILS --strict like `settings`: the tier writes it and the
644
- // Recommendations advisor renders it verbatim, so a bad entry would corrupt a seeded line.
641
+ // families. `velocity-profile --bridge-tier` seeds them into sandbox.network.allowedDomains
642
+ // for USED bridges (a recipe slot uses them, revision 4; AD-172); the pre-allow widens egress for EVERY
643
+ // sandboxed command, stated at that consent. A malformed list FAILS --strict like `settings`:
644
+ // the tier writes it and the Recommendations advisor renders it verbatim, so a bad entry would corrupt a seeded line.
645
645
  const networkHosts = manifest.networkHosts;
646
646
  if (networkHosts != null) {
647
647
  if (!Array.isArray(networkHosts) || networkHosts.length === 0) {
@@ -659,11 +659,11 @@ export const validateManifest = (skillDir) => {
659
659
  }
660
660
  }
661
661
 
662
- // `writableDirs` (REC-UX-REWORK, D6): the backend CLI's writable state-dir declarations —
663
- // {env, default} entries the Recommendations advisor RESOLVES at run time (a NON-EMPTY env
664
- // value wins, else the default) and renders into the sandbox-lane recipe. Unlike `networkHosts`
665
- // this is a DOCUMENTATION source (the kit never seeds filesystem allowances), and a malformed
666
- // list FAILS --strict for the same reason: the resolved dir is rendered into a hand-applied line.
662
+ // `writableDirs` (REC-UX-REWORK, D6): {env, default} entries the tier RESOLVES (a NON-EMPTY
663
+ // env value wins, else the default) and seeds home-relative into sandbox.filesystem.allowWrite
664
+ // for USED bridges via `bridge-state-dirs.mjs`; the advisor's `bridge-tier` item counts and names them.
665
+ // A malformed list FAILS --strict: the resolved dir is written into the settings and rendered
666
+ // on the Recommendations advisor's ONE-LINE `recipe:` line.
667
667
  const writableDirs = manifest.writableDirs;
668
668
  if (writableDirs != null) {
669
669
  if (!Array.isArray(writableDirs) || writableDirs.length === 0) {
@@ -685,8 +685,8 @@ export const validateManifest = (skillDir) => {
685
685
  return;
686
686
  }
687
687
  if (/[*?[\]]/.test(dir)) errors.push(`${at}.default must not carry glob characters ("${dir}")`);
688
- // The resolved dir is rendered into a ONE-LINE hand-applied recipe — a control character
689
- // (newline, CR, NUL, …) would break the line or the shell paste.
688
+ // The resolved dir is written into `allowWrite` and rendered on a ONE-LINE advisor line —
689
+ // a control character (newline, CR, NUL, …) would break that line or the settings entry.
690
690
  // eslint-disable-next-line no-control-regex
691
691
  if (/[\x00-\x1f\x7f]/.test(dir)) errors.push(`${at}.default must not carry control characters (${JSON.stringify(dir)})`);
692
692
  if (dir.endsWith('/')) errors.push(`${at}.default must not end with a trailing slash ("${dir}")`);
package/tools/recipes.mjs CHANGED
@@ -40,6 +40,12 @@ export { ACTIVITIES, POLICY_ACTIVITIES, SLOT_RECIPES, isSwitchSlot, EXECUTOR_APP
40
40
 
41
41
  const [CODEX, AGY] = BACKEND_PRIORITY;
42
42
 
43
+ // Mirror bridge-wiring.mjs states: a static import would cycle through bridge-sandbox-recipe.mjs.
44
+ const WIRED = 'wired';
45
+ const UNWIRED = 'not wired';
46
+ const UNCHECKED = 'unchecked';
47
+ const NOT_COVERED = 'not covered';
48
+
43
49
  // Keyed by readiness-array provider name; executor is the only `carry` provider.
44
50
  export const BACKEND_ROLES = {
45
51
  [CODEX]: ['execute', 'review'],
@@ -237,6 +243,20 @@ export const recommendRecipe = (detection) => {
237
243
  return { recipe: 'solo', clause: `Solo — ${DISPLAY_ALIASES[best.name] ?? best.name}: ${remedy} to unlock Reviewed` };
238
244
  };
239
245
 
246
+ const orderedWiringEntries = (wiring) => [...(wiring?.bridges ?? [])]
247
+ .sort((a, b) => priorityIndex(a.bridge) - priorityIndex(b.bridge));
248
+
249
+ export const recommendWiredRecipe = (detection, wiring) => {
250
+ const base = recommendRecipe(detection);
251
+ const entries = orderedWiringEntries(wiring);
252
+ const uncovered = entries.filter((entry) => entry.review === NOT_COVERED)
253
+ .map((entry) => `${DISPLAY_ALIASES[entry.bridge] ?? entry.bridge} (review) — ${entry.reviewRoute}`).join('; ');
254
+ const unchecked = entries.filter((entry) => entry.review === UNCHECKED)
255
+ .map((entry) => DISPLAY_ALIASES[entry.bridge] ?? entry.bridge).join(', ');
256
+ const tail = (uncovered ? ` · ${UNWIRED}: ${uncovered}` : '') + (unchecked ? ` · ${UNCHECKED}: ${unchecked}` : '');
257
+ return tail ? { recipe: base.recipe, clause: base.clause + tail } : base;
258
+ };
259
+
240
260
  // ── activity procedures: per-slot recipe resolution ────────────────────────────────
241
261
  // The computed default for a silent slot. NEVER Council (opt-in: it spends two backends' quota) and
242
262
  // deliberately not recommendRecipe, which drives the status line rather than a per-slot default.
@@ -317,14 +337,26 @@ export const composeConfiguredPosture = (ctx = {}) => {
317
337
  // mode, and only the recipe lattice sees it.
318
338
  export const bridgeEntries = (readiness) => readiness.filter((b) => b.name !== EXECUTOR_PROVIDER);
319
339
 
320
- export const composeStatusLine = (detection, recommendation, settings = null, autonomy = null, posture = null) => {
340
+ export const composeStatusLine = (detection, recommendation, settings = null, autonomy = null, posture = null, wiring = null) => {
341
+ // A raw env value may carry newlines/control chars — collapse them so the one-line contract holds.
342
+ const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
321
343
  const backends = bridgeEntries(detection)
322
344
  .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
323
- .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
345
+ .map((b) => {
346
+ const alias = DISPLAY_ALIASES[b.name] ?? b.name;
347
+ const entry = b.readiness === READY ? wiring?.bridges.find((row) => row.bridge === b.name) : null;
348
+ if (entry && entry.state !== WIRED) {
349
+ if (entry.state === UNWIRED) {
350
+ return `${alias} ✗ ${UNWIRED}${entry.used ? '' : ' (unused)'} — ${entry.count} sandbox entr(ies) missing`;
351
+ }
352
+ if (entry.state === UNCHECKED) return `${alias} ✗ ${UNCHECKED} — ${oneLine(entry.reason)}`;
353
+ }
354
+ return `${alias} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`;
355
+ })
324
356
  .join(' · ');
325
- const base = `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
326
- // A raw env value may carry newlines/control chars — collapse them so the one-line contract holds.
327
- const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
357
+ const clause = wiring == null ? recommendation.clause : recommendWiredRecipe(detection, wiring).clause;
358
+ const base = `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${clause} — see /agent-workflow-kit recipes`;
359
+ const sandboxSegment = wiring == null ? '' : ` · sandbox: ${oneLine(wiring.condition)}`;
328
360
  const active = settings?.active ?? [];
329
361
  // A RETIRED knob renders as retired: hiding the user's line would be a silent deletion, reading it
330
362
  // as active would claim a capability the wrapper no longer has.
@@ -332,7 +364,7 @@ export const composeStatusLine = (detection, recommendation, settings = null, au
332
364
  // Each optional segment renders ONLY when its facts are supplied; an omitted param changes nothing.
333
365
  const autonomySegment = autonomy == null ? '' : ` · autonomy: ${oneLine(formatAutonomySegment(autonomy))}`;
334
366
  const postureSegment = posture == null ? '' : ` · posture: ${oneLine(posture)}`;
335
- return base + suffix + autonomySegment + postureSegment;
367
+ return base + sandboxSegment + suffix + autonomySegment + postureSegment;
336
368
  };
337
369
 
338
370
  const formatAutonomySegment = (a) => {
@@ -432,7 +464,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection, auto
432
464
  // ── report + CLI ─────────────────────────────────────────────────────────────────
433
465
 
434
466
  // The structured report behind `--json`, incl. the same one-line status the --status-line mode emits.
435
- export const buildReport = (detection, settings = null, autonomy = null, posture = null) => {
467
+ export const buildReport = (detection, settings = null, autonomy = null, posture = null, wiring = null) => {
436
468
  const recommendation = recommendRecipe(detection);
437
469
  return {
438
470
  recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
@@ -446,19 +478,31 @@ export const buildReport = (detection, settings = null, autonomy = null, posture
446
478
  recommendation,
447
479
  plans: RECIPES.map((r) => planRecipe(r.id, detection)),
448
480
  // The --json envelope must never expose a status line staler than the --status-line surface.
449
- statusLine: composeStatusLine(detection, recommendation, settings, autonomy, posture),
481
+ statusLine: composeStatusLine(detection, recommendation, settings, autonomy, posture, wiring),
482
+ ...(wiring == null ? {} : { wiring }),
450
483
  };
451
484
  };
452
485
 
453
486
  // Deterministic human advisor text: the recipes, the recommendation, and the per-recipe plan here.
454
- export const formatRecipes = (detection) => {
487
+ export const formatRecipes = (detection, wiring = null) => {
455
488
  const lines = [
456
489
  'agent-workflow orchestration recipes (read-only — the orchestrator executes via the bridge skills or the executor vehicle and always commits)',
457
490
  '',
458
491
  ];
459
492
  for (const r of RECIPES) lines.push(` ${r.title} (${r.id}) — ${r.summary}`);
460
- const rec = recommendRecipe(detection);
461
- lines.push('', `recommended here: ${rec.recipe} — ${rec.clause}`, '', 'plan for the current environment:');
493
+ const rec = wiring == null ? recommendRecipe(detection) : recommendWiredRecipe(detection, wiring);
494
+ lines.push('', `recommended here: ${rec.recipe} — ${rec.clause}`);
495
+ for (const entry of orderedWiringEntries(wiring)) {
496
+ const alias = DISPLAY_ALIASES[entry.bridge] ?? entry.bridge;
497
+ if (entry.state === UNWIRED) {
498
+ const missing = [['excludedCommands', entry.missing.excludedCommands], ['allowedDomains', entry.missing.hosts], ['allowWrite', entry.missing.dirs]]
499
+ .filter(([, entries]) => entries.length)
500
+ .map(([key, entries]) => `${key} [${entries.join(', ')}]`).join('; ');
501
+ lines.push(` ${UNWIRED}: ${alias} — missing ${missing}; ${wiring.condition}; ${entry.route}`);
502
+ }
503
+ if (entry.state === UNCHECKED) lines.push(` ${UNCHECKED}: ${alias} — ${entry.reason}`);
504
+ }
505
+ lines.push('', 'plan for the current environment:');
462
506
  for (const r of RECIPES) {
463
507
  const p = planRecipe(r.id, detection);
464
508
  const arrow = p.degraded ? ` → ${p.effective}` : '';
@@ -493,6 +537,15 @@ export const composeReadiness = (cwd, deps = {}) => {
493
537
  return withVehicle(detection, survey);
494
538
  };
495
539
 
540
+ // The wiring the status line, --json and the default output render. Lazy imports: the leaf reaches
541
+ // this module through bridge-sandbox-recipe.mjs. A failed import fails the run loudly.
542
+ const composeWiring = async (cwd, readiness) => {
543
+ const { composeBridgeWiring } = await import('./bridge-wiring.mjs');
544
+ const { preflightVelocityProfile } = await import('./velocity-profile.mjs');
545
+ const root = projectTopOf(cwd);
546
+ return composeBridgeWiring({ root, readiness }, { preflight: () => preflightVelocityProfile({ cwd: root }) });
547
+ };
548
+
496
549
  const main = async (argv, deps = {}) => {
497
550
  if (argv.includes('--help') || argv.includes('-h')) {
498
551
  console.log(`recipes — read-only orchestration-recipe advisor for the agent-workflow family.
@@ -510,7 +563,13 @@ CONFIGURED recipe per activity/slot, resolved from the per-project docs/ai/orche
510
563
  from the current directory) + live readiness, with degradation stated and each activity's autonomy
511
564
  level beside its cells; paste it verbatim at session start / into the handover "Active recipes:" slot.
512
565
  --json emits the structured report (incl. the same line as \`statusLine\`); the three are mutually
513
- exclusive. Detection only — never writes, never commits, never runs a subscription CLI.`);
566
+ exclusive. Detection only — never writes, never commits, never runs a subscription CLI.
567
+
568
+ Where the project settings enable the sandbox, a ready bridge whose sandbox entries they lack
569
+ reads "<alias> ✗ not wired — <n> sandbox entr(ies) missing" ("not wired (unused)" when no slot
570
+ uses it), one that cannot be judged "<alias> ✗ unchecked — <reason>"; the status line carries
571
+ " · sandbox: <condition>" after the recipes pointer, and the default output lists the missing
572
+ entries and the route per bridge.`);
514
573
  return;
515
574
  }
516
575
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
@@ -525,6 +584,7 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
525
584
  }
526
585
  const cwd = process.cwd();
527
586
  const readiness = composeReadiness(cwd, deps);
587
+ const wiring = argv.includes('--active-line') ? null : await composeWiring(cwd, readiness);
528
588
  if (argv.includes('--active-line')) {
529
589
  // Lazy: orchestration-config.mjs statically imports this module — no static cycle.
530
590
  const { loadConfig } = await import('./orchestration-config.mjs');
@@ -541,12 +601,12 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
541
601
  }
542
602
  } else if (argv.includes('--status-line')) {
543
603
  const snapshot = settingsSnapshot();
544
- console.log(composeStatusLine(readiness, recommendRecipe(readiness), snapshot, await composeAutonomyFacts(cwd), composeConfiguredPosture({ settings: snapshot })));
604
+ console.log(composeStatusLine(readiness, recommendRecipe(readiness), snapshot, await composeAutonomyFacts(cwd), composeConfiguredPosture({ settings: snapshot }), wiring));
545
605
  } else if (argv.includes('--json')) {
546
606
  const snapshot = settingsSnapshot();
547
- console.log(JSON.stringify(buildReport(readiness, snapshot, await composeAutonomyFacts(cwd), composeConfiguredPosture({ settings: snapshot })), null, 2));
607
+ console.log(JSON.stringify(buildReport(readiness, snapshot, await composeAutonomyFacts(cwd), composeConfiguredPosture({ settings: snapshot }), wiring), null, 2));
548
608
  }
549
- else console.log(formatRecipes(readiness));
609
+ else console.log(formatRecipes(readiness, wiring));
550
610
  return 0;
551
611
  };
552
612