@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.
- package/CHANGELOG.md +35 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/recipes.md +2 -0
- package/references/modes/recommendations.md +8 -9
- package/references/modes/set-recipe.md +1 -1
- package/references/modes/velocity.md +2 -3
- package/references/shared/report-footer.md +29 -32
- package/tools/ack-write.mjs +11 -11
- package/tools/bridge-sandbox-recipe.mjs +1 -1
- package/tools/bridge-wiring.mjs +196 -0
- package/tools/manifest/schema.md +12 -11
- package/tools/manifest/validate.mjs +11 -11
- package/tools/recipes.mjs +75 -15
- package/tools/recommendations.mjs +43 -89
- package/tools/velocity-profile.mjs +26 -13
- package/tools/write-lanes.mjs +0 -1
|
@@ -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
|
+
};
|
package/tools/manifest/schema.md
CHANGED
|
@@ -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
|
|
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
|
|
108
|
-
|
|
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.
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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
|
|
139
|
-
|
|
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
|
|
642
|
-
//
|
|
643
|
-
//
|
|
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
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
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
|
|
689
|
-
// (newline, CR, NUL, …) would break
|
|
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) =>
|
|
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
|
|
326
|
-
|
|
327
|
-
const
|
|
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}
|
|
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
|
|