@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
@@ -51,7 +51,7 @@ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-confi
51
51
  import { deriveDoctorPlan } from './autonomy-doctor.mjs';
52
52
  import { detectBackends, findOnPath } from './detect-backends.mjs';
53
53
  import { isDirectRun } from './direct-run.mjs';
54
- import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
54
+ import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
55
55
  import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
56
56
  import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
57
57
  import { shellQuoteArg } from './review-state.mjs';
@@ -82,7 +82,9 @@ import { loadConfig } from './orchestration-config.mjs';
82
82
  import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
83
83
  import { assertContainedRealPath } from './fs-safe.mjs';
84
84
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
85
- import { preflightCheapAgents } from './cheap-agents.mjs';
85
+ import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
86
+ // The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
87
+ import { surveyExecutorVehicle, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
86
88
  // The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
87
89
  // (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
88
90
  import {
@@ -176,6 +178,10 @@ export const SEVERITIES = Object.freeze({
176
178
  'mcp-channel.masked': SEVERITY_OPTIONAL,
177
179
  'mcp-channel.differing': SEVERITY_ATTENTION,
178
180
  agents: SEVERITY_OPTIONAL,
181
+ // The `agents` row above is an OFFER to place vehicles. This one reports a config that ALREADY
182
+ // names the subagent carrier over a vehicle that cannot carry it — a configured declaration that
183
+ // is broken, which is what attention means.
184
+ 'executor-vehicle': SEVERITY_ATTENTION,
179
185
  'family-freshness': SEVERITY_ATTENTION,
180
186
  'adr-store-migration': SEVERITY_ATTENTION,
181
187
  'sandbox-masks': SEVERITY_OPTIONAL,
@@ -250,7 +256,8 @@ export const WHATS = Object.freeze({
250
256
  'mcp-channel': "the kit's read-only MCP server is not registered here — path questions and literal searches stay shell strings",
251
257
  'mcp-channel.masked': '{rel} is a {className} here (a sandbox device mask is the usual cause), so the entry to merge is printed instead',
252
258
  'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
253
- agents: '{n} read-only subagent(s) not placed (Claude Code) — no shell-free vehicle for that work; the apply PREVIEWS first',
259
+ agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
260
+ 'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
254
261
  'family-freshness': '{parts}',
255
262
  'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
256
263
  'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
@@ -309,7 +316,8 @@ export const BENEFITS = Object.freeze({
309
316
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
310
317
  'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
311
318
  'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
312
- agents: 'cost and quiet — mechanical work runs on a cheap model, and no vehicle has a shell, so a read-only fan-out cannot flood you with prompts',
319
+ agents: 'cost and quiet — cheap-model mechanical work, no shell on a read-only vehicle (no prompt flood); the executor carries slices you verify',
320
+ 'executor-vehicle': 'carrier readiness — a slot you configured subagent dispatches the subagent it names instead of silently running solo',
313
321
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
314
322
  'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
315
323
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
@@ -367,6 +375,10 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
367
375
  // which is why the review-recipe benefit is worded for either slot rather than for review alone.
368
376
  { id: 'delegated-execution', mode: 'set-recipe', advisorKey: 'review-recipe' },
369
377
  { id: 'agents', mode: 'agents', advisorKey: 'agents' },
378
+ // A DISTINCT capability from the offer above: the offer converges the moment nothing is left to
379
+ // PLACE, which a customized-but-unusable executor also satisfies — so it can never observe the
380
+ // state that makes a configured subagent carrier run solo.
381
+ { id: 'executor-vehicle', mode: 'agents', advisorKey: 'executor-vehicle' },
370
382
  // Exempt, not un-audited. `acceptEdits` auto-applies Edit/Write and auto-runs mkdir/touch/mv/cp:
371
383
  // a TRUST-POSTURE change. The kit never nudges a user toward weakening their approval posture (the
372
384
  // same doctrine that keeps sandbox network/filesystem allowances HAND-APPLY); velocity presents the
@@ -477,11 +489,12 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
477
489
  // The VALIDATED reader (Segment B): a schema-invalid config (unknown activity/slot,
478
490
  // bad recipe) throws here and becomes a stated skip — raw JSON.parse would silently ignore it.
479
491
  const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
480
- const detection = detectBackends(deps);
492
+ const readiness = composeReadiness(root, { ...deps, detect: deps.detect ?? (() => detectBackends(deps)), onDetectError: (err) => { throw err; } });
481
493
  const degraded = [];
482
494
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
483
495
  for (const slot of Object.keys(def.slots)) {
484
- const r = resolveActivityRecipe({ config, readiness: detection, activity, slot });
496
+ if (config?.[activity]?.[slot] === 'subagent') continue; // the executor vehicle has its own probe
497
+ const r = resolveActivityRecipe({ config, readiness, activity, slot });
485
498
  if (r.degradedFrom) degraded.push(`${activity}.${slot}: configured ${r.degradedFrom} degrades to ${r.recipe} (${r.reason})`);
486
499
  }
487
500
  }
@@ -952,11 +965,9 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
952
965
  // skipped the per-vehicle plan the user is supposed to see before consenting.
953
966
  // The hidden-mode reconcile rides the detail, never the apply line: it is wrong to run on a
954
967
  // VISIBLE deployment, and the apply slot must stay one pure executable command.
955
- add(
956
- 'agents',
957
- fillTemplate(WHATS.agents, { n: toPlace.length }),
958
- `node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`,
959
- 'agents',
968
+ const executors = toPlace.filter((item) => item.name === EXECUTOR_VEHICLE).length;
969
+ add('agents', fillTemplate(WHATS.agents, { n: toPlace.length, ex: executors, ro: toPlace.length - executors }),
970
+ `node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`, 'agents',
960
971
  `hidden-mode deployments only: after the --apply the preview prints, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
961
972
  );
962
973
  } catch (err) {
@@ -964,6 +975,60 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
964
975
  }
965
976
  };
966
977
 
978
+ // The subagent carrier's ONE instrument (contract: kit/carriers). Neither neighbour can report this
979
+ // state: `probeReviewRecipe` skips a slot configured `subagent` by construction, and the offer above
980
+ // judges only what is left to PLACE — a customized-but-broken vehicle leaves nothing to place. So a
981
+ // project could declare the carrier and have every such slot run solo, unseen.
982
+ const VEHICLE_BROKEN_STATES = Object.freeze(['missing', 'unusable']);
983
+
984
+ const writerBlock = (root, deps) => {
985
+ try {
986
+ preflightCheapAgents({ cwd: root }, deps);
987
+ return null;
988
+ } catch (err) {
989
+ return safeLine(err?.message ?? String(err));
990
+ }
991
+ };
992
+
993
+ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
994
+ try {
995
+ // The VALIDATED reader, as probeReviewRecipe uses it: a schema-invalid config is a stated skip,
996
+ // never an item computed over a shape nothing accepted.
997
+ const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
998
+ const configured = Object.entries(ACTIVITIES).flatMap(([activity, def]) =>
999
+ Object.keys(def.slots).filter((slot) => config?.[activity]?.[slot] === 'subagent'));
1000
+ if (configured.length === 0) return;
1001
+ const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(root, deps);
1002
+ if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return;
1003
+ const reason = safeLine(survey.reason ?? '');
1004
+ const stamp = readStamp(join(root, WORKFLOW_STAMP), readFsDeps(deps));
1005
+ const preconditions = [
1006
+ ...(stamp === EXPECTED_WORKFLOW_VERSION ? [] : [`run /agent-workflow-kit upgrade first (deployment stamp ${stamp ?? 'none'}, expected ${EXPECTED_WORKFLOW_VERSION})`]),
1007
+ ...(survey.state === 'unusable' ? [`${reason || 'the vehicle file is unusable'} — fix that`] : []),
1008
+ ];
1009
+ // The writer refuses on ANY vehicle path it cannot touch (a symlinked read-only vehicle blocks a
1010
+ // missing executor's placement too), so its own preflight is the last precondition — named once.
1011
+ const blocked = writerBlock(root, deps);
1012
+ if (blocked && blocked !== reason) preconditions.push(`${blocked} — fix that`);
1013
+ const room = templateBudget(WHATS['executor-vehicle']) - String(configured.length).length - survey.state.length - 2;
1014
+ add(
1015
+ 'executor-vehicle',
1016
+ fillTemplate(WHATS['executor-vehicle'], {
1017
+ n: configured.length,
1018
+ state: survey.state,
1019
+ reason: reason && room > 0 ? `: ${truncatedTo(reason, room)}` : '',
1020
+ }),
1021
+ // The writer places a MISSING vehicle; an unusable path (a symlink, a read-only customization)
1022
+ // is kept or refused, so that state's apply is a hand-apply precondition before the writer.
1023
+ `${preconditions.length ? `HAND-APPLY: ${preconditions.join('; ')}, then run: ` : ''}node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
1024
+ 'executor-vehicle',
1025
+ `hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
1026
+ );
1027
+ } catch (err) {
1028
+ skip('executor-vehicle', err);
1029
+ }
1030
+ };
1031
+
967
1032
  const probeFamilyFreshness = ({ deps, add, skip }) => {
968
1033
  try {
969
1034
  const survey = deps.surveyFamily ?? surveyFamily;
@@ -1485,6 +1550,7 @@ const PROBES = Object.freeze([
1485
1550
  probeReadLane,
1486
1551
  probeStateBlockHook,
1487
1552
  probeCheapAgents,
1553
+ probeExecutorVehicle,
1488
1554
  probeFamilyFreshness,
1489
1555
  probeAdrStore,
1490
1556
  probeMasksItem,
@@ -20,6 +20,10 @@ const SETTINGS_COL = 14;
20
20
  // checks, so a future token joins the render by joining this line.
21
21
  const ACTIONABLE_ADR_LAYOUTS = Object.freeze(['old', 'old-unrotated']);
22
22
 
23
+ const DEGRADE_ARROW = '←';
24
+ const EMPTY_CELL = '—';
25
+ const UNKNOWN_EXECUTOR = 'unknown';
26
+
23
27
  const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
24
28
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
25
29
  export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
@@ -116,13 +120,22 @@ const renderSettings = (vm, { color, glyph }) => {
116
120
  const s = vm.project?.settings;
117
121
  if (!s) return [];
118
122
  const lines = ['', heading(BLOCK_TITLES.settings, color)];
119
- // recipes — the effective recipe per slot, or a loud error; a detector floor adds a sub-line.
123
+ // recipes — one line per slot: the effective recipe, where it came from, and the requested value
124
+ // a degrade replaced (a joined single line could not carry three activities and their slots), or a
125
+ // loud error; a detector floor adds a sub-line.
120
126
  if (s.recipes?.error) lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}error: ${s.recipes.error}`);
121
127
  else if (s.recipes) {
122
- const joined = s.recipes.pairs.map((p) => `${p.key}=${p.recipe}`).join(' · ') || '—';
123
- lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}${joined}`);
128
+ const rows = s.recipes.pairs.map((p) => {
129
+ const source = p.source ? ` (${p.source})` : '';
130
+ const recovery = p.degradedFrom === 'subagent' ? ' — runs solo until the executor vehicle is usable (/agent-workflow-kit agents)' : '';
131
+ const degraded = p.degradedFrom ? ` ${DEGRADE_ARROW} degraded from ${p.degradedFrom}${recovery}` : '';
132
+ return `${p.key}: ${p.recipe}${source}${degraded}`;
133
+ });
134
+ (rows.length ? rows : [EMPTY_CELL]).forEach((row, i) => {
135
+ lines.push(` ${pad(i === 0 ? SETTINGS_LABELS.recipes : '', SETTINGS_COL)}${row}`);
136
+ });
124
137
  if (s.recipes.detectError) {
125
- lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
138
+ lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); bridge-backed recipes floored at solo; the executor vehicle is unaffected`);
126
139
  }
127
140
  }
128
141
  // attribution — effective includeCoAuthoredBy; a real local override is called out.
@@ -136,10 +149,17 @@ const renderSettings = (vm, { color, glyph }) => {
136
149
  else if (s.velocity) {
137
150
  lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
138
151
  }
139
- // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle.
140
- if (s.agents?.error) lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}`);
152
+ // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle, then the
153
+ // split the subagent carrier turns on — the read-only vehicles and the ONE executor, whose state
154
+ // decides whether a slot configured `subagent` can ride it (an unusable one carries its reason).
155
+ if (s.agents?.error) {
156
+ const partial = s.agents.executor ? ` — executor ${s.agents.executor}${s.agents.executorReason ? ` (${s.agents.executorReason})` : ''}` : '';
157
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}${partial}`);
158
+ }
141
159
  else if (s.agents) {
142
- lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}placed=${s.agents.placed}/${s.agents.bundled}`);
160
+ const reason = s.agents.executorReason ? ` (${s.agents.executorReason})` : '';
161
+ const executor = s.agents.executor == null ? `${UNKNOWN_EXECUTOR} (the installed kit predates the field)` : `${s.agents.executor}${reason}`;
162
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}${s.agents.placed}/${s.agents.bundled} placed — ${s.agents.readOnly} read-only, executor ${executor}`);
143
163
  }
144
164
  // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
145
165
  // declared gate count (null → '?' — unknown is shown as unknown, never as a number).
@@ -20,3 +20,43 @@ export const lexicalRepoRelative = (rel) => {
20
20
 
21
21
  // POSIX single-quote for pasteable command rendering (display only — never an execution boundary).
22
22
  export const shellQuoteArg = (s) => (/^[A-Za-z0-9_/.\-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
23
+
24
+ // The bytes a settings-level allow rule cannot see past: command separators, redirections,
25
+ // expansions and globs. ONE home for the seeder (velocity-profile.mjs, the allow-rule writer) and
26
+ // the renders that must spell a path in the seeded byte-form (procedures.mjs) — two predicates for
27
+ // one rule drift apart, and a drifted render is a dead allow rule that simply prompts.
28
+ export const SHELL_METACHARACTERS = Object.freeze([
29
+ '&', '|', ';', '<', '>', '$', '`', '(', ')',
30
+ '\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
31
+ ]);
32
+ export const hasShellMetacharacter = (cmd) => SHELL_METACHARACTERS.some((ch) => cmd.includes(ch));
33
+
34
+ // A string a one-line render can carry: no control character (C0 or C1) and no Unicode line or
35
+ // paragraph separator. The receipt-derived fields are REFUSED on it; a plan name is escaped for display.
36
+ const LINE_BREAKING_SOURCE = '[\\p{Cc}\\p{Zl}\\p{Zp}]';
37
+ const LINE_BREAKING = new RegExp(LINE_BREAKING_SOURCE, 'u');
38
+ const LINE_BREAKING_ALL = new RegExp(LINE_BREAKING_SOURCE, 'gu');
39
+ export const isRenderableLine = (value) => typeof value === 'string' && !LINE_BREAKING.test(value);
40
+ export const escapeForDisplay = (value) => String(value).replace(LINE_BREAKING_ALL, (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`);
41
+
42
+ // The receipt encoder's carriability rule for an artifact path (S21), the JS twin of the wrappers'
43
+ // refuse_uncarriable_artifact_byte: a quote, a backslash, a C0 control or DEL — deliberately NOT
44
+ // \p{Cc} (C1 is the declared residual, and the two normalizations are parity-pinned on this set).
45
+ // ONE home: the round table's refusal names the byte, the advisor's fallback reads the boolean.
46
+ export const uncarriableArtifactByte = (value) =>
47
+ value.includes('"') ? 'a double quote' : value.includes('\\') ? 'a backslash' : /[\u0000-\u001f\u007f]/u.test(value) ? 'a control' : null;
48
+ export const isArtifactPathCarriable = (value) => typeof value === 'string' && uncarriableArtifactByte(value) === null;
49
+
50
+ // Characters that survive whitespace tokenization but break an UNQUOTED byte-exact path rule:
51
+ // shell quoting syntax and glob brackets (SHELL_METACHARACTERS owns the command-level separators/
52
+ // redirections/expansions — `*`/`?` globs included — but not these four).
53
+ const PATH_BREAKING_CHARACTERS = Object.freeze(["'", '"', '[', ']']);
54
+
55
+ // A path token that can be seeded UNQUOTED into a byte-exact allow rule: POSIX-absolute, no
56
+ // whitespace, no shell metacharacter, no quoting/glob syntax.
57
+ export const isSeedablePathToken = (token) =>
58
+ typeof token === 'string' &&
59
+ token.startsWith('/') &&
60
+ !/\s/u.test(token) &&
61
+ !hasShellMetacharacter(token) &&
62
+ !PATH_BREAKING_CHARACTERS.some((ch) => token.includes(ch));
@@ -0,0 +1,104 @@
1
+ import { safeLine, REVIEW_CMD_ALIASES } from './carriers.mjs';
2
+ import { READY, NEEDS_SKILL, NEEDS_CLI, NEEDS_CREDENTIALS, DEGRADED } from './detect-backends.mjs';
3
+ import { parseSlotToken, validateRoster } from './review-roster.mjs';
4
+ import { refuseDirectRun } from './direct-run.mjs';
5
+
6
+ const READY_STATES = new Set([READY, 'placed', 'customized']);
7
+
8
+ const setupTarget = (entry) => entry?.setupHint?.local ?? entry?.setupHint?.url ?? null;
9
+
10
+ export const remedyFor = (entry = {}) => {
11
+ const setup = setupTarget(entry);
12
+ if (entry.readiness === NEEDS_SKILL) return setup ? `bridge skill not installed — ${safeLine(setup)}` : 'bridge skill not installed — run /agent-workflow-kit setup';
13
+ if (entry.readiness === NEEDS_CLI) return setup ? `the CLI is not installed — ${safeLine(setup)}` : 'the CLI is not installed';
14
+ if (entry.readiness === NEEDS_CREDENTIALS) return 'not signed in (credentials missing)';
15
+ if (entry.readiness === DEGRADED) return 'wrapper not on PATH — run /agent-workflow-kit setup';
16
+ return entry.readiness ? safeLine(entry.readiness) : 'bridge readiness unavailable';
17
+ };
18
+
19
+ export const lensVehicleSpec = (member) => {
20
+ const parsed = typeof member === 'string' ? parseSlotToken(member) : member;
21
+ if (parsed.kind !== 'lens') throw new Error(`not a lens member: ${parsed.member ?? member}`);
22
+ return {
23
+ stem: parsed.stem,
24
+ template: parsed.template,
25
+ model: parsed.model,
26
+ effort: parsed.effort,
27
+ tools: 'read-only',
28
+ derived: parsed.derived,
29
+ };
30
+ };
31
+
32
+ export const deriveLensTemplate = (template, spec) => {
33
+ if (!spec?.derived) return String(template);
34
+ return String(template)
35
+ .replace(/^name:.*$/mu, `name: ${spec.stem}`)
36
+ .replace(/^model:.*$/mu, `model: ${spec.model}`)
37
+ .replace(/^effort:.*$/mu, `effort: ${spec.effort}`);
38
+ };
39
+
40
+ const postureValue = (postures, receiptId) => {
41
+ const value = postures?.[receiptId];
42
+ if (typeof value === 'string') return value;
43
+ return value?.state === 'valid' ? value.posture : null;
44
+ };
45
+
46
+ const bridgeRow = (parsed, readiness, postures) => {
47
+ const alias = REVIEW_CMD_ALIASES[parsed.instrument];
48
+ const entry = readiness.find((candidate) => candidate?.name === alias.backend);
49
+ const state = entry?.readiness ?? NEEDS_SKILL;
50
+ return {
51
+ member: parsed.member,
52
+ stem: parsed.stem,
53
+ kind: 'bridge',
54
+ state,
55
+ reason: state === READY ? null : remedyFor({ ...entry, readiness: state }),
56
+ posture: postureValue(postures, parsed.stem),
57
+ };
58
+ };
59
+
60
+ const lensRow = (parsed, surveyLens) => {
61
+ if (typeof surveyLens !== 'function') {
62
+ return {
63
+ member: parsed.member, stem: parsed.stem, kind: 'lens', state: 'unsurveyed',
64
+ reason: null, posture: null,
65
+ };
66
+ }
67
+ const survey = surveyLens(lensVehicleSpec(parsed)) ?? {};
68
+ const state = survey.state ?? 'unusable';
69
+ const posture = READY_STATES.has(state) && survey.model && survey.effort
70
+ ? `model=${safeLine(survey.model)} effort=${safeLine(survey.effort)}`
71
+ : null;
72
+ return {
73
+ member: parsed.member,
74
+ stem: parsed.stem,
75
+ kind: 'lens',
76
+ state,
77
+ reason: survey.reason == null ? null : safeLine(survey.reason),
78
+ posture,
79
+ };
80
+ };
81
+
82
+ export const resolveRoster = ({ value, readiness = [], surveyLens, postures } = {}) => {
83
+ validateRoster(value);
84
+ return value.map((member) => {
85
+ const parsed = parseSlotToken(member);
86
+ return parsed.kind === 'bridge'
87
+ ? bridgeRow(parsed, readiness, postures)
88
+ : lensRow(parsed, surveyLens);
89
+ });
90
+ };
91
+
92
+ export const isReadyMember = (row) => READY_STATES.has(row.state);
93
+
94
+ export const skippedLine = (row, remedy = row.reason) =>
95
+ `skipped this round — ${safeLine(row.state)}: ${remedy ?? 'no remedy recorded'}`;
96
+
97
+ export const rosterLabel = (roster, { states = true } = {}) => roster.map((row) => {
98
+ if (!states || isReadyMember(row)) return row.member;
99
+ return `${row.member} (${safeLine(row.state)})`;
100
+ }).join(' + ');
101
+
102
+ export const activeLineCell = (roster, options) => `[${rosterLabel(roster, options)}]`;
103
+
104
+ refuseDirectRun(import.meta.url);
@@ -0,0 +1,128 @@
1
+ import { REVIEW_CMD_ALIASES, receiptIdOfCmd, LENS_VERDICTS } from './carriers.mjs';
2
+ import { KNOWN_BACKENDS } from './detect-backends.mjs';
3
+ import { refuseDirectRun } from './direct-run.mjs';
4
+
5
+ export { LENS_VERDICTS };
6
+
7
+ export const BUNDLED_LENS_TEMPLATES = Object.freeze(['review-lens']);
8
+ const MEMBER_TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
9
+ const SUFFIX_TOKEN = /^[a-z0-9]+$/u;
10
+
11
+ const reviewCommands = () => KNOWN_BACKENDS
12
+ .map((backend) => backend.roleCmds?.review)
13
+ .filter(Boolean);
14
+
15
+ const failRoster = (message, code = null) => Object.assign(new Error(`review roster: ${message}`), { code });
16
+
17
+ export const parseSlotToken = (member) => {
18
+ if (typeof member !== 'string' || member.length === 0) {
19
+ throw failRoster('every member must be a non-empty string');
20
+ }
21
+ const bridgeCommands = reviewCommands();
22
+ if (bridgeCommands.includes(member)) {
23
+ const alias = REVIEW_CMD_ALIASES[member];
24
+ if (!alias) throw failRoster(`bridge member "${member}" has no receipt alias`);
25
+ return {
26
+ member,
27
+ instrument: member,
28
+ kind: 'bridge',
29
+ stem: alias.receiptId,
30
+ model: null,
31
+ effort: null,
32
+ template: null,
33
+ derived: false,
34
+ };
35
+ }
36
+ const parts = member.split(':');
37
+ if (parts.length !== 1 && parts.length !== 3) {
38
+ if (bridgeCommands.includes(parts[0])) throw failRoster(`bridge member "${parts[0]}" takes no suffix`);
39
+ throw failRoster(`member "${member}" must be a bare stem or carry both model and effort`);
40
+ }
41
+ const [instrument, model = null, effort = null] = parts;
42
+ if (bridgeCommands.includes(instrument)) throw failRoster(`bridge member "${instrument}" takes no suffix`);
43
+ if (!MEMBER_TOKEN.test(instrument)) throw failRoster(`member instrument "${instrument}" is not a slug`);
44
+ if (model !== null && (!SUFFIX_TOKEN.test(model) || !SUFFIX_TOKEN.test(effort))) {
45
+ throw failRoster(`member "${member}" has an invalid model or effort token`);
46
+ }
47
+ if (model !== null && !BUNDLED_LENS_TEMPLATES.includes(instrument)) {
48
+ throw failRoster(`derived member "${member}" has no bundled lens template`);
49
+ }
50
+ return {
51
+ member,
52
+ instrument,
53
+ kind: 'lens',
54
+ stem: model === null ? instrument : `${instrument}-${model}-${effort}`,
55
+ model,
56
+ effort,
57
+ template: BUNDLED_LENS_TEMPLATES.includes(instrument) ? instrument : null,
58
+ derived: model !== null,
59
+ };
60
+ };
61
+
62
+ export const validateRoster = (value) => {
63
+ if (!Array.isArray(value)) throw failRoster('value must be an array');
64
+ if (value.length === 0) throw failRoster('array must not be empty');
65
+ const parsed = value.map(parseSlotToken);
66
+ const seen = new Set();
67
+ for (const member of parsed) {
68
+ if (seen.has(member.stem)) throw failRoster(`duplicate resolved stem "${member.stem}"`);
69
+ seen.add(member.stem);
70
+ }
71
+ return value;
72
+ };
73
+
74
+ export const expandShorthand = (value) => {
75
+ if (value === 'solo') return { lossless: true, members: [] };
76
+ if (value === 'council') return { lossless: true, members: reviewCommands() };
77
+ return { lossless: false, members: null };
78
+ };
79
+
80
+ export const bridgeMembersOf = (value) => validateRoster(value)
81
+ .filter((member) => parseSlotToken(member).kind === 'bridge');
82
+
83
+ export const lensMembersOf = (value) => {
84
+ const values = Array.isArray(value)
85
+ ? [value]
86
+ : Object.values(value ?? {}).flatMap((activity) => (
87
+ Array.isArray(activity?.review) ? [activity.review] : []
88
+ ));
89
+ return values.flat().filter((member) => parseSlotToken(member).kind === 'lens');
90
+ };
91
+
92
+ export const obligationsOf = (value) => {
93
+ const backends = bridgeMembersOf(value).map(receiptIdOfCmd);
94
+ if (backends.length === 0) return { recipe: 'solo', backends, minShip: 0, perBackend: false };
95
+ return {
96
+ recipe: backends.length === 1 ? 'reviewed' : 'council',
97
+ backends,
98
+ minShip: 1,
99
+ perBackend: true,
100
+ };
101
+ };
102
+
103
+ const explicitMembers = (value) => {
104
+ if (Array.isArray(value)) return [...value];
105
+ const expanded = expandShorthand(value);
106
+ if (!expanded.lossless) throw failRoster('reviewed has no lossless roster expansion');
107
+ return [...expanded.members];
108
+ };
109
+
110
+ export const addReviewer = (value, member) => {
111
+ const next = explicitMembers(value);
112
+ const parsed = parseSlotToken(member);
113
+ if (next.some((entry) => parseSlotToken(entry).stem === parsed.stem)) return next;
114
+ next.push(member);
115
+ validateRoster(next);
116
+ return next;
117
+ };
118
+
119
+ export const removeReviewer = (value, member) => {
120
+ const next = explicitMembers(value);
121
+ const stem = parseSlotToken(member).stem;
122
+ const filtered = next.filter((entry) => parseSlotToken(entry).stem !== stem);
123
+ if (next.length > 0 && filtered.length === 0) throw failRoster('removing the last member requires the solo shorthand', 'last-member');
124
+ if (filtered.length > 0) validateRoster(filtered);
125
+ return filtered;
126
+ };
127
+
128
+ refuseDirectRun(import.meta.url);
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { isDirectRun } from './direct-run.mjs';
6
+ import { uncarriableArtifactByte } from './repo-lex.mjs';
7
+ import { readReceipts, resolveReceiptsPath } from './core-evidence.mjs';
8
+ import { loadConfig } from './orchestration-config.mjs';
9
+ import { ACTIVITIES, composeReadiness, requiredBackendsForConfiguredRecipe } from './recipes.mjs';
10
+ import { groupRounds, renderRounds } from './review-rounds.mjs';
11
+
12
+ const REVIEW_ACTIVITIES = new Set(Object.entries(ACTIVITIES).filter(([, def]) => Object.hasOwn(def.slots, 'review')).map(([name]) => name));
13
+
14
+ const assertArtifactPathCarryable = (value) => {
15
+ const byte = uncarriableArtifactByte(value);
16
+ if (byte !== null) throw new Error(`artifact path contains ${byte} byte, which the receipt encoder cannot carry`);
17
+ };
18
+
19
+ const gitTopLevel = (cwd, run = spawnSync) => {
20
+ const result = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
21
+ return result.status === 0 ? result.stdout.replace(/\r?\n$/u, '') : null;
22
+ };
23
+
24
+ export const normalizeArtifactPath = (path, { cwd = process.cwd(), run = spawnSync, realpath = realpathSync, top = gitTopLevel(cwd, run) } = {}) => {
25
+ assertArtifactPathCarryable(path);
26
+ const absolute = realpath(resolve(cwd, path));
27
+ const root = top === null ? null : realpath(top);
28
+ const rel = root === null ? null : relative(root, absolute);
29
+ const contained = rel !== null && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
30
+ const normalized = contained ? rel.split(sep).join('/') : absolute.split(sep).join('/');
31
+ assertArtifactPathCarryable(normalized);
32
+ return normalized;
33
+ };
34
+
35
+ const parseArgs = (argv) => {
36
+ const values = { activity: 'plan-authoring', artifact: null };
37
+ for (const [index, arg] of argv.entries()) {
38
+ if (arg === '--artifact' || arg === '--activity') {
39
+ const value = argv[index + 1];
40
+ if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
41
+ if (arg === '--artifact') values.artifact = value;
42
+ else values.activity = value;
43
+ continue;
44
+ }
45
+ if (index > 0 && (argv[index - 1] === '--artifact' || argv[index - 1] === '--activity')) continue;
46
+ throw new Error(`unknown argument: ${arg}`);
47
+ }
48
+ if (values.artifact === null) throw new Error('--artifact needs a <path>');
49
+ if (!REVIEW_ACTIVITIES.has(values.activity)) throw new Error(`--activity must be one of ${[...REVIEW_ACTIVITIES].join(', ')}, got ${values.activity}`);
50
+ return values;
51
+ };
52
+
53
+ // Every refusal exits 2: usage, an unreadable store, a malformed config, a detection failure.
54
+ export const main = (argv, deps = {}) => {
55
+ try {
56
+ const cwd = deps.cwd ?? process.cwd();
57
+ const env = deps.env ?? process.env;
58
+ const { artifact, activity } = parseArgs(argv);
59
+ const run = deps.run ?? spawnSync;
60
+ const top = gitTopLevel(cwd, run);
61
+ const artifactPath = normalizeArtifactPath(artifact, { cwd, run, top });
62
+ const root = top ?? cwd;
63
+ const { config } = loadConfig(root);
64
+ const detection = { failed: false };
65
+ const readiness = config?.[activity]?.review == null
66
+ ? composeReadiness(root, { onDetectError: () => { detection.failed = true; }, ...(deps.readinessDeps ?? {}) })
67
+ : [];
68
+ const obligation = requiredBackendsForConfiguredRecipe({ config, readiness, detectionFailed: detection.failed, activity });
69
+ if (obligation.unknowable) throw new Error('backend detection failed — the review obligation is unknowable');
70
+ const receiptsPath = resolveReceiptsPath(root, env);
71
+ if (receiptsPath === null) throw new Error('the review receipts store cannot be resolved (not a git work tree and AW_REVIEW_RECEIPTS is unset)');
72
+ const read = readReceipts(receiptsPath);
73
+ if (read.readError !== undefined) throw new Error(`the review receipts store is unreadable: ${read.readError}`);
74
+ const selected = read.receipts.filter((receipt) => receipt.artifactPath === artifactPath);
75
+ const pathless = read.receipts.filter((receipt) => ['plan', 'diff'].includes(receipt.artifact) && !Object.hasOwn(receipt, 'artifactPath')).length;
76
+ const grouped = groupRounds(selected, obligation);
77
+ return {
78
+ code: 0,
79
+ stdout: renderRounds({ ...grouped, obligation, artifactPath, pathless, malformed: read.malformed }),
80
+ stderr: '',
81
+ };
82
+ } catch (err) {
83
+ return { code: 2, stdout: '', stderr: `review-rounds: ${err.message}` };
84
+ }
85
+ };
86
+
87
+ if (isDirectRun(import.meta.url)) {
88
+ const result = main(process.argv.slice(2));
89
+ if (result.stdout) process.stdout.write(`${result.stdout}\n`);
90
+ if (result.stderr) process.stderr.write(`${result.stderr}\n`);
91
+ process.exitCode = result.code;
92
+ }