@sabaiway/agent-workflow-kit 10.3.0 → 10.5.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +5 -5
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +7 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +69 -17
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +73 -2
  7. package/bridges/antigravity-cli-bridge/capability.json +2 -2
  8. package/bridges/antigravity-cli-bridge/references/review-prompt.md +3 -0
  9. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
  11. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +1 -1
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +89 -18
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +55 -2
  14. package/bridges/codex-cli-bridge/capability.json +2 -2
  15. package/capability.json +1 -1
  16. package/package.json +1 -1
  17. package/references/agents/executor.md +40 -0
  18. package/references/agents/review-lens.md +5 -3
  19. package/references/modes/agents.md +9 -4
  20. package/references/modes/procedures.md +21 -8
  21. package/references/modes/recipes.md +7 -4
  22. package/references/modes/recommendations.md +3 -1
  23. package/references/modes/set-recipe.md +23 -6
  24. package/references/modes/status.md +2 -2
  25. package/references/modes/upgrade.md +1 -1
  26. package/references/modes/velocity.md +1 -0
  27. package/references/shared/composition-handoff.md +1 -1
  28. package/references/shared/deploy-tail.md +1 -1
  29. package/references/templates/orchestration.json +1 -1
  30. package/tools/autonomy-config.mjs +1 -1
  31. package/tools/bridge-posture.mjs +48 -0
  32. package/tools/carriers.mjs +152 -0
  33. package/tools/cheap-agents-read.mjs +234 -0
  34. package/tools/cheap-agents.mjs +101 -109
  35. package/tools/commands.mjs +3 -3
  36. package/tools/detect-backends.mjs +2 -2
  37. package/tools/direct-run.mjs +9 -0
  38. package/tools/family-registry.mjs +38 -18
  39. package/tools/flow-check.mjs +2 -7
  40. package/tools/fold-scope.mjs +5 -60
  41. package/tools/grounding.mjs +2 -2
  42. package/tools/inject-methodology.mjs +4 -0
  43. package/tools/orchestration-config.mjs +23 -61
  44. package/tools/orchestration-readme.mjs +70 -0
  45. package/tools/plan-shape-cli.mjs +112 -0
  46. package/tools/plan-shape-facts.mjs +204 -0
  47. package/tools/plan-shape.mjs +348 -0
  48. package/tools/procedures.mjs +197 -83
  49. package/tools/recipes.mjs +183 -230
  50. package/tools/recommendations.mjs +77 -11
  51. package/tools/renderers.mjs +27 -7
  52. package/tools/repo-lex.mjs +40 -0
  53. package/tools/review-roster-resolve.mjs +104 -0
  54. package/tools/review-roster.mjs +128 -0
  55. package/tools/review-rounds-cli.mjs +92 -0
  56. package/tools/review-rounds.mjs +115 -0
  57. package/tools/review-state.mjs +10 -11
  58. package/tools/set-recipe-roster.mjs +167 -0
  59. package/tools/set-recipe.mjs +138 -42
  60. package/tools/velocity-profile.mjs +8 -22
  61. package/tools/view-model.mjs +17 -3
