@sabaiway/agent-workflow-kit 10.4.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 (47) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  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/review-lens.md +5 -3
  18. package/references/modes/agents.md +1 -1
  19. package/references/modes/procedures.md +9 -5
  20. package/references/modes/recipes.md +2 -2
  21. package/references/modes/set-recipe.md +4 -4
  22. package/references/modes/status.md +1 -1
  23. package/references/modes/velocity.md +1 -0
  24. package/references/templates/orchestration.json +1 -1
  25. package/tools/bridge-posture.mjs +48 -0
  26. package/tools/carriers.mjs +21 -9
  27. package/tools/cheap-agents-read.mjs +86 -24
  28. package/tools/cheap-agents.mjs +47 -7
  29. package/tools/detect-backends.mjs +2 -2
  30. package/tools/direct-run.mjs +3 -0
  31. package/tools/fold-scope.mjs +5 -60
  32. package/tools/grounding.mjs +2 -2
  33. package/tools/orchestration-config.mjs +19 -78
  34. package/tools/orchestration-readme.mjs +70 -0
  35. package/tools/plan-shape-cli.mjs +112 -0
  36. package/tools/plan-shape-facts.mjs +204 -0
  37. package/tools/plan-shape.mjs +348 -0
  38. package/tools/procedures.mjs +132 -31
  39. package/tools/recipes.mjs +60 -79
  40. package/tools/repo-lex.mjs +40 -0
  41. package/tools/review-roster-resolve.mjs +104 -0
  42. package/tools/review-roster.mjs +128 -0
  43. package/tools/review-rounds-cli.mjs +92 -0
  44. package/tools/review-rounds.mjs +115 -0
  45. package/tools/set-recipe-roster.mjs +167 -0
  46. package/tools/set-recipe.mjs +80 -23
  47. package/tools/velocity-profile.mjs +8 -22
@@ -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
+ };
@@ -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);
@@ -26,8 +26,11 @@
26
26
  // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
27
27
 
28
28
  import { readFileSync, lstatSync } from 'node:fs';
29
- import { homedir } from 'node:os';
30
29
  import { isDirectRun } from './direct-run.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';
31
34
  import {
32
35
  ACTIVITIES,
33
36
  SLOT_RECIPES,
@@ -49,23 +52,36 @@ import {
49
52
  CANON_README,
50
53
  } from './orchestration-config.mjs';
51
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';
52
63
 
53
64
  // ── argument parsing (usage errors → exit 2) ────────────────────────────────────────
54
65
 
55
- // Parse argv → { ops, write, json }. `--set`/`--unset` take a fully-qualified token (parseOp validates
56
- // it). A duplicate op for the same activity.slot, a `--write` with zero ops, an unknown flag, or a bad
57
- // 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.
58
68
  const parseArgs = (argv) => {
59
69
  const ops = [];
60
- const seen = new Set();
70
+ const fixed = new Set();
71
+ const reviewer = new Set();
61
72
  let write = false;
62
73
  let json = false;
63
74
  const takeOp = (kind, tok) => {
64
- if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<value>' : ''}`);
65
- 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);
66
79
  const key = `${op.activity}.${op.slot}`;
67
- if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each activity.slot at most once`);
68
- 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);
69
85
  ops.push(op);
70
86
  };
71
87
  for (let i = 0; i < argv.length; i += 1) {
@@ -74,12 +90,16 @@ const parseArgs = (argv) => {
74
90
  else if (a === '--write') write = true;
75
91
  else if (a === '--set') { takeOp('set', argv[i + 1]); i += 1; }
76
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; }
77
95
  else if (a.startsWith('--set=')) takeOp('set', a.slice('--set='.length));
78
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));
79
99
  else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
80
100
  else throw fail(2, `unexpected argument: ${a}`);
81
101
  }
82
- 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)');
83
103
  return { ops, write, json };
84
104
  };
85
105
 
@@ -115,15 +135,21 @@ const effectiveLine = (e) =>
115
135
  ? `effective here: ${e.effective} (requested ${e.degradedFrom} → degraded: ${e.reason})`
116
136
  : `effective here: ${e.effective}`;
117
137
 
118
- const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine }) => {
138
+ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine, agentsApply, persistedLenses }) => {
119
139
  const lines = [];
120
140
  if (wrote) lines.push(`wrote ${CONFIG_REL}`);
121
141
  else if (changed.length) lines.push(`set-recipe — preview (nothing written; re-run with --write to apply)`);
122
142
  for (const e of changed) {
143
+ if (Array.isArray(e.roster)) {
144
+ lines.push(renderRosterPreview(e, { agentsApply, wrote, persistedLenses }));
145
+ continue;
146
+ }
123
147
  lines.push(` ${e.activity}.${e.slot}: ${valueLabel(e.from)} → ${valueLabel(e.to)}`);
124
148
  lines.push(` ↳ ${effectiveLine(e)}`);
125
149
  }
126
- 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)`);
127
153
  for (const w of warnings) lines.push(` ⚠ ${w}`);
128
154
  if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
129
155
  // The post-write discovery echo (AD-038): after every successful write, paste the freshly composed
@@ -140,8 +166,8 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody,
140
166
  };
