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