@@ -0,0 +1,115 @@
1
+ import { isRenderableLine } from './repo-lex.mjs';
2
+ import { isShipVerdict, isRecognizedVerdict } from './core-evidence.mjs';
3
+
4
+ export const SIGNALS = Object.freeze({
5
+ NO_RECEIPTS: 'no receipts for <path>',
6
+ INCOMPLETE: 'incomplete round — <backend> missing: dispatch it',
7
+ CONVERGED: 'converged',
8
+ CROSSOVER: 'crossover — stop: diff-review',
9
+ CAP_REACHED: 'cap reached — classify each surviving finding: fixable-bug / inherent-layer-residual / escalate',
10
+ FOLD_AND_RE_REVIEW: 'round 1 — fold and re-review',
11
+ });
12
+
13
+ const isNonNegativeInteger = (value) => Number.isInteger(value) && value >= 0;
14
+ const isPlainLine = isRenderableLine;
15
+
16
+ const invalidFieldOf = (receipt) => {
17
+ if (typeof receipt?.artifactPath !== 'string') return 'artifactPath';
18
+ if (typeof receipt.fingerprint !== 'string' || !/^[0-9a-f]{64}$/u.test(receipt.fingerprint)) return 'fingerprint';
19
+ if (!isPlainLine(receipt.backend) || receipt.backend.length === 0) return 'backend';
20
+ if (receipt.probe !== false) return 'probe';
21
+ if (!isPlainLine(receipt.verdict)) return 'verdict';
22
+ if (!isNonNegativeInteger(receipt.durationS)) return 'durationS';
23
+ if (!isNonNegativeInteger(receipt.blocking)) return 'blocking';
24
+ return null;
25
+ };
26
+
27
+ export const groupRounds = (receipts, obligation) => {
28
+ const expected = new Set(obligation.backends);
29
+ const rounds = [];
30
+ const invalid = [];
31
+ const unexpected = [];
32
+ for (const receipt of receipts) {
33
+ const invalidField = invalidFieldOf(receipt);
34
+ if (invalidField !== null) {
35
+ invalid.push({ field: invalidField, receipt });
36
+ continue;
37
+ }
38
+ if (!expected.has(receipt.backend)) {
39
+ unexpected.push(receipt);
40
+ continue;
41
+ }
42
+ const previous = rounds.at(-1);
43
+ const round = previous?.fingerprint === receipt.fingerprint
44
+ ? previous
45
+ : { fingerprint: receipt.fingerprint, byBackend: {} };
46
+ if (round !== previous) rounds.push(round);
47
+ round.byBackend[receipt.backend] = receipt;
48
+ }
49
+ return { rounds, invalid, unexpected };
50
+ };
51
+
52
+ export const isComplete = (round, obligation) => {
53
+ const present = obligation.backends.filter((backend) => round.byBackend[backend] !== undefined);
54
+ return obligation.perBackend
55
+ ? present.length === obligation.backends.length
56
+ : present.length >= obligation.minShip;
57
+ };
58
+
59
+ const isConverged = (round, obligation) => obligation.backends
60
+ .map((backend) => round.byBackend[backend])
61
+ .filter(Boolean)
62
+ .every((receipt) => receipt.blocking === 0 && isShipVerdict(receipt.verdict));
63
+
64
+ const hasCrossover = (rounds, obligation) => {
65
+ const [earlier, latest] = rounds.slice(-2);
66
+ return obligation.backends.some((shipBackend) => {
67
+ const shipEarlier = earlier.byBackend[shipBackend];
68
+ const shipLatest = latest.byBackend[shipBackend];
69
+ if (!isShipVerdict(shipEarlier?.verdict) || !isShipVerdict(shipLatest?.verdict)) return false;
70
+ return obligation.backends.some((negativeBackend) => {
71
+ if (negativeBackend === shipBackend) return false;
72
+ const negativeEarlier = earlier.byBackend[negativeBackend]?.verdict;
73
+ const negativeLatest = latest.byBackend[negativeBackend]?.verdict;
74
+ return isRecognizedVerdict(negativeEarlier) && !isShipVerdict(negativeEarlier)
75
+ && isRecognizedVerdict(negativeLatest) && !isShipVerdict(negativeLatest);
76
+ });
77
+ });
78
+ };
79
+
80
+ export const signalFor = (rounds, obligation, artifactPath) => {
81
+ if (rounds.length === 0) return SIGNALS.NO_RECEIPTS.replace('<path>', () => artifactPath);
82
+ const latest = rounds.at(-1);
83
+ if (!isComplete(latest, obligation)) {
84
+ const missing = obligation.backends.find((backend) => latest.byBackend[backend] === undefined);
85
+ return SIGNALS.INCOMPLETE.replace('<backend>', () => missing);
86
+ }
87
+ if (isConverged(latest, obligation)) return SIGNALS.CONVERGED;
88
+ const complete = rounds.filter((round) => isComplete(round, obligation));
89
+ if (complete.length >= 2 && hasCrossover(complete, obligation)) return SIGNALS.CROSSOVER;
90
+ if (complete.length >= 2) return SIGNALS.CAP_REACHED;
91
+ return SIGNALS.FOLD_AND_RE_REVIEW;
92
+ };
93
+
94
+ export const renderRounds = ({ rounds, invalid = [], unexpected = [], obligation, artifactPath, pathless = 0, malformed = 0 }) => {
95
+ const lines = [];
96
+ rounds.reduce((total, round, index) => {
97
+ const receipts = obligation.backends.map((backend) => round.byBackend[backend]).filter(Boolean);
98
+ const duration = receipts.reduce((sum, receipt) => sum + receipt.durationS, 0);
99
+ const cells = obligation.backends.map((backend) => {
100
+ const receipt = round.byBackend[backend];
101
+ return receipt === undefined
102
+ ? `${backend}: missing`
103
+ : `${backend}: ${receipt.verdict} (${receipt.blocking} blocking, ${receipt.durationS}s)`;
104
+ });
105
+ const next = total + duration;
106
+ lines.push(`round ${index + 1} · ${cells.join(' · ')} · receipted duration: ${duration}s · cumulative: ${next}s`);
107
+ return next;
108
+ }, 0);
109
+ for (const entry of invalid) lines.push(`invalid: ${entry.field}`);
110
+ for (const receipt of unexpected) lines.push(`unexpected: ${receipt.backend}`);
111
+ lines.push(`pathless plan/diff receipts: ${pathless}`);
112
+ lines.push(`malformed receipt lines: ${malformed}`);
113
+ lines.push(`signal: ${signalFor(rounds, obligation, artifactPath)}`);
114
+ return lines.join('\n');
115
+ };
@@ -93,9 +93,9 @@ import { join, dirname } from 'node:path';
93
93
  import { fileURLToPath } from 'node:url';
94
94
  import { spawnSync } from 'node:child_process';
95
95
  import { createHash } from 'node:crypto';
96
- import { detectBackends, READY } from './detect-backends.mjs';
96
+ import { detectBackends } from './detect-backends.mjs';
97
97
  import { isDirectRun } from './direct-run.mjs';
98
- import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe } from './recipes.mjs';
98
+ import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe, composeReadiness } from './recipes.mjs';
99
99
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
100
100
  import { resolveFlowStorePath, readFlowStore, deriveFlowOwner, readPlanFrontmatterId } from './flow-store.mjs';
101
101
  import { CHAIN_KIND, authoritativeFlowRecords } from './flow-record.mjs';
