@sabaiway/agent-workflow-kit 1.45.0 → 1.46.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 +59 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
- package/bridges/antigravity-cli-bridge/capability.json +2 -1
- package/bridges/codex-cli-bridge/SKILL.md +7 -7
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -3
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +4 -4
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bridge-settings.md +1 -1
- package/references/modes/recommendations.md +16 -5
- package/references/modes/upgrade.md +2 -2
- package/references/modes/velocity.md +1 -1
- package/references/templates/agent_rules.md +1 -1
- package/tools/doc-parity.mjs +16 -4
- package/tools/manifest/schema.md +29 -2
- package/tools/manifest/validate.mjs +38 -0
- package/tools/procedures.mjs +2 -0
- package/tools/recommendations.mjs +279 -79
|
@@ -6,19 +6,23 @@
|
|
|
6
6
|
// bridge allowlist, autonomy render drifted, sandbox not provisioned, gates undeclared) — every
|
|
7
7
|
// `upgrade` run therefore ends with a mandatory, deterministic Recommendations section: what is
|
|
8
8
|
// sub-optimal · the benefit in ONE plain line · the exact consent-gated apply one-liner. The agent
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// PRESENTS the section in the user's conversational language — every fact, count and item from
|
|
10
|
+
// the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; the raw
|
|
11
|
+
// tool block shown on request (the AD-032 report-contract lane). The user picks items in plain
|
|
12
|
+
// language; after the per-item consent moment the agent runs EXACTLY the rendered one-liners — no
|
|
13
|
+
// improvisation, each writer's own consent semantics intact.
|
|
12
14
|
//
|
|
13
15
|
// Contract:
|
|
14
16
|
// node recommendations.mjs --cwd <project-root> [--json]
|
|
15
17
|
// --cwd is REQUIRED (subdir-proof: the target project is explicit, never inferred from the shell's
|
|
16
18
|
// current directory). The section renders PRESENT-EVEN-WHEN-EMPTY (the exact empty-state line
|
|
17
|
-
// below)
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// item. The
|
|
21
|
-
// backends concur): the kit never seeds
|
|
19
|
+
// below) and VERDICT-FIRST (D1): every non-optimal state opens with ONE composed verdict line.
|
|
20
|
+
// Registry strings are frozen tool DATA, fact-true, one line under the shape cap (D2); posture/
|
|
21
|
+
// risk prose lives in the mode doc at the consent moment (D3). A probe failure is a stated
|
|
22
|
+
// skipped-item line — never a crash, never a fabricated item. The sandbox-lane item is HAND-APPLY
|
|
23
|
+
// by design (bridge council 2026-07-11, both backends concur): the kit never seeds
|
|
24
|
+
// sandbox.network.allowedDomains / filesystem.allowWrite; its convergence is a NEUTRAL
|
|
25
|
+
// fingerprint-bound acknowledgement, never a security key (D4).
|
|
22
26
|
//
|
|
23
27
|
// Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
|
|
24
28
|
// exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
|
|
@@ -26,6 +30,8 @@
|
|
|
26
30
|
// Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
27
31
|
|
|
28
32
|
import { readFileSync, readdirSync, lstatSync } from 'node:fs';
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { homedir } from 'node:os';
|
|
29
35
|
import { dirname, join, resolve } from 'node:path';
|
|
30
36
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
37
|
import {
|
|
@@ -60,24 +66,141 @@ export const RECOMMENDATIONS_EMPTY_LINE = 'no recommendations — flow optimal.'
|
|
|
60
66
|
// The one dual-wording security clause — rides ONLY the items with a real security delta.
|
|
61
67
|
export const DUAL_SECURITY_BENEFIT = 'safer — blast radius bounded by the OS sandbox, not human attention';
|
|
62
68
|
|
|
69
|
+
// ── the verdict-first contract (D1, REC-UX-REWORK) ──────────────────────────────────────────────
|
|
70
|
+
// The optimal state (no items, no skips) renders the frozen empty-state line ALONE — byte-identical
|
|
71
|
+
// to the pre-verdict contract. Every other state opens the body with ONE verdict line composed from
|
|
72
|
+
// these frozen templates ({X}-style placeholders; the "(s)" invariant form IS the pinned
|
|
73
|
+
// pluralization rule — no singular/plural branching). The templates are English tool DATA
|
|
74
|
+
// (doc-parity-bound in both mode docs); user-language rendering is the agent's presentation layer.
|
|
75
|
+
export const VERDICT_ATTENTION_TEMPLATE = '{K} item(s) need attention';
|
|
76
|
+
export const VERDICT_NOTHING_BROKEN = 'nothing is broken';
|
|
77
|
+
export const VERDICT_OPTIONAL_TEMPLATE = '{N} optional recommendation(s), apply any you want';
|
|
78
|
+
export const VERDICT_SKIPS_TEMPLATE = 'optimality NOT attested — {M} probe check(s) skipped';
|
|
79
|
+
|
|
80
|
+
// ── the frozen severity registry (D1; pinned by tests) ─────────────────────────────────────────
|
|
81
|
+
// `attention` — the item reports a CONFIGURED declaration that is broken, drifted, degrading or
|
|
82
|
+
// invalid (the deployment needs review); `optional` — an offer to enable an unconfigured
|
|
83
|
+
// capability. One class per key, frozen registry data; a `<key>.<variant>` entry classes a
|
|
84
|
+
// per-site arm whose semantics differ from its base (the invalid-env arm reports an INVALID
|
|
85
|
+
// configured value — attention — while the unset arm stays an offer).
|
|
86
|
+
export const SEVERITY_ATTENTION = 'attention';
|
|
87
|
+
export const SEVERITY_OPTIONAL = 'optional';
|
|
88
|
+
export const SEVERITIES = Object.freeze({
|
|
89
|
+
'velocity-core': SEVERITY_OPTIONAL,
|
|
90
|
+
'kit-tools-tier': SEVERITY_OPTIONAL,
|
|
91
|
+
'bridge-tier': SEVERITY_OPTIONAL,
|
|
92
|
+
'autonomy-policy': SEVERITY_OPTIONAL,
|
|
93
|
+
'autonomy-render': SEVERITY_ATTENTION,
|
|
94
|
+
'sandbox-provision': SEVERITY_OPTIONAL,
|
|
95
|
+
'review-recipe': SEVERITY_ATTENTION,
|
|
96
|
+
'gates-declaration': SEVERITY_OPTIONAL,
|
|
97
|
+
'gate-hook': SEVERITY_OPTIONAL,
|
|
98
|
+
'family-freshness': SEVERITY_ATTENTION,
|
|
99
|
+
'sandbox-masks': SEVERITY_OPTIONAL,
|
|
100
|
+
'agy-adddir': SEVERITY_OPTIONAL,
|
|
101
|
+
'agy-adddir.invalid-env': SEVERITY_ATTENTION,
|
|
102
|
+
'sandbox-lane': SEVERITY_OPTIONAL,
|
|
103
|
+
});
|
|
104
|
+
// The per-item render tags (frozen presentation data, same language contract as the templates).
|
|
105
|
+
export const SEVERITY_LABELS = Object.freeze({
|
|
106
|
+
[SEVERITY_ATTENTION]: 'needs attention',
|
|
107
|
+
[SEVERITY_OPTIONAL]: 'optional',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// {X}-style template fill (D1/D2): every placeholder must be supplied — a miss is a programming
|
|
111
|
+
// error that surfaces through the probe's stated-skip lane, never a rendered "{K}".
|
|
112
|
+
const fillTemplate = (template, values) => template.replace(/\{([A-Za-z]+)\}/g, (_, name) => {
|
|
113
|
+
if (!(name in values)) throw new Error(`unfilled template placeholder {${name}}`);
|
|
114
|
+
return String(values[name]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// composeVerdict(counts) → the ONE verdict line, or null for the optimal state (D1 state matrix).
|
|
118
|
+
// attention>0 leads; the "nothing is broken" wording renders ONLY when attention==0 AND skipped==0
|
|
119
|
+
// (a skipped probe could hide an attention-class problem, so the claim would overreach; it renders
|
|
120
|
+
// only as the lead-in to the optional offer, never in a skips-only state); the skips part is
|
|
121
|
+
// appended last.
|
|
122
|
+
export const composeVerdict = ({ attention, optional, skipped }) => {
|
|
123
|
+
if (attention === 0 && optional === 0 && skipped === 0) return null;
|
|
124
|
+
const parts = [];
|
|
125
|
+
if (attention > 0) parts.push(fillTemplate(VERDICT_ATTENTION_TEMPLATE, { K: attention }));
|
|
126
|
+
if (optional > 0) {
|
|
127
|
+
const offer = fillTemplate(VERDICT_OPTIONAL_TEMPLATE, { N: optional });
|
|
128
|
+
parts.push(attention === 0 && skipped === 0 ? `${VERDICT_NOTHING_BROKEN} — ${offer}` : offer);
|
|
129
|
+
}
|
|
130
|
+
if (skipped > 0) parts.push(fillTemplate(VERDICT_SKIPS_TEMPLATE, { M: skipped }));
|
|
131
|
+
return parts.join('; ');
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── the frozen WHAT-template registry (D2; pinned by the static shape test) ─────────────────────
|
|
135
|
+
// Every static WHAT template lives here — `<key>` is the item key, `<key>.<variant>` a per-site
|
|
136
|
+
// variant of the same item — so ALL variants are assertable at build time (single line, char cap,
|
|
137
|
+
// banned tokens), never a fixture-coverage gamble. A pure-placeholder template marks a WHAT whose
|
|
138
|
+
// content is fully dynamic (capped at composition by truncation-with-count).
|
|
139
|
+
export const WHATS = Object.freeze({
|
|
140
|
+
'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
|
|
141
|
+
'kit-tools-tier': "the kit's own read-only tools still prompt — {n} kit-tools tier entr(ies) not seeded",
|
|
142
|
+
'bridge-tier': 'council review runs prompt per bridge invocation — {n} bridge-wrappers tier entr(ies) not seeded (placed bridges only, code mode only)',
|
|
143
|
+
'autonomy-policy': 'no {path} — the computed defaults apply implicitly (red-lines ask/deny; every activity floors at prompt)',
|
|
144
|
+
'autonomy-render': 'the declared autonomy policy is not rendered into .claude/settings.json — drift: {drift}',
|
|
145
|
+
'sandbox-provision': 'the OS sandbox is unavailable: {reason}',
|
|
146
|
+
'sandbox-provision.installable': 'the OS sandbox is unavailable: {reason} — installable via the doctor (consent tuple {tuple})',
|
|
147
|
+
'review-recipe': '{degraded}',
|
|
148
|
+
'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
|
|
149
|
+
'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
|
|
150
|
+
'family-freshness': '{parts}',
|
|
151
|
+
'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
|
|
152
|
+
'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
|
|
153
|
+
'agy-adddir': 'agy-review is placed but AGY_REVIEW_ALLOW_ADDDIR is not set ({file}) — an oversized code review refuses instead of offloading',
|
|
154
|
+
'agy-adddir.invalid-env': 'AGY_REVIEW_ALLOW_ADDDIR is set to an INVALID value ({value}) — refuse-mode applies and the settings file is shadowed while it is set',
|
|
155
|
+
'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── the shape contract (D2): registry strings AND composed items stay one line under the cap ────
|
|
159
|
+
export const ITEM_LINE_CAP = 140;
|
|
160
|
+
export const SKIP_REASON_CAP = 200;
|
|
161
|
+
|
|
162
|
+
const oneLineOf = (text) => String(text).replace(/\s*[\r\n]+\s*/g, ' ').trim();
|
|
163
|
+
// Scalar truncation-with-count — a capped value states what it dropped, never a silent cut.
|
|
164
|
+
// GUARANTEED result.length <= cap for every input/budget: when even the count note cannot fit
|
|
165
|
+
// the budget, the tail arm hard-slices to a bare ellipsis instead of overflowing.
|
|
166
|
+
const truncatedTo = (text, cap) => {
|
|
167
|
+
if (text.length <= cap) return text;
|
|
168
|
+
const note = (dropped) => `… (+${dropped} more chars)`;
|
|
169
|
+
let keep = cap;
|
|
170
|
+
while (keep > 0 && keep + note(text.length - keep).length > cap) keep -= 1;
|
|
171
|
+
if (keep === 0) return cap <= 0 ? '' : `${text.slice(0, cap - 1)}…`;
|
|
172
|
+
return text.slice(0, keep) + note(text.length - keep);
|
|
173
|
+
};
|
|
174
|
+
// List truncation-with-count: whole leading entries + " (+N more)" for the dropped tail; if even
|
|
175
|
+
// the first entry overflows, it is scalar-truncated so the count survives.
|
|
176
|
+
const capList = (entries, budget, sep = '; ') => {
|
|
177
|
+
for (let take = entries.length; take >= 1; take -= 1) {
|
|
178
|
+
const joined = entries.slice(0, take).join(sep);
|
|
179
|
+
const tail = take < entries.length ? ` (+${entries.length - take} more)` : '';
|
|
180
|
+
if (joined.length + tail.length <= budget) return joined + tail;
|
|
181
|
+
}
|
|
182
|
+
const tail = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
|
183
|
+
return truncatedTo(entries[0], Math.max(0, budget - tail.length)) + tail;
|
|
184
|
+
};
|
|
185
|
+
// The char budget a template leaves for its placeholder values.
|
|
186
|
+
const templateBudget = (template) => ITEM_LINE_CAP - template.replace(/\{[A-Za-z]+\}/g, '').length;
|
|
187
|
+
|
|
63
188
|
// ── the frozen benefit registry (fact-true; pinned by tests) ────────────────────────────────────
|
|
64
189
|
export const BENEFITS = Object.freeze({
|
|
65
190
|
'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away',
|
|
66
191
|
'kit-tools-tier': "velocity — the kit's own read-only tools run promptless (audited, resolved-absolute tier)",
|
|
67
192
|
'bridge-tier':
|
|
68
|
-
'velocity —
|
|
193
|
+
'velocity — placed review wrappers run code-mode council reviews promptless (plan/diff modes and delegated execution keep their prompt)',
|
|
69
194
|
'autonomy-policy': 'clarity — the per-activity autonomy policy becomes an explicit, versioned declaration instead of implicit computed defaults',
|
|
70
|
-
'autonomy-render': `velocity —
|
|
71
|
-
'sandbox-provision': `velocity — confined ad-hoc commands stop prompting
|
|
195
|
+
'autonomy-render': `velocity — confined commands auto-allow per your declared policy; ${DUAL_SECURITY_BENEFIT}`,
|
|
196
|
+
'sandbox-provision': `velocity — confined ad-hoc commands stop prompting; ${DUAL_SECURITY_BENEFIT}`,
|
|
72
197
|
'review-recipe': 'review coverage — the review recipe you configured actually runs instead of silently degrading',
|
|
73
198
|
'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
|
|
74
199
|
'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
|
|
75
200
|
'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
|
|
76
201
|
'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
|
|
77
|
-
'agy-adddir':
|
|
78
|
-
|
|
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)',
|
|
202
|
+
'agy-adddir': 'large reviews — an oversized agy code review offloads to a staging dir instead of refusing',
|
|
203
|
+
'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
|
|
81
204
|
});
|
|
82
205
|
|
|
83
206
|
// A typed usage failure (exit 2) — the codebase's typed-error idiom (no classes).
|
|
@@ -103,12 +226,12 @@ const probeVelocityItems = ({ root, deps, add, skip }) => {
|
|
|
103
226
|
// above owns the real failure modes).
|
|
104
227
|
const core = planVelocityProfile(preflight, {});
|
|
105
228
|
if (core.toAdd.length > 0) {
|
|
106
|
-
add('velocity-core',
|
|
229
|
+
add('velocity-core', fillTemplate(WHATS['velocity-core'], { n: core.toAdd.length }), applyLine(''));
|
|
107
230
|
}
|
|
108
231
|
try {
|
|
109
232
|
const kt = planVelocityProfile(preflight, { kitTools: true });
|
|
110
233
|
if (kt.tierToAdd.length > 0) {
|
|
111
|
-
add('kit-tools-tier',
|
|
234
|
+
add('kit-tools-tier', fillTemplate(WHATS['kit-tools-tier'], { n: kt.tierToAdd.length }), applyLine(' --kit-tools'));
|
|
112
235
|
}
|
|
113
236
|
} catch (err) {
|
|
114
237
|
skip('kit-tools-tier', err);
|
|
@@ -117,7 +240,7 @@ const probeVelocityItems = ({ root, deps, add, skip }) => {
|
|
|
117
240
|
const bt = planVelocityProfile(preflight, { bridgeTier: true, findWrapper: deps.findWrapper });
|
|
118
241
|
const delta = bt.bridgeToAdd.length + bt.excludedToAdd.length;
|
|
119
242
|
if (delta > 0) {
|
|
120
|
-
add('bridge-tier',
|
|
243
|
+
add('bridge-tier', fillTemplate(WHATS['bridge-tier'], { n: delta }), applyLine(' --bridge-tier'));
|
|
121
244
|
}
|
|
122
245
|
} catch (err) {
|
|
123
246
|
skip('bridge-tier', err);
|
|
@@ -134,7 +257,7 @@ const probeAutonomyItems = ({ root, deps, add, skip }) => {
|
|
|
134
257
|
// declaration: its render check still runs below (codex, Segment B closing).
|
|
135
258
|
if (source !== 'none' && isSparseSeedConfig(config)) return;
|
|
136
259
|
if (source === 'none') {
|
|
137
|
-
add('autonomy-policy',
|
|
260
|
+
add('autonomy-policy', fillTemplate(WHATS['autonomy-policy'], { path: AUTONOMY_REL }), '/agent-workflow-kit set-autonomy (run IN the target project — the conversational writer previews, then writes its docs/ai/autonomy.json)');
|
|
138
261
|
}
|
|
139
262
|
} catch (err) {
|
|
140
263
|
skip('autonomy-policy', err);
|
|
@@ -144,7 +267,8 @@ const probeAutonomyItems = ({ root, deps, add, skip }) => {
|
|
|
144
267
|
try {
|
|
145
268
|
const check = checkAutonomyProfile({ cwd: root }, deps);
|
|
146
269
|
if (!check.inSync) {
|
|
147
|
-
|
|
270
|
+
const drift = capList(check.drift, templateBudget(WHATS['autonomy-render']));
|
|
271
|
+
add('autonomy-render', fillTemplate(WHATS['autonomy-render'], { drift }), `node ${q(toolPath('velocity-profile.mjs'))} --autonomy --apply --cwd ${q(root)}`);
|
|
148
272
|
}
|
|
149
273
|
} catch (err) {
|
|
150
274
|
skip('autonomy-render', err);
|
|
@@ -156,10 +280,11 @@ const probeSandboxProvision = ({ root, deps, add, skip }) => {
|
|
|
156
280
|
const p = probeSandboxAvailability(deps);
|
|
157
281
|
if (p.available) return;
|
|
158
282
|
const plan = deriveDoctorPlan({ probeResult: p, env: deps.env ?? process.env, isExec: deps.isExecutable ?? isExecutableFile });
|
|
159
|
-
const
|
|
283
|
+
const variant = plan.tuple ? 'sandbox-provision.installable' : 'sandbox-provision';
|
|
284
|
+
const reason = truncatedTo(oneLineOf(p.reason), templateBudget(WHATS[variant]) - (plan.tuple ? String(plan.tuple).length : 0));
|
|
160
285
|
// The doctor reads process.cwd() (deployment-gated) and takes no --cwd flag — the one-liner
|
|
161
286
|
// pins the target project via a cd prefix (codex R2, Segment B).
|
|
162
|
-
add('sandbox-provision',
|
|
287
|
+
add('sandbox-provision', fillTemplate(WHATS[variant], { reason, tuple: plan.tuple }), `cd ${q(root)} && node ${q(toolPath('autonomy-doctor.mjs'))}`);
|
|
163
288
|
} catch (err) {
|
|
164
289
|
skip('sandbox-provision', err);
|
|
165
290
|
}
|
|
@@ -179,7 +304,7 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
|
|
|
179
304
|
}
|
|
180
305
|
}
|
|
181
306
|
if (degraded.length > 0) {
|
|
182
|
-
add('review-recipe',
|
|
307
|
+
add('review-recipe', fillTemplate(WHATS['review-recipe'], { degraded: capList(degraded, templateBudget(WHATS['review-recipe'])) }), '/agent-workflow-kit backends');
|
|
183
308
|
}
|
|
184
309
|
} catch (err) {
|
|
185
310
|
skip('review-recipe', err);
|
|
@@ -196,13 +321,13 @@ const probeGates = ({ root, deps, add, skip }) => {
|
|
|
196
321
|
// scripts and writes only on an explicit yes) — never the runner.
|
|
197
322
|
if (!sg.declarationPresent || sg.declaredGates === 0) {
|
|
198
323
|
// The seeder writes ONLY with --apply and consent is per-entry (--only) — the apply field
|
|
199
|
-
// stays a PURE executable command (run-exactly-
|
|
324
|
+
// stays a PURE executable command (run-exactly-as-rendered feeds it to the shell); the
|
|
200
325
|
// two-step preview semantics live in WHAT, never as prose appended to the command.
|
|
201
|
-
add('gates-declaration', '
|
|
326
|
+
add('gates-declaration', fillTemplate(WHATS['gates-declaration'], {}), `node ${q(toolPath('seed-gates.mjs'))} --cwd ${q(root)}`);
|
|
202
327
|
return;
|
|
203
328
|
}
|
|
204
329
|
if (sg.declaredGates > 0 && !sg.wired) {
|
|
205
|
-
add('gate-hook',
|
|
330
|
+
add('gate-hook', fillTemplate(WHATS['gate-hook'], { n: sg.declaredGates }), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`);
|
|
206
331
|
}
|
|
207
332
|
} catch (err) {
|
|
208
333
|
skip('gate-hook', err);
|
|
@@ -228,7 +353,7 @@ const probeFamilyFreshness = ({ deps, add, skip }) => {
|
|
|
228
353
|
// ALL caveats per row — a memory missing BOTH templates must not drop the second (codex).
|
|
229
354
|
...caveated.map((r) => `${r.name}: ${r.caveats.join('; ')}`),
|
|
230
355
|
];
|
|
231
|
-
add('family-freshness',
|
|
356
|
+
add('family-freshness', fillTemplate(WHATS['family-freshness'], { parts: capList(parts, templateBudget(WHATS['family-freshness'])) }), 'npx @sabaiway/agent-workflow-kit@latest init');
|
|
232
357
|
} catch (err) {
|
|
233
358
|
skip('family-freshness', err);
|
|
234
359
|
}
|
|
@@ -239,11 +364,11 @@ const probeMasksItem = ({ root, deps, add, skip }) => {
|
|
|
239
364
|
const p = probeSandboxMasks({ cwd: root, ...deps });
|
|
240
365
|
if (p == null) return; // not a git work tree — the lane is N/A, not sub-optimal
|
|
241
366
|
if (!needsMasksApply(p)) return;
|
|
242
|
-
const
|
|
367
|
+
const variant = p.staleReal.length > 0 ? 'sandbox-masks.stale-real' : 'sandbox-masks';
|
|
243
368
|
// A stale-real-only fence (EMPTY derivation over a non-empty block) makes the plain --apply
|
|
244
369
|
// REFUSE — the exact one-liner must carry --clear there (codex R1, Segment B).
|
|
245
370
|
const apply = p.masks.length === 0 && p.staleReal.length > 0 ? `${p.applyCmd} --clear` : p.applyCmd;
|
|
246
|
-
add('sandbox-masks',
|
|
371
|
+
add('sandbox-masks', fillTemplate(WHATS[variant], { n: p.masks.length, m: p.staleReal.length }), apply);
|
|
247
372
|
} catch (err) {
|
|
248
373
|
skip('sandbox-masks', err);
|
|
249
374
|
}
|
|
@@ -265,7 +390,8 @@ const probeAgyAdddir = ({ deps, add, skip }) => {
|
|
|
265
390
|
if (env.AGY_REVIEW_ALLOW_ADDDIR === '') return;
|
|
266
391
|
// env > file: while ANY env value is set the wrapper ignores the settings file, so the file
|
|
267
392
|
// writer cannot fix an invalid env — the honest apply is to fix/unset the env var (codex).
|
|
268
|
-
|
|
393
|
+
const value = truncatedTo(oneLineOf(JSON.stringify(env.AGY_REVIEW_ALLOW_ADDDIR)), templateBudget(WHATS['agy-adddir.invalid-env']));
|
|
394
|
+
add('agy-adddir', fillTemplate(WHATS['agy-adddir.invalid-env'], { value }), 'HAND-APPLY: unset AGY_REVIEW_ALLOW_ADDDIR in the environment (or export it as 1), THEN configure it durably via the bridge-settings writer', 'agy-adddir.invalid-env');
|
|
269
395
|
return;
|
|
270
396
|
}
|
|
271
397
|
const confPath = settingsPath({ getenv: env, home: deps.home });
|
|
@@ -285,7 +411,7 @@ const probeAgyAdddir = ({ deps, add, skip }) => {
|
|
|
285
411
|
// The settings writer REFUSES a duplicate-carrying file — rendering its command would hand
|
|
286
412
|
// the user a guaranteed failure; the honest apply is fix-duplicates-first (codex terminal).
|
|
287
413
|
const dups = duplicateKeys(parsed);
|
|
288
|
-
const what =
|
|
414
|
+
const what = fillTemplate(WHATS['agy-adddir'], { file: SETTINGS_FILENAME });
|
|
289
415
|
if (dups.length > 0) {
|
|
290
416
|
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
417
|
return;
|
|
@@ -296,16 +422,18 @@ const probeAgyAdddir = ({ deps, add, skip }) => {
|
|
|
296
422
|
}
|
|
297
423
|
};
|
|
298
424
|
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
425
|
+
// The manifest-declared session-sandbox recipe surfaces of every BUNDLED bridge whose review
|
|
426
|
+
// wrapper is in the wired set — networkHosts ∪ writableDirs, derived from the manifests (the
|
|
427
|
+
// single documentation source), never hardcoded here.
|
|
428
|
+
const bundledSandboxRecipe = (placedWrappers, deps) => {
|
|
302
429
|
const readFile = deps.readFile ?? readFileSync;
|
|
303
430
|
const readDir = deps.readdir ?? readdirSync;
|
|
304
431
|
const bundleRoot = deps.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
|
|
305
432
|
const hosts = [];
|
|
433
|
+
const dirEntries = [];
|
|
306
434
|
for (const dir of readDir(bundleRoot)) {
|
|
307
|
-
// An unreadable/unparsable bundled manifest must NOT thin the
|
|
308
|
-
//
|
|
435
|
+
// An unreadable/unparsable bundled manifest must NOT thin the recipe silently — a partial
|
|
436
|
+
// recipe rendered as complete is worse than no item. The throw reaches the probe's catch and
|
|
309
437
|
// becomes a stated skip.
|
|
310
438
|
let manifest;
|
|
311
439
|
try {
|
|
@@ -316,14 +444,58 @@ const bundledNetworkHosts = (placedWrappers, deps) => {
|
|
|
316
444
|
throw new Error(`bundled manifest unreadable: ${join(dir, 'capability.json')} — ${err?.message ?? err}`);
|
|
317
445
|
}
|
|
318
446
|
const reviewCmd = manifest?.roles?.review?.cmd;
|
|
319
|
-
if (reviewCmd
|
|
447
|
+
if (!reviewCmd || !placedWrappers.includes(reviewCmd)) continue;
|
|
448
|
+
if (Array.isArray(manifest.networkHosts)) {
|
|
320
449
|
for (const h of manifest.networkHosts) if (!hosts.includes(h)) hosts.push(h);
|
|
321
450
|
}
|
|
451
|
+
if (Array.isArray(manifest.writableDirs)) dirEntries.push(...manifest.writableDirs);
|
|
322
452
|
}
|
|
323
|
-
return hosts;
|
|
453
|
+
return { hosts, dirEntries };
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// D6 resolution, mirroring the wrappers' byte-semantics (`${VAR:-default}` + the exact case-arms:
|
|
457
|
+
// `~` / `~/…` / `/…` ride as-given; EVERY other form — including `~user/…`, which the wrappers
|
|
458
|
+
// never resolve as a home path — anchors like a relative path). The advisor anchors to the TARGET
|
|
459
|
+
// PROJECT ROOT (the pinned --cwd), matching what a wrapper invoked from the project root resolves
|
|
460
|
+
// (the documented dispatch form; the wrapper itself anchors to its invocation $PWD).
|
|
461
|
+
const resolveWritableDir = (entry, { env, root }) => {
|
|
462
|
+
const value = entry.env == null ? '' : (env[entry.env] ?? '');
|
|
463
|
+
if (value === '') return entry.default;
|
|
464
|
+
if (value === '~' || value.startsWith('~/') || value.startsWith('/')) return value;
|
|
465
|
+
return resolve(root, value);
|
|
324
466
|
};
|
|
325
467
|
|
|
326
|
-
|
|
468
|
+
// The NEUTRAL recipe fingerprint (D4): a hash over the resolved hosts ∪ dirs data — an
|
|
469
|
+
// acknowledgement token, never a security key. Canonical form is HOME-SYMBOLIC: an
|
|
470
|
+
// absolute dir under the resolved home canonicalizes BACK to its `~/…` form and tilde forms stay
|
|
471
|
+
// symbolic — so `~/.codex` and its absolute expansion acknowledge the SAME recipe AND the default
|
|
472
|
+
// recipe's fingerprint is identical across machines/users (a committed project-scope ack never
|
|
473
|
+
// churns between them); only a genuinely-outside-home absolute override stays absolute
|
|
474
|
+
// (machine-specific by nature). Any change to the recipe re-fires the item.
|
|
475
|
+
export const recipeFingerprint = ({ hosts, dirs, home }) => {
|
|
476
|
+
const homeAbs = resolve(home);
|
|
477
|
+
const norm = (d) => {
|
|
478
|
+
if (d === '~') return '~';
|
|
479
|
+
if (d.startsWith('~/')) return `~/${d.slice(2)}`;
|
|
480
|
+
const abs = resolve(d);
|
|
481
|
+
if (abs === homeAbs) return '~';
|
|
482
|
+
return abs.startsWith(`${homeAbs}/`) ? `~/${abs.slice(homeAbs.length + 1)}` : abs;
|
|
483
|
+
};
|
|
484
|
+
const canonical = JSON.stringify({ hosts: [...hosts].sort(), dirs: [...new Set(dirs.map(norm))].sort() });
|
|
485
|
+
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// The kit-owned neutral ack namespace (D4): project-scoped, hand-applicable as one line, read from
|
|
489
|
+
// BOTH settings scopes; the sandbox/permissions security keys are NEVER consulted as an ack.
|
|
490
|
+
export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
|
|
491
|
+
export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
|
|
492
|
+
|
|
493
|
+
// D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
|
|
494
|
+
// at the consent moment; the static contract test asserts EXACT bidirectional coverage
|
|
495
|
+
// (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
|
|
496
|
+
export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane']);
|
|
497
|
+
|
|
498
|
+
const probeSandboxLane = ({ root, deps, add, skip }) => {
|
|
327
499
|
try {
|
|
328
500
|
const settings = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
|
|
329
501
|
const localSettings = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
|
|
@@ -331,8 +503,8 @@ const probeNetworkAllowlist = ({ root, deps, add, skip }) => {
|
|
|
331
503
|
const excluded = Array.isArray(sandbox?.excludedCommands) ? sandbox.excludedCommands : [];
|
|
332
504
|
const probePlaced = deps.findWrapper ?? ((cmd) => findOnPath(cmd, deps).state === 'present');
|
|
333
505
|
// Wired = the FULL two-surface tier proof (excludedCommands + the code-mode allow rule, either
|
|
334
|
-
// scope) — surfacing the
|
|
335
|
-
//
|
|
506
|
+
// scope) — surfacing the recipe while the tier is half-configured would front-run the
|
|
507
|
+
// bridge-tier item (codex terminal). Byte-form from the tier's own constants.
|
|
336
508
|
const allowRules = [
|
|
337
509
|
...(Array.isArray(settings.data?.permissions?.allow) ? settings.data.permissions.allow : []),
|
|
338
510
|
...(Array.isArray(localSettings.data?.permissions?.allow) ? localSettings.data.permissions.allow : []),
|
|
@@ -341,27 +513,26 @@ const probeNetworkAllowlist = ({ root, deps, add, skip }) => {
|
|
|
341
513
|
(w) => excluded.includes(w) && probePlaced(w) && allowRules.includes(`Bash(${w} ${BRIDGE_REVIEW_MODE}:*)`),
|
|
342
514
|
);
|
|
343
515
|
if (wired.length === 0) return; // the tier is not (fully) wired — the bridge-tier item covers first
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const hostsJson = finalList.map((h) => JSON.stringify(h)).join(', ');
|
|
516
|
+
const { hosts, dirEntries } = bundledSandboxRecipe(wired, deps);
|
|
517
|
+
const env = deps.getenv ?? process.env;
|
|
518
|
+
const home = deps.home ?? homedir();
|
|
519
|
+
const dirs = [];
|
|
520
|
+
for (const entry of dirEntries) {
|
|
521
|
+
const resolved = resolveWritableDir(entry, { env, root });
|
|
522
|
+
if (!dirs.includes(resolved)) dirs.push(resolved);
|
|
523
|
+
}
|
|
524
|
+
const fingerprint = recipeFingerprint({ hosts, dirs, home });
|
|
525
|
+
// Convergence is the NEUTRAL fingerprint-bound acknowledgement, read from BOTH scopes — a
|
|
526
|
+
// changed recipe (hosts, dirs, or an env override) re-fires the item (D4).
|
|
527
|
+
const acks = [settings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY], localSettings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY]];
|
|
528
|
+
if (acks.includes(fingerprint)) return; // the acknowledged recipe — the item converged
|
|
358
529
|
add(
|
|
359
|
-
'
|
|
360
|
-
'
|
|
361
|
-
`HAND-APPLY (
|
|
530
|
+
'sandbox-lane',
|
|
531
|
+
fillTemplate(WHATS['sandbox-lane'], {}),
|
|
532
|
+
`HAND-APPLY (a neutral acknowledgement, never a security key): recipe — egress hosts [${hosts.join(', ')}]; writable state dirs [${dirs.join(', ')}] (observed-minimal; a blocked host names itself at run time); what to DO with it per host class: the mode doc's sandbox-lanes section; once handled, record the ack: set "${SANDBOX_LANE_ACK_PARENT}"."${SANDBOX_LANE_ACK_KEY}" to "${fingerprint}" in .claude/settings.json or settings.local.json — a MERGE into the existing ${SANDBOX_LANE_ACK_PARENT} object (keep its other keys; create it only if absent)`,
|
|
362
533
|
);
|
|
363
534
|
} catch (err) {
|
|
364
|
-
skip('
|
|
535
|
+
skip('sandbox-lane', err);
|
|
365
536
|
}
|
|
366
537
|
};
|
|
367
538
|
|
|
@@ -375,31 +546,58 @@ const PROBES = Object.freeze([
|
|
|
375
546
|
probeFamilyFreshness,
|
|
376
547
|
probeMasksItem,
|
|
377
548
|
probeAgyAdddir,
|
|
378
|
-
|
|
549
|
+
probeSandboxLane,
|
|
379
550
|
]);
|
|
380
551
|
|
|
381
552
|
export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
|
|
382
553
|
const root = resolve(cwd);
|
|
383
554
|
const items = [];
|
|
384
555
|
const skips = [];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
556
|
+
// Skip reasons ride arbitrary Error.messages — normalized to ONE line and length-capped so a
|
|
557
|
+
// multiline or oversized message can never rebuild a prose wall (D2).
|
|
558
|
+
const skip = (key, err) => skips.push({ key, reason: truncatedTo(oneLineOf(err?.message ?? String(err)), SKIP_REASON_CAP) });
|
|
559
|
+
// The runtime shape backstop (D2): every COMPOSED item is validated at construction — a
|
|
560
|
+
// violation surfaces through the stated-skip lane, never a crash, never a rendered violation.
|
|
561
|
+
// severityKey defaults to the item key; a per-site arm passes its `<key>.<variant>` entry when
|
|
562
|
+
// its class differs from the base (the invalid-env attention arm).
|
|
563
|
+
const add = (key, what, apply, severityKey = key) => {
|
|
564
|
+
const problems = [];
|
|
565
|
+
if (!(key in BENEFITS)) problems.push(`unregistered item key ${JSON.stringify(key)}`);
|
|
566
|
+
if (!(severityKey in SEVERITIES)) problems.push(`unregistered severity key ${JSON.stringify(severityKey)}`);
|
|
567
|
+
if (/[\r\n]/.test(what)) problems.push('WHAT is not a single line');
|
|
568
|
+
else if (what.length > ITEM_LINE_CAP) problems.push(`WHAT exceeds the ${ITEM_LINE_CAP}-char cap (${what.length})`);
|
|
569
|
+
if (/[\r\n]/.test(apply)) problems.push('apply is not a single line');
|
|
570
|
+
if (problems.length > 0) {
|
|
571
|
+
skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply });
|
|
575
|
+
};
|
|
576
|
+
for (const probe of deps.probes ?? PROBES) probe({ root, deps, add, skip });
|
|
388
577
|
return { root, items, skips };
|
|
389
578
|
};
|
|
390
579
|
|
|
391
|
-
// ── rendering (the agent
|
|
580
|
+
// ── rendering (English tool DATA — the agent presents it in the user's conversational language,
|
|
581
|
+
// facts/counts complete, commands byte-exact; the raw block on request) ─────────────────────────
|
|
392
582
|
export const formatRecommendations = ({ items, skips }) => {
|
|
393
583
|
const lines = [RECOMMENDATIONS_SECTION_HEADER, ''];
|
|
394
|
-
|
|
584
|
+
const attention = items.filter((i) => i.severity === SEVERITY_ATTENTION).length;
|
|
585
|
+
const verdict = composeVerdict({ attention, optional: items.length - attention, skipped: skips.length });
|
|
586
|
+
if (verdict == null) {
|
|
395
587
|
// The flow-optimal claim renders ONLY when every probe ran and none fired — an empty item
|
|
396
588
|
// list beside skipped checks would falsely attest optimality (codex R1, Segment B).
|
|
397
589
|
lines.push(RECOMMENDATIONS_EMPTY_LINE);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
590
|
+
return lines.join('\n');
|
|
591
|
+
}
|
|
592
|
+
lines.push(verdict);
|
|
593
|
+
if (items.length > 0) {
|
|
594
|
+
lines.push('');
|
|
595
|
+
// Attention items lead (stable within each class — the frozen probe order).
|
|
596
|
+
const ordered = [...items].sort(
|
|
597
|
+
(a, b) => (a.severity === SEVERITY_ATTENTION ? 0 : 1) - (b.severity === SEVERITY_ATTENTION ? 0 : 1),
|
|
598
|
+
);
|
|
599
|
+
ordered.forEach((item, i) => {
|
|
600
|
+
lines.push(`${i + 1}. ${SEVERITY_LABELS[item.severity] ?? SEVERITY_LABELS[SEVERITY_OPTIONAL]}: ${item.what}`);
|
|
403
601
|
lines.push(` benefit: ${item.benefit}`);
|
|
404
602
|
lines.push(` apply: ${item.apply}`);
|
|
405
603
|
});
|
|
@@ -415,15 +613,17 @@ const HELP = `recommendations — the read-only upgrade Recommendations advisor
|
|
|
415
613
|
Usage:
|
|
416
614
|
node recommendations.mjs --cwd <project-root> [--json]
|
|
417
615
|
|
|
418
|
-
Computes the deterministic Recommendations section every kit upgrade ends with
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
current directory). The
|
|
422
|
-
a probe failure is a
|
|
423
|
-
|
|
424
|
-
item
|
|
425
|
-
|
|
426
|
-
the
|
|
616
|
+
Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
|
|
617
|
+
one composed verdict line opens every non-optimal render, then per item {severity · what is
|
|
618
|
+
sub-optimal · the benefit in one plain line · the exact consent-gated apply one-liner}. --cwd is
|
|
619
|
+
REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
|
|
620
|
+
section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
|
|
621
|
+
stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
|
|
622
|
+
the doctor item pins via a cd prefix; the ONE exception is the set-autonomy item — a
|
|
623
|
+
conversational skill invocation labeled "run IN the target project") and preserve each writer's
|
|
624
|
+
own consent semantics; the sandbox-lane item is HAND-APPLY by design — this tool and the kit
|
|
625
|
+
writers never seed sandbox network/filesystem allowances, and its convergence is a neutral
|
|
626
|
+
fingerprint acknowledgement, never a security key.
|
|
427
627
|
|
|
428
628
|
Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
|
|
429
629
|
rendered (items or empty); 1 error; 2 usage.`;
|