141
167
 
142
168
  const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine }) => ({
143
- 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 })),
144
- unchanged: unchanged.map((e) => ({ activity: e.activity, slot: e.slot, recipe: e.from })),
169
+ changed: rosterJsonRows(changed, true),
170
+ unchanged: rosterJsonRows(unchanged, false),
145
171
  writtenPath: writtenPath ?? null,
146
172
  noop,
147
173
  warnings,
@@ -167,10 +193,14 @@ const QUALIFIED_SLOTS = Object.entries(ACTIVITIES)
167
193
  const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
168
194
 
169
195
  Usage:
170
- node set-recipe.mjs [--set <activity>.<slot>=<value>]... [--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]
171
199
 
172
200
  --set <activity>.<slot>=<value> pin a value (fully-qualified; e.g. plan-authoring.review=council)
173
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)
174
204
  --write apply the change (default: preview only — writes nothing)
175
205
  --json machine-readable output
176
206
  --help, -h this help
@@ -220,12 +250,37 @@ export const main = (argv, ctx = {}) => {
220
250
 
221
251
  // The merged config, then the _README refresh: a note that normalize-matches a KNOWN PRIOR canonical
222
252
  // is replaced by the current one on a touched write, while a customized note stays untouched.
223
- const after = refreshReadme(applySetOps(current, ops, { seedReadme: CANON_README })).config;
224
-
253
+ const render = { agentsApply: applyCheapAgentsCommand(cwd), persistedLenses: persistedLensStems(current) };
225
254
  const warnings = [];
226
255
  const readiness = composeReadinessOrWarn(cwd, readinessDeps, warnings);
227
-
228
- const resolved = ops.map((op) => resolveOp(op, current, after, readiness));
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
+ ];
229
284
  const changed = resolved.filter((e) => e.from !== e.to);
230
285
  const unchanged = resolved.filter((e) => e.from === e.to);
231
286
  const noop = changed.length === 0;
@@ -233,7 +288,7 @@ export const main = (argv, ctx = {}) => {
233
288
  if (!write) {
234
289
  const stdout = json
235
290
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop }), null, 2)
236
- : formatHuman({ changed, unchanged, warnings, willWrite: !noop, wrote: false });
291
+ : formatHuman({ changed, unchanged, warnings, willWrite: !noop, wrote: false, ...render });
237
292
  return { code: 0, stdout, stderr: '' };
238
293
  }
239
294
 
@@ -241,7 +296,7 @@ export const main = (argv, ctx = {}) => {
241
296
  if (noop) {
242
297
  const stdout = json
243
298
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath: null, noop: true }), null, 2)
244
- : formatHuman({ changed, unchanged, warnings, willWrite: false, wrote: false });
299
+ : formatHuman({ changed, unchanged, warnings, willWrite: false, wrote: false, ...render });
245
300
  return { code: 0, stdout, stderr: '' };
246
301
  }
247
302
 
@@ -259,10 +314,12 @@ export const main = (argv, ctx = {}) => {
259
314
  return { error: (err && err.message) || String(err) };
260
315
  }
261
316
  })();
262
- const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, readiness, autonomyFacts);
317
+ const activeLine = composeActiveRecipeLine(
318
+ { config: after, source: CONFIG_REL }, readiness, autonomyFacts, { surveyLens, postures },
319
+ );
263
320
  const stdout = json
264
321
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
265
- : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
322
+ : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine, ...render });
266
323
  return { code: 0, stdout, stderr: '' };
267
324
  } catch (err) {
268
325
  return { code: err.exitCode ?? 1, stdout: '', stderr: `set-recipe: ${err.message}` };
@@ -10,6 +10,8 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
10
10
  // The bridge-wrappers tier's placement probe (AD-044 Plan 4, Decision 2): a tier entry derives ONLY
11
11
  // for a PLACED bridge wrapper — findOnPath is the same read-only PATH scan the backend detector uses.
12
12
  import { findOnPath } from './detect-backends.mjs';
13
+ // The seedable-path predicate lives in the pure leaf so the renders spell a path exactly as it is seeded.
14
+ import { SHELL_METACHARACTERS, hasShellMetacharacter, isSeedablePathToken } from './repo-lex.mjs';
13
15
  import { isDirectRun } from './direct-run.mjs';
14
16
  import { compareSemver } from './semver-lite.mjs';
15
17
  // The declared-path resolution + segment containment the allowWrite degrade shares with the
@@ -131,7 +133,7 @@ export const SHELL_READONLY = Object.freeze([
131
133
  // seed time from the RUNNING tool's own location — resolved-absolute, so a moved or reinstalled
132
134
  // skill leaves a stale rule that FAIL-SAFE prompts again (never a silent widening).
133
135
  //
134
- // Membership (10, frozen): the read-only kit tools plus run-gates.mjs — which is NOT read-only but
136
+ // Membership (13, frozen): the read-only kit tools plus run-gates.mjs — which is NOT read-only but
135
137
  // project-exec (it runs the project's OWN declared gates.json commands), so it seeds as ONE exact
136
138
  // byte-string pinned to this project root (`--cwd <resolved root>`): a wildcard would be BROADER
137
139
  // than the AD-037 hook boundary (`--cwd <dir>` executes an arbitrary OTHER project's gates.json)
@@ -158,6 +160,9 @@ export const KIT_READONLY_TOOLS = Object.freeze([
158
160
  // useless approvals is mostly small path questions batched into a composed shell because no single
159
161
  // call answered them, and a lane the agent must still ask about is not a lane.
160
162
  'tools/path-inventory.mjs',
163
+ // The round table the procedures advisor names every review round; its path argument is resolved
164
+ // and compared, never opened as a pattern, so it rides the settings-level posture like the rest.
165
+ 'tools/review-rounds-cli.mjs',
161
166
  ]);
162
167
  // Writer previews: ONLY writers whose ARG-FREE invocation is a documented dry-run ("Default is
163
168
  // --dry-run" in their usage) seed an EXACT preview byte-string — every --apply/--write/--yes keeps
@@ -269,10 +274,7 @@ export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}
269
274
  // a sandbox. velocity never ADDS commit/push/publish as allow rules and keeps acceptEdits opt-in;
270
275
  // runtime closure is not something settings-level allow rules can enforce — the residual guard
271
276
  // ships as the opt-in PreToolUse hook (Mode: hook, tools/gate-hook.mjs; probe record in AD-037).
272
- export const SHELL_METACHARACTERS = Object.freeze([
273
- '&', '|', ';', '<', '>', '$', '`', '(', ')',
274
- '\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
275
- ]);
277
+ export { SHELL_METACHARACTERS };
276
278
 
277
279
  // The RUNTIME residual documented above, as data: the exact write-redirection / command-
278
280
  // substitution / bounded write-flag forms a settings-level allow rule cannot see. Consumed by
@@ -484,8 +486,6 @@ const getDefaultMode = (data) => {
484
486
  : { present: false, value: undefined };
485
487
  };
486
488
 
487
- const hasShellMetacharacter = (cmd) => SHELL_METACHARACTERS.some((ch) => cmd.includes(ch));
488
-
489
489
  const tokenizeCommand = (cmd) => cmd.trim().split(WHITESPACE_PATTERN).filter(Boolean);
490
490
 
491
491
  const getSubcommand = (tokens) => tokens.slice(1).join(' ');
@@ -504,20 +504,6 @@ const getBashExactCommand = (pattern) => {
504
504
  return match ? match[1] : undefined;
505
505
  };
506
506
 
507
- // Characters that survive whitespace tokenization but break an UNQUOTED byte-exact path rule:
508
- // shell quoting syntax and glob brackets (SHELL_METACHARACTERS owns the command-level separators/
509
- // redirections/expansions — `*`/`?` globs included — but not these four).
510
- const PATH_BREAKING_CHARACTERS = Object.freeze(["'", '"', '[', ']']);
511
-
512
- // A path token that can be seeded UNQUOTED into a byte-exact allow rule: POSIX-absolute, no
513
- // whitespace, no shell metacharacter, no quoting/glob syntax.
514
- const isSeedablePathToken = (token) =>
515
- typeof token === 'string' &&
516
- token.startsWith('/') &&
517
- !/\s/u.test(token) &&
518
- !hasShellMetacharacter(token) &&
519
- !PATH_BREAKING_CHARACTERS.some((ch) => token.includes(ch));
520
-
521
507
  // A tier path token must be SEEDABLE (a relative or shell-syntax-carrying spelling is a dead rule
522
508
  // the screen refuses to bless) and end on a known tier-tool tail. Any seedable absolute prefix is
523
509
  // accepted — the user's own kit copy elsewhere is legitimate shape-wise; entries OUTSIDE the
@@ -794,7 +780,7 @@ export const screenAllowlistEntry = (pattern) => {
794
780
  };
795
781
 
796
782
  /**
797
- * Derive the opt-in kit-tools tier for a project: 9 wildcard entries (resolved-absolute script
783
+ * Derive the opt-in kit-tools tier for a project: 12 wildcard entries (resolved-absolute script
798
784
  * path + args wildcard), ONE exact run-gates entry pinned to the resolved project root, and the
799
785
  * writer-preview exact dry-run byte-strings. Pure derivation — a stale path simply prompts again.
800
786
  */