@@ -309,16 +309,16 @@ export const degradeRecordSet = ({ cwd, env = process.env, fingerprint }) => {
309
309
  // work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
310
310
  // must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
311
311
  // flight". Outside a git tree the cwd is the only anchor (and --check exits 0).
312
- export const buildState = ({ cwd, env = process.env, detect = detectBackends, lstat = lstatSync, readFile = readFileSync } = {}) => {
312
+ export const buildState = ({ cwd, env = process.env, detect = detectBackends, surveyVehicle, lstat = lstatSync, readFile = readFileSync } = {}) => {
313
313
  const root = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
314
314
  const { config, source: configSource } = loadConfig(root);
315
- let detection = [];
315
+ // A bridge-detector throw reaches the hook, never a catch that would also lose the surveyed
316
+ // executor vehicle: bridge readiness goes unknown (fail closed below), the carrier stays known.
316
317
  let detectionWarning = null;
317
- try {
318
- detection = detect();
319
- } catch (err) {
320
- detectionWarning = `backend detection failed (${(err && err.message) || err}) — readiness unknown.`;
321
- }
318
+ const onDetectError = (err) => {
319
+ detectionWarning = `backend detection failed (${(err && err.message) || err}) — bridge readiness unknown.`;
320
+ };
321
+ const detection = composeReadiness(root, { detect, surveyVehicle, onDetectError });
322
322
  // The resolver stays for DISPLAY/diagnostics only; the OBLIGATIONS come from the configured
323
323
  // recipe (never the readiness-degraded effective one — no silent solo).
324
324
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
@@ -439,7 +439,6 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends, ls
439
439
  degradedExempt,
440
440
  maskedUntracked: countNeverCommittableUntracked(cwd, { lstat }),
441
441
  detectionWarning,
442
- anyReviewerReady: detection.some((b) => b.readiness === READY),
443
442
  flowPresent,
444
443
  flowArmed,
445
444
  flowBrokenReason,
@@ -733,7 +732,7 @@ export const main = (argv, ctx = {}) => {
733
732
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
734
733
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
735
734
  if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
736
- const state = buildState({ cwd, env, detect, lstat: ctx.lstat, readFile: ctx.readFile });
735
+ const state = buildState({ cwd, env, detect, surveyVehicle: ctx.surveyVehicle, lstat: ctx.lstat, readFile: ctx.readFile });
737
736
  const check = decideCheck(state);
738
737
  // The mask advisory is NON-FAILING by contract: one notice line, never an exit-code arm.
739
738
  const advisory = maskAdvisoryLine(state);
@@ -0,0 +1,167 @@
1
+ import {
2
+ addReviewer,
3
+ expandShorthand,
4
+ lensMembersOf,
5
+ obligationsOf,
6
+ parseSlotToken,
7
+ removeReviewer,
8
+ } from './review-roster.mjs';
9
+ import { isReadyMember, resolveRoster, skippedLine } from './review-roster-resolve.mjs';
10
+ import { applySetOps, assertSlot, fail } from './orchestration-config.mjs';
11
+ import { refuseDirectRun } from './direct-run.mjs';
12
+
13
+ const REVIEWER_KINDS = new Set(['add-reviewer', 'remove-reviewer']);
14
+
15
+ const parseQualified = (token, flag) => {
16
+ const equals = token.indexOf('=');
17
+ const qualified = equals < 0 ? token : token.slice(0, equals);
18
+ const dot = qualified.indexOf('.');
19
+ if (equals <= 0 || equals === token.length - 1 || dot <= 0 || dot === qualified.length - 1) {
20
+ throw fail(2, `--${flag} must be <activity>.review=<member> (got "${token}")`);
21
+ }
22
+ return {
23
+ activity: qualified.slice(0, dot),
24
+ slot: qualified.slice(dot + 1),
25
+ member: token.slice(equals + 1),
26
+ };
27
+ };
28
+
29
+ export const parseReviewerOp = (kind, token) => {
30
+ if (!REVIEWER_KINDS.has(kind)) throw fail(2, `unknown reviewer op: ${kind}`);
31
+ const parsed = parseQualified(token, kind);
32
+ if (assertSlot(parsed.activity, parsed.slot) !== 'review') {
33
+ throw fail(2, `--${kind} requires a review slot (got "${parsed.activity}.${parsed.slot}")`);
34
+ }
35
+ try {
36
+ parseSlotToken(parsed.member);
37
+ } catch (error) {
38
+ throw fail(2, error.message);
39
+ }
40
+ return { kind, ...parsed };
41
+ };
42
+
43
+ const membersOf = (value) => {
44
+ if (Array.isArray(value)) return value;
45
+ const expanded = expandShorthand(value);
46
+ return expanded.lossless ? expanded.members : null;
47
+ };
48
+
49
+ const sameMembers = (left, right) => {
50
+ const a = membersOf(left);
51
+ const b = membersOf(right);
52
+ return a !== null && b !== null && a.length === b.length && a.every((member, index) => member === b[index]);
53
+ };
54
+
55
+ const reviewedRefusal = (activity) => fail(
56
+ 2,
57
+ `reviewed has no lossless roster expansion — run --set ${activity}.review=council first, or use --add-reviewer ${activity}.review=codex-review / --add-reviewer ${activity}.review=agy-review on a solo slot`,
58
+ );
59
+
60
+ const applyOne = (value, op) => {
61
+ if (membersOf(value) === null) throw reviewedRefusal(op.activity);
62
+ try {
63
+ return op.kind === 'add-reviewer'
64
+ ? addReviewer(value, op.member)
65
+ : removeReviewer(value, op.member);
66
+ } catch (error) {
67
+ throw fail(2, error.code === 'last-member' ? `${error.message} — run --set ${op.activity}.review=solo` : error.message);
68
+ }
69
+ };
70
+
71
+ export const applyReviewerOps = (current, ops, { defaults = {}, seedReadme = null } = {}) => {
72
+ const states = new Map();
73
+ for (const op of ops) {
74
+ const key = `${op.activity}.${op.slot}`;
75
+ const raw = current?.[op.activity]?.[op.slot] ?? null;
76
+ const state = states.get(key) ?? {
77
+ activity: op.activity,
78
+ slot: op.slot,
79
+ from: raw,
80
+ beforeValue: raw ?? defaults[key],
81
+ value: raw ?? defaults[key],
82
+ named: new Set(),
83
+ };
84
+ if (state.value === undefined) throw fail(2, `no computed default supplied for ${key}`);
85
+ state.value = applyOne(state.value, op);
86
+ if (op.kind === 'add-reviewer') state.named.add(parseSlotToken(op.member).stem);
87
+ states.set(key, state);
88
+ }
89
+ const rows = [...states.values()].map((state) => {
90
+ const changed = !sameMembers(state.beforeValue, state.value);
91
+ return {
92
+ ...state,
93
+ changed,
94
+ to: changed ? state.value : state.from,
95
+ afterValue: changed ? state.value : state.beforeValue,
96
+ };
97
+ });
98
+ const changes = rows.filter((row) => row.changed).map((row) => ({
99
+ kind: 'set', activity: row.activity, slot: row.slot, recipe: row.to,
100
+ }));
101
+ return {
102
+ config: changes.length ? applySetOps(current, changes, { seedReadme }) : current,
103
+ rows,
104
+ };
105
+ };
106
+
107
+ const gateLabel = (value) => {
108
+ const members = membersOf(value) ?? [];
109
+ if (members.length === 0) return 'solo []';
110
+ const obligation = obligationsOf(members);
111
+ return `${obligation.recipe} [${obligation.backends.join(', ')}]`;
112
+ };
113
+
114
+ const valueLabel = (value) => value == null
115
+ ? '(computed default)'
116
+ : Array.isArray(value) ? JSON.stringify(value) : value;
117
+
118
+ const lensRemedy = (parsed, member, agentsApply) => {
119
+ if (parsed.kind !== 'lens' || member.state !== 'missing') return null;
120
+ if (parsed.template === null) return `HAND-APPLY: create .claude/agents/${parsed.stem}.md as a read-only vehicle`;
121
+ return agentsApply ? `to place it, run exactly: ${agentsApply}` : null;
122
+ };
123
+
124
+ export const persistedLensStems = (config) => new Set(lensMembersOf(config ?? {})
125
+ .map((member) => parseSlotToken(member))
126
+ .filter((parsed) => parsed.derived)
127
+ .map((parsed) => parsed.stem));
128
+
129
+ export const renderRosterPreview = (row, { agentsApply, wrote = false, persistedLenses = new Set() } = {}) => {
130
+ const lines = [row.changed
131
+ ? ` ${row.activity}.${row.slot}: ${valueLabel(row.from)} → ${valueLabel(row.to)}`
132
+ : ` ${row.activity}.${row.slot}: already ${valueLabel(row.from)} (no change)`];
133
+ const persisted = new Set((membersOf(row.from) ?? []).map((member) => parseSlotToken(member).stem));
134
+ for (const member of row.roster) {
135
+ const parsed = parseSlotToken(member.member);
136
+ const apply = lensRemedy(parsed, member, agentsApply);
137
+ const remedy = [member.reason, apply].filter(Boolean).join('; ') || null;
138
+ const posture = member.posture == null ? '' : ` (${member.posture})`;
139
+ lines.push(` ↳ ${member.member}: ${isReadyMember(member) ? member.state : skippedLine(member, remedy)}${posture}`);
140
+ if (apply && parsed.derived && !wrote && !persisted.has(parsed.stem) && !persistedLenses.has(parsed.stem)) {
141
+ lines.push(' after --write — the agents writer derives this lens from what docs/ai/orchestration.json names');
142
+ }
143
+ if (parsed.kind === 'lens' && parsed.template === null && row.named.has(parsed.stem)) {
144
+ lines.push(` resolved as a lens with no bundled template — a hand-written vehicle; if you meant a bridge, the review cmds are ${expandShorthand('council').members.join(', ')}`);
145
+ }
146
+ }
147
+ lines.push(` gate: ${gateLabel(row.beforeValue)} → ${gateLabel(row.afterValue)}`);
148
+ return lines.join('\n');
149
+ };
150
+
151
+ export const resolveReviewerRows = (rows, deps = {}) => rows.map((row) => {
152
+ const members = membersOf(row.afterValue);
153
+ return {
154
+ ...row,
155
+ roster: members.length === 0 ? [] : resolveRoster({ value: members, ...deps }),
156
+ };
157
+ });
158
+
159
+ export const rosterJsonRows = (rows, changed) => rows.map((row) => changed ? ({
160
+ activity: row.activity, slot: row.slot, from: row.from, to: row.to,
161
+ effective: row.effective, degradedFrom: row.degradedFrom ?? null, reason: row.reason ?? null,
162
+ roster: row.roster ?? null,
163
+ }) : ({
164
+ activity: row.activity, slot: row.slot, recipe: row.from, roster: row.roster ?? null,
165
+ }));
166
+
167
+ refuseDirectRun(import.meta.url);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // set-recipe.mjs — the WRITER for docs/ai/orchestration.json. The division of labor (AD-025): the AGENT
3
- // turns plain language into explicit `--set <activity>.<slot>=<recipe>` / `--unset <activity>.<slot>`
3
+ // turns plain language into explicit `--set <activity>.<slot>=<value>` / `--unset <activity>.<slot>`
4
4
  // ops; the KIT does the deterministic validate → merge → preview → write. The kit ships NO NL parser
5
5
  // (stays dependency-free + deterministic) and performs no `all`-magic — the agent expands "both review"
6
6
  // into explicit per-activity ops (asking if scope is unclear).
@@ -17,15 +17,28 @@
17
17
  // language when narrating. Exit codes: 0 success (an explicit recipe that gracefully degrades is still
18
18
  // 0); 2 usage (bad/duplicate op, --write with zero ops); 1 config error (malformed/unreadable config)
19
19
  // or a write STOP (no deployment / symlinked leaf). main(argv, ctx) → { code, stdout, stderr }; cwd /
20
- // env / home / detect / fs are injectable for host-independent tests.
20
+ // env / home / detect / surveyVehicle / fs are injectable for host-independent tests.
21
+ //
22
+ // It writes every slot of every activity in the registry (carriers.mjs, via recipes.mjs) — the
23
+ // carrier slots and the `routine.parallel` switch included — and resolves each preview against the
24
+ // SAME readiness the recipes advisor composes: detected backends plus the executor-vehicle survey.
21
25
  //
22
26
  // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
23
27
 
24
28
  import { readFileSync, lstatSync } from 'node:fs';
25
- import { homedir } from 'node:os';
26
- import { detectBackends } from './detect-backends.mjs';
27
29
  import { isDirectRun } from './direct-run.mjs';
28
- import { resolveActivityRecipe, composeActiveRecipeLine } from './recipes.mjs';
30
+ import { settingsSnapshot } from './bridge-settings-read.mjs';
31
+ import { posturesByBackend } from './bridge-posture.mjs';
32
+ import { surveyVehicle } from './cheap-agents-read.mjs';
33
+ import { applyCheapAgentsCommand } from './cheap-agents.mjs';
34
+ import {
35
+ ACTIVITIES,
36
+ SLOT_RECIPES,
37
+ EXECUTOR_APPLY,
38
+ composeReadiness,
39
+ resolveActivityRecipe,
40
+ composeActiveRecipeLine,
41
+ } from './recipes.mjs';
29
42
  import { loadAutonomy, resolveAutonomy } from './autonomy-config.mjs';
30
43
  import {
31
44
  CONFIG_REL,
@@ -35,26 +48,40 @@ import {
35
48
  parseOp,
36
49
  applySetOps,
37
50
  serializeConfig,
51
+ refreshReadme,
38
52
  CANON_README,
39
53
  } from './orchestration-config.mjs';
40
54
  import { writeConfig as writeConfigFs } from './orchestration-write.mjs';
55
+ import {
56
+ applyReviewerOps,
57
+ parseReviewerOp,
58
+ persistedLensStems,
59
+ renderRosterPreview,
60
+ resolveReviewerRows,
61
+ rosterJsonRows,
62
+ } from './set-recipe-roster.mjs';
41
63
 
42
64
  // ── argument parsing (usage errors → exit 2) ────────────────────────────────────────
43
65
 
44
- // Parse argv → { ops, write, json }. `--set`/`--unset` take a fully-qualified token (parseOp validates
45
- // it). A duplicate op for the same activity.slot, a `--write` with zero ops, an unknown flag, or a bad
46
- // token → exit 2. `--set=<tok>` / `--unset=<tok>` inline forms are accepted too.
66
+ // Parse argv → { ops, write, json }. Fixed ops stay unique per slot; reviewer list ops accumulate.
67
+ // A `--write` with zero ops, an unknown flag, or a bad token → exit 2. Inline forms are accepted too.
47
68
  const parseArgs = (argv) => {
48
69
  const ops = [];
49
- const seen = new Set();
70
+ const fixed = new Set();
71
+ const reviewer = new Set();
50
72
  let write = false;
51
73
  let json = false;
52
74
  const takeOp = (kind, tok) => {
53
- if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<recipe>' : ''}`);
54
- const op = parseOp(kind, tok);
75
+ const reviewerKind = kind === 'add-reviewer' || kind === 'remove-reviewer';
76
+ const form = reviewerKind ? '<activity>.review=<member>' : `<activity>.<slot>${kind === 'set' ? '=<value>' : ''}`;
77
+ if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires ${form}`);
78
+ const op = reviewerKind ? parseReviewerOp(kind, tok) : parseOp(kind, tok);
55
79
  const key = `${op.activity}.${op.slot}`;
56
- if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each activity.slot at most once`);
57
- seen.add(key);
80
+ if (fixed.has(key) || (!reviewerKind && reviewer.has(key))) {
81
+ throw fail(2, `duplicate op for "${key}" — --set/--unset name each activity.slot at most once and cannot mix with reviewer list ops`);
82
+ }
83
+ if (reviewerKind) reviewer.add(key);
84
+ else fixed.add(key);
58
85
  ops.push(op);
59
86
  };
60
87
  for (let i = 0; i < argv.length; i += 1) {
@@ -63,23 +90,39 @@ const parseArgs = (argv) => {
63
90
  else if (a === '--write') write = true;
64
91
  else if (a === '--set') { takeOp('set', argv[i + 1]); i += 1; }
65
92
  else if (a === '--unset') { takeOp('unset', argv[i + 1]); i += 1; }
93
+ else if (a === '--add-reviewer') { takeOp('add-reviewer', argv[i + 1]); i += 1; }
94
+ else if (a === '--remove-reviewer') { takeOp('remove-reviewer', argv[i + 1]); i += 1; }
66
95
  else if (a.startsWith('--set=')) takeOp('set', a.slice('--set='.length));
67
96
  else if (a.startsWith('--unset=')) takeOp('unset', a.slice('--unset='.length));
97
+ else if (a.startsWith('--add-reviewer=')) takeOp('add-reviewer', a.slice('--add-reviewer='.length));
98
+ else if (a.startsWith('--remove-reviewer=')) takeOp('remove-reviewer', a.slice('--remove-reviewer='.length));
68
99
  else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
69
100
  else throw fail(2, `unexpected argument: ${a}`);
70
101
  }
71
- if (write && ops.length === 0) throw fail(2, 'nothing to write — pass at least one --set/--unset (a bare --write is a no-op)');
102
+ if (write && ops.length === 0) throw fail(2, 'nothing to write — pass at least one --set/--unset/--add-reviewer/--remove-reviewer (a bare --write is a no-op)');
72
103
  return { ops, write, json };
73
104
  };
74
105
 
75
106
  // ── effective-recipe resolution per op (degradation honesty) ────────────────────────
76
107
 
108
+ // The readiness EVERY resolution here runs against: the detected bridges plus the executor-vehicle
109
+ // survey, composed by the one helper the recipes CLI uses. Detection is a SECONDARY input — a bridge
110
+ // detector throw must NOT block the write (the config write is readiness-independent) and must not
111
+ // cost the CARRIER either: the hook warns, the bridge half floors at not-ready, the vehicle survives.
112
+ const composeReadinessOrWarn = (cwd, deps, warnings) =>
113
+ composeReadiness(cwd, {
114
+ ...deps,
115
+ onDetectError: (err) => warnings.push(
116
+ `backend detection failed (${(err && err.message) || err}) — treating every bridge as not ready; recipes needing a bridge degrade to solo (the executor vehicle is unaffected).`,
117
+ ),
118
+ });
119
+
77
120
  // A single op's before/after value + the effective recipe it resolves to here (vs live readiness).
78
121
  // `to` is null for an unset (falls to the computed default). degradedFrom/reason carry the honesty.
79
- const resolveOp = (op, current, after, detection) => {
122
+ const resolveOp = (op, current, after, readiness) => {
80
123
  const from = current?.[op.activity]?.[op.slot] ?? null;
81
124
  const to = after?.[op.activity]?.[op.slot] ?? null;
82
- const r = resolveActivityRecipe({ config: after ?? {}, readiness: detection, activity: op.activity, slot: op.slot });
125
+ const r = resolveActivityRecipe({ config: after ?? {}, readiness, activity: op.activity, slot: op.slot });
83
126
  return { activity: op.activity, slot: op.slot, from, to, effective: r.recipe, degradedFrom: r.degradedFrom, reason: r.reason };
84
127
  };
85
128
 
@@ -92,15 +135,21 @@ const effectiveLine = (e) =>
92
135
  ? `effective here: ${e.effective} (requested ${e.degradedFrom} → degraded: ${e.reason})`
93
136
  : `effective here: ${e.effective}`;
94
137
 
95
- const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine }) => {
138
+ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine, agentsApply, persistedLenses }) => {
96
139
  const lines = [];
97
140
  if (wrote) lines.push(`wrote ${CONFIG_REL}`);
98
141
  else if (changed.length) lines.push(`set-recipe — preview (nothing written; re-run with --write to apply)`);
99
142
  for (const e of changed) {
143
+ if (Array.isArray(e.roster)) {
144
+ lines.push(renderRosterPreview(e, { agentsApply, wrote, persistedLenses }));
145
+ continue;
146
+ }
100
147
  lines.push(` ${e.activity}.${e.slot}: ${valueLabel(e.from)} → ${valueLabel(e.to)}`);
101
148
  lines.push(` ↳ ${effectiveLine(e)}`);
102
149
  }
103
- for (const e of unchanged) lines.push(` ${e.activity}.${e.slot}: already ${valueLabel(e.from)} (no change)`);
150
+ for (const e of unchanged) lines.push(Array.isArray(e.roster)
151
+ ? renderRosterPreview(e, { agentsApply, wrote, persistedLenses })
152
+ : ` ${e.activity}.${e.slot}: already ${valueLabel(e.from)} (no change)`);
104
153
  for (const w of warnings) lines.push(` ⚠ ${w}`);
105
154
  if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
106
155
  // The post-write discovery echo (AD-038): after every successful write, paste the freshly composed
@@ -117,8 +166,8 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody,
117
166
  };
118
167
 
119
168
  const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine }) => ({
120
- changed: changed.map((e) => ({ activity: e.activity, slot: e.slot, from: e.from, to: e.to, effective: e.effective, degradedFrom: e.degradedFrom ?? null, reason: e.reason ?? null })),
121
- unchanged: unchanged.map((e) => ({ activity: e.activity, slot: e.slot, recipe: e.from })),
169
+ changed: rosterJsonRows(changed, true),
170
+ unchanged: rosterJsonRows(unchanged, false),
122
171
  writtenPath: writtenPath ?? null,
123
172
  noop,
124
173
  warnings,
@@ -127,19 +176,44 @@ const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine
127
176
  activeLine: activeLine ?? null,
128
177
  });
129
178
 
179
+ // The writable surface, rendered FROM the registry — never re-typed as literals, so an activity, a
180
+ // slot or an accepted value added to the table shows up in the help (and in the doc that quotes it).
181
+ const ACTIVITY_LINES = Object.entries(ACTIVITIES)
182
+ .map(([activity, def]) => ` ${activity} → ${Object.keys(def.slots).join(', ')}`)
183
+ .join('\n');
184
+
185
+ const VALUE_LINES = Object.entries(SLOT_RECIPES)
186
+ .map(([slotType, values]) => ` ${slotType} slots accept ${values.join(' | ')}`)
187
+ .join('\n');
188
+
189
+ const QUALIFIED_SLOTS = Object.entries(ACTIVITIES)
190
+ .flatMap(([activity, def]) => Object.keys(def.slots).map((slot) => `${activity}.${slot}`))
191
+ .join(', ');
192
+
130
193
  const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
131
194
 
132
195
  Usage:
133
- node set-recipe.mjs [--set <activity>.<slot>=<recipe>]... [--unset <activity>.<slot>]... [--write] [--json]
196
+ node set-recipe.mjs [--set <activity>.<slot>=<value>]... [--unset <activity>.<slot>]...
197
+ [--add-reviewer <activity>.review=<member>]...
198
+ [--remove-reviewer <activity>.review=<member>]... [--write] [--json]
134
199
 
135
- --set <activity>.<slot>=<recipe> pin a recipe (fully-qualified; e.g. plan-authoring.review=council)
200
+ --set <activity>.<slot>=<value> pin a value (fully-qualified; e.g. plan-authoring.review=council)
136
201
  --unset <activity>.<slot> return a slot to its computed default
202
+ --add-reviewer <activity>.review=<member> append a reviewer (same-slot ops accumulate in argv order)
203
+ --remove-reviewer <activity>.review=<member> remove a reviewer (same-slot ops accumulate in argv order)
137
204
  --write apply the change (default: preview only — writes nothing)
138
205
  --json machine-readable output
139
206
  --help, -h this help
140
207
 
141
- Activities/slots: plan-authoring review; plan-execution → execute, review
142
- Recipes: review accepts solo|reviewed|council; execute accepts solo|delegated
208
+ Activities and their slots:
209
+ ${ACTIVITY_LINES}
210
+
211
+ Accepted values per slot type:
212
+ ${VALUE_LINES}
213
+
214
+ A carrier slot set to subagent needs the executor vehicle placed in this project — ${EXECUTOR_APPLY};
215
+ without it the slot resolves to solo with the reason stated. routine.parallel is a flag, not a
216
+ recipe: it never degrades.
143
217
 
144
218
  Previews by default; --write applies via an atomic, symlink/TOCTOU-safe write behind a deployment gate.
145
219
  Config writer only: it NEVER runs a backend and NEVER commits. Hand-editing the file stays fully supported.
@@ -152,7 +226,7 @@ Exit codes: 0 success (an explicit recipe that gracefully degrades is still 0);
152
226
 
153
227
  export const main = (argv, ctx = {}) => {
154
228
  const cwd = ctx.cwd ?? process.cwd();
155
- const detect = ctx.detect ?? detectBackends;
229
+ const readinessDeps = { detect: ctx.detect, surveyVehicle: ctx.surveyVehicle };
156
230
  const readFile = ctx.readFileSync ?? readFileSync;
157
231
  const lstat = ctx.lstatSync ?? lstatSync;
158
232
  const writeConfig = ctx.writeConfig ?? writeConfigFs;
@@ -170,23 +244,43 @@ export const main = (argv, ctx = {}) => {
170
244
  return { code: 0, stdout: JSON.stringify(buildJson({ changed: [], unchanged: [], warnings: [], writtenPath: null, noop: true }), null, 2), stderr: '' };
171
245
  }
172
246
  const shown = current == null ? `(no ${CONFIG_REL} yet — computed defaults apply)` : serializeConfig(current).replace(/\n$/, '');
173
- const hint = `\nPass --set <activity>.<slot>=<recipe> (preview) then --write to apply. Activities/slots: plan-authoring.review, plan-execution.execute, plan-execution.review.`;
247
+ const hint = `\nPass --set <activity>.<slot>=<value> (preview) then --write to apply. Activities/slots: ${QUALIFIED_SLOTS}.`;
174
248
  return { code: 0, stdout: `${source === 'none' ? '' : `${CONFIG_REL}:\n`}${shown}${hint}`, stderr: '' };
175
249
  }
176
250
 
177
- const after = applySetOps(current, ops, { seedReadme: CANON_README });
178
-
179
- // Detection is a SECONDARY input it only refines the EFFECTIVE recipe note. A throw must NOT block
180
- // the write (the config write is readiness-independent): treat all backends as not-ready, warn, exit 0.
251
+ // The merged config, then the _README refresh: a note that normalize-matches a KNOWN PRIOR canonical
252
+ // is replaced by the current one on a touched write, while a customized note stays untouched.
253
+ const render = { agentsApply: applyCheapAgentsCommand(cwd), persistedLenses: persistedLensStems(current) };
181
254
  const warnings = [];
182
- let detection = [];
183
- try {
184
- detection = detect();
185
- } catch (err) {
186
- warnings.push(`backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; recipes needing a backend degrade to solo.`);
187
- }
188
-
189
- const resolved = ops.map((op) => resolveOp(op, current, after, detection));
255
+ const readiness = composeReadinessOrWarn(cwd, readinessDeps, warnings);
256
+ const reviewerOps = ops.filter((op) => op.kind === 'add-reviewer' || op.kind === 'remove-reviewer');
257
+ const fixedOps = ops.filter((op) => op.kind === 'set' || op.kind === 'unset');
258
+ const fixedAfter = fixedOps.length
259
+ ? applySetOps(current, fixedOps, { seedReadme: CANON_README })
260
+ : current;
261
+ const defaults = Object.fromEntries(reviewerOps.map((op) => [
262
+ `${op.activity}.${op.slot}`,
263
+ resolveActivityRecipe({ config: {}, readiness, activity: op.activity, slot: op.slot }).recipe,
264
+ ]));
265
+ const reviewerResult = applyReviewerOps(fixedAfter, reviewerOps, { defaults, seedReadme: CANON_README });
266
+ const after = refreshReadme(reviewerResult.config ?? {}).config;
267
+ const surveyLens = ctx.surveyLens ?? ((spec) => surveyVehicle(cwd, spec, ctx));
268
+ const hasRoster = reviewerOps.length > 0 || Object.values(after).some((activity) => Array.isArray(activity?.review));
269
+ const settings = hasRoster ? settingsSnapshot({
270
+ getenv: ctx.env, home: ctx.home, readFile: ctx.readFileSync, lstat: ctx.lstatSync,
271
+ }) : null;
272
+ const postures = ctx.postures ?? (hasRoster ? posturesByBackend({ settings }) : {});
273
+ const reviewerRows = resolveReviewerRows(reviewerResult.rows, { readiness, surveyLens, postures })
274
+ .map((row) => {
275
+ const resolved = resolveActivityRecipe({
276
+ config: after, readiness, activity: row.activity, slot: row.slot, surveyLens, postures,
277
+ });
278
+ return { ...row, effective: resolved.recipe, degradedFrom: null, reason: null };
279
+ });
280
+ const resolved = [
281
+ ...fixedOps.map((op) => resolveOp(op, current, after, readiness)),
282
+ ...reviewerRows,
283
+ ];
190
284
  const changed = resolved.filter((e) => e.from !== e.to);
191
285
  const unchanged = resolved.filter((e) => e.from === e.to);
192
286
  const noop = changed.length === 0;
@@ -194,7 +288,7 @@ export const main = (argv, ctx = {}) => {
194
288
  if (!write) {
195
289
  const stdout = json
196
290
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop }), null, 2)
197
- : formatHuman({ changed, unchanged, warnings, willWrite: !noop, wrote: false });
291
+ : formatHuman({ changed, unchanged, warnings, willWrite: !noop, wrote: false, ...render });
198
292
  return { code: 0, stdout, stderr: '' };
199
293
  }
200
294
 
@@ -202,7 +296,7 @@ export const main = (argv, ctx = {}) => {
202
296
  if (noop) {
203
297
  const stdout = json
204
298
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop: true }), null, 2)
205
- : formatHuman({ changed, unchanged, warnings, willWrite: false, wrote: false });
299
+ : formatHuman({ changed, unchanged, warnings, willWrite: false, wrote: false, ...render });
206
300
  return { code: 0, stdout, stderr: '' };
207
301
  }
208
302
 
@@ -220,10 +314,12 @@ export const main = (argv, ctx = {}) => {
220
314
  return { error: (err && err.message) || String(err) };
221
315
  }
222
316
  })();
223
- const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection, autonomyFacts);
317
+ const activeLine = composeActiveRecipeLine(
318
+ { config: after, source: CONFIG_REL }, readiness, autonomyFacts, { surveyLens, postures },
319
+ );
224
320
  const stdout = json
225
321
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
226
- : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
322
+ : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine, ...render });
227
323
  return { code: 0, stdout, stderr: '' };
228
324
  } catch (err) {
229
325
  return { code: err.exitCode ?? 1, stdout: '', stderr: `set-recipe: ${err.message}` };