@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
package/tools/recipes.mjs CHANGED
@@ -1,19 +1,9 @@
1
1
  #!/usr/bin/env node
2
- // Recipe planner the pure brain behind the read-only `/agent-workflow-kit recipes` advisor.
3
- //
4
- // A "recipe" is a NAMED orchestration pattern over the family's optional carriers: the
5
- // subscription-CLI bridges (codex-cli-bridge → `codex`, antigravity-cli-bridge → `agy`) and the
6
- // executor subagent vehicle. The ENGINE owns the canonical narrative
7
- // (agent-workflow-engine/references/orchestration.md, kept in lockstep by the recipe-name parity
8
- // guard in recipes.test.mjs); this module owns the EXECUTABLE dispatch. The activity/slot registry
9
- // lives in carriers.mjs, re-exported here so every importer keeps its './recipes.mjs' path.
10
- // Invariants: pure (no fs/network/CLI in planRecipe/recommendRecipe), read-only, NEVER runs a
11
- // subscription CLI. The orchestrator executes a recipe and always makes the single commit; a
12
- // backend or a subagent is advisory or delegated, never autonomous. Dependency-free, Node >= 22.
2
+ // Read-only recipe planner. The engine owns the narrative; carriers.mjs owns the activity/slot
3
+ // registry re-exported here. Pure planning functions never run a subscription CLI.
13
4
 
14
5
  import { existsSync, readFileSync } from 'node:fs';
15
6
  import { dirname, join, resolve } from 'node:path';
16
- // The READ-ONLY settings core, never the writer: this advisor never pulls in the atomic-write core.
17
7
  import { settingsSnapshot, DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
18
8
  import { isDirectRun } from './direct-run.mjs';
19
9
  import {
@@ -29,10 +19,13 @@ import {
29
19
  vehicleDegradeReason,
30
20
  EXECUTOR_APPLY,
31
21
  safeLine,
22
+ DISPLAY_ALIASES,
23
+ BACKEND_PRIORITY,
32
24
  } from './carriers.mjs';
33
- // The READ core of the vehicle surface, never the writer: this advisor can never reach a module
34
- // that creates `.claude/agents/`.
35
- import { surveyExecutorVehicle } from './cheap-agents-read.mjs';
25
+ import { surveyExecutorVehicle, surveyVehicle } from './cheap-agents-read.mjs';
26
+ import { obligationsOf } from './review-roster.mjs';
27
+ import { resolveRoster, activeLineCell, remedyFor } from './review-roster-resolve.mjs';
28
+ import { posturesByBackend } from './bridge-posture.mjs';
36
29
  import {
37
30
  detectBackends,
38
31
  wrapperCmdFor,
@@ -43,32 +36,32 @@ import {
43
36
  DEGRADED,
44
37
  } from './detect-backends.mjs';
45
38
 
46
- export { ACTIVITIES, POLICY_ACTIVITIES, SLOT_RECIPES, isSwitchSlot, EXECUTOR_APPLY, safeLine };
39
+ export { ACTIVITIES, POLICY_ACTIVITIES, SLOT_RECIPES, isSwitchSlot, EXECUTOR_APPLY, safeLine, DISPLAY_ALIASES };
47
40
 
48
- const CODEX = 'codex-cli-bridge';
49
- const AGY = 'antigravity-cli-bridge';
41
+ const [CODEX, AGY] = BACKEND_PRIORITY;
50
42
 
51
- // The manifest-name → human-alias map (the detector emits manifest names; humans say codex/agy).
52
- export const DISPLAY_ALIASES = { [CODEX]: 'codex', [AGY]: 'agy' };
53
-
54
- // The provider → role table, keyed by the name the readiness array carries, NOT the display alias.
55
- // The bridge rows are drift-guarded against each capability.json `provides[]`; the executor row is
56
- // the vehicle withVehicle appends, and the ONLY provider of `carry`.
43
+ // Keyed by readiness-array provider name; executor is the only `carry` provider.
57
44
  export const BACKEND_ROLES = {
58
45
  [CODEX]: ['execute', 'review'],
59
46
  [AGY]: ['review', 'probe'],
60
47
  [EXECUTOR_PROVIDER]: [CARRY_ROLE],
61
48
  };
62
49
 
63
- // Review obligations from the CONFIGURED plan-execution.review recipe — the RAW config value, never
64
- // the readiness-degraded one. Homed HERE so review-state and flow-check share one backend set (#42).
65
- export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], detectionFailed = false } = {}) => {
66
- const configured = config?.['plan-execution']?.review;
50
+ // Review obligations from the CONFIGURED activity's review recipe — the RAW config value, never
51
+ // the readiness-degraded one. The default preserves review-state and flow-check's backend set (#42).
52
+ export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], detectionFailed = false, activity = 'plan-execution' } = {}) => {
53
+ if (!Object.hasOwn(ACTIVITIES, activity) || !Object.hasOwn(ACTIVITIES[activity].slots, 'review')) {
54
+ throw new Error(`activity "${activity}" has no review slot`);
55
+ }
56
+ const configured = config?.[activity]?.review;
67
57
  const providers = Object.values(DISPLAY_ALIASES); // every review-capable backend, codex first
68
58
  if (configured == null && detectionFailed) {
69
59
  // No config + no readiness signal: the computed default is UNKNOWABLE — fail closed upstream.
70
60
  return { recipe: null, source: 'default', backends: [], minShip: 0, perBackend: false, unknowable: true };
71
61
  }
62
+ if (Array.isArray(configured)) {
63
+ return { ...obligationsOf(configured), source: 'config', unknowable: false };
64
+ }
72
65
  // Role-filtered: a ready EXECUTOR vehicle is a carry provider, never a reviewer, so it must not
73
66
  // turn a silent review config into `reviewed`.
74
67
  const anyReady = readyProvidersOf('review', readiness).length >= 1;
@@ -90,8 +83,6 @@ export const BACKEND_META = {
90
83
  },
91
84
  };
92
85
 
93
- // Deterministic tie-break: codex before agy — the more reliable default for substantive reviews.
94
- const BACKEND_PRIORITY = [CODEX, AGY];
95
86
  const priorityIndex = (name) => {
96
87
  const i = BACKEND_PRIORITY.indexOf(name);
97
88
  return i === -1 ? BACKEND_PRIORITY.length : i;
@@ -137,14 +128,6 @@ export const RECIPES = [
137
128
 
138
129
  const recipeById = (id) => RECIPES.find((r) => r.id === id);
139
130
 
140
- // Read-only file-presence remedies — never a claim about whether a backend's service is responsive.
141
- const READINESS_REASON = {
142
- [NEEDS_SKILL]: 'bridge skill not installed — run /agent-workflow-kit setup',
143
- [NEEDS_CLI]: 'the CLI is not installed',
144
- [NEEDS_CREDENTIALS]: 'not signed in (credentials missing)',
145
- [DEGRADED]: 'wrapper not on PATH — run /agent-workflow-kit setup',
146
- };
147
-
148
131
  // ── pure planner ───────────────────────────────────────────────────────────────
149
132
 
150
133
  const providersOf = (role, detection) => detection.filter((b) => (BACKEND_ROLES[b.name] ?? []).includes(role));
@@ -171,7 +154,7 @@ const degradeReason = (recipe, detection) => {
171
154
  .filter((b) => b.readiness !== READY)
172
155
  .map((b) => (b.name === EXECUTOR_PROVIDER
173
156
  ? vehicleDegradeReason(b.vehicle, EXECUTOR_APPLY)
174
- : `${DISPLAY_ALIASES[b.name] ?? b.name}: ${READINESS_REASON[b.readiness] ?? b.readiness}`))
157
+ : `${DISPLAY_ALIASES[b.name] ?? b.name}: ${remedyFor({ readiness: b.readiness })}`))
175
158
  .join('; ');
176
159
  return `${recipe.title} needs ${recipe.minBackends} provider(s) providing ${recipe.role}, but only ${ready.length} ready${detail ? ` — ${detail}` : ''}`;
177
160
  };
@@ -268,7 +251,7 @@ const computedDefaultForSlot = (slotType, detection) => {
268
251
  // The effective recipe for ONE slot. Precedence: an explicit `override` (degrades LOUDLY, so the
269
252
  // agent tells the user) > the `config` entry (graceful) > the computed default. Satisfiability and
270
253
  // the lattice REUSE planRecipe — one source. Pure; never mutates.
271
- export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, slot, override } = {}) => {
254
+ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, slot, override, surveyLens, postures } = {}) => {
272
255
  const activityDef = ACTIVITIES[activity];
273
256
  if (!activityDef) throw new Error(`unknown activity: ${activity}`);
274
257
  const slotType = activityDef.slots[slot];
@@ -278,6 +261,21 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
278
261
  const requested = override ?? configured ?? computedDefaultForSlot(slotType, readiness);
279
262
  const source = override != null ? 'override' : configured != null ? 'config' : 'default';
280
263
 
264
+ if (Array.isArray(requested)) {
265
+ if (slotType !== 'review' || source !== 'config') {
266
+ throw new Error(`invalid roster for ${slotType} slot of "${activity}"`);
267
+ }
268
+ const obligations = obligationsOf(requested);
269
+ return {
270
+ recipe: obligations.recipe,
271
+ source,
272
+ degradedFrom: null,
273
+ reason: null,
274
+ overrideUnsatisfied: false,
275
+ roster: resolveRoster({ value: requested, readiness, surveyLens, postures }),
276
+ };
277
+ }
278
+
281
279
  // Defensive: the IO shell and the CLI validate first, so a stray value here is a programmer error
282
280
  // — surfaced loudly rather than silently coerced into a neighbour recipe.
283
281
  if (!(SLOT_RECIPES[slotType] ?? []).includes(requested)) {
@@ -300,42 +298,14 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
300
298
  };
301
299
  };
302
300
 
303
- // ── the one-line backend status (the tool speaks, the agent pastes) ───────────────────────────────
304
- // Machine-composed so the agent composes NOTHING factual (a session once echoed SKILL.md's canonical
305
- // example while the detector said otherwise). Always exactly one line.
306
-
307
- // The CONFIGURED dispatch posture: the bundled manifests' pins overlaid with the active
308
- // bridge-settings. No pins or a corrupt bundle → null; the posture segment is a fact, never a gate.
301
+ // Configured posture from bundled manifest pins plus bridge settings; corruption returns null.
309
302
  export const composeConfiguredPosture = (ctx = {}) => {
310
303
  try {
311
- const bundleRoot = ctx.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
312
- const read = ctx.readFile ?? readFileSync;
313
- const parts = [];
314
- for (const name of BACKEND_PRIORITY) {
315
- let manifest;
316
- try {
317
- manifest = JSON.parse(String(read(join(bundleRoot, name, 'capability.json'), 'utf8')));
318
- } catch {
319
- return null; // an unreadable/corrupt manifest is CORRUPTION — a partial posture would lie
320
- }
321
- if (!Object.hasOwn(manifest, 'posture')) continue; // a pre-D5 bridge — legitimate absence
322
- const posture = manifest.posture;
323
- // A present-but-invalid block nulls the WHOLE render — never a partial/mangled line.
324
- const invalid =
325
- posture === null || typeof posture !== 'object' || Array.isArray(posture) ||
326
- typeof posture.model !== 'string' || posture.model.length === 0 ||
327
- (Object.hasOwn(posture, 'effort') && (typeof posture.effort !== 'string' || posture.effort.length === 0)) ||
328
- (Object.hasOwn(posture, 'tier') && posture.tier !== null && (typeof posture.tier !== 'string' || posture.tier.length === 0)) ||
329
- Object.keys(posture).some((k) => !['model', 'effort', 'tier'].includes(k));
330
- if (invalid) return null;
331
- const seg = [`model=${posture.model}`];
332
- if (Object.hasOwn(posture, 'effort')) seg.push(`effort=${posture.effort}`);
333
- if (Object.hasOwn(posture, 'tier')) {
334
- const knob = (ctx.settings?.active ?? []).find((s) => s.key === 'CODEX_SERVICE_TIER' && s.bridge === name);
335
- seg.push(knob ? `tier=${knob.value} (bridge-settings)` : `tier=${posture.tier ?? 'standard'}`);
336
- }
337
- parts.push(`${DISPLAY_ALIASES[name] ?? name} ${seg.join(' ')}`);
338
- }
304
+ const rows = posturesByBackend({ bundleRoot: ctx.bundleRoot ?? DEFAULT_BUNDLE_ROOT, settings: ctx.settings ?? { active: [] }, readFile: ctx.readFile ?? readFileSync });
305
+ if (Object.values(rows).some((row) => row.state === 'unreadable')) return null;
306
+ const parts = Object.entries(rows)
307
+ .filter(([, row]) => row.state === 'valid')
308
+ .map(([receiptId, row]) => `${receiptId} ${row.posture}`);
339
309
  return parts.length ? parts.join(' · ') : null;
340
310
  } catch {
341
311
  return null;
@@ -422,19 +392,25 @@ export const composeAutonomyFacts = async (cwd, deps = {}) => {
422
392
  // ONE line rendering the CONFIGURED recipe of every activity/slot with its source, degradation and
423
393
  // dispatched wrappers — contrasted with the readiness-RECOMMENDED recipe, which is NOT what runs.
424
394
  // The session-start checklist and the handover "Active recipes:" slot paste it verbatim.
425
- export const composeActiveRecipeLine = ({ config, source } = {}, detection, autonomy = null) => {
395
+ export const composeActiveRecipeLine = ({ config, source } = {}, detection, autonomy = null, rosterDeps = null) => {
426
396
  const cells = [];
427
397
  for (const [activity, def] of Object.entries(ACTIVITIES)) {
428
398
  const level = autonomy?.activities?.[activity]?.autonomy;
429
399
  const auto = level ? `; autonomy ${level}` : '';
430
400
  for (const [slot, slotType] of Object.entries(def.slots)) {
431
- const r = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot });
432
- const dispatch = isSwitchSlot(slotType) ? [] : planRecipe(r.recipe, detection).dispatch;
401
+ const r = resolveActivityRecipe({
402
+ config: config ?? {}, readiness: detection, activity, slot,
403
+ surveyLens: rosterDeps?.surveyLens, postures: rosterDeps?.postures,
404
+ });
405
+ const dispatch = isSwitchSlot(slotType) || r.roster ? [] : planRecipe(r.recipe, detection).dispatch;
433
406
  const wrappers = dispatch.map((d) => wrapperCmdFor(d.backend, d.role)).filter(Boolean);
434
407
  const srcLabel = r.source === 'config' ? 'configured' : 'computed default';
408
+ const renderedValue = r.roster
409
+ ? activeLineCell(r.roster, { states: rosterDeps !== null })
410
+ : r.recipe;
435
411
  const head = r.degradedFrom
436
412
  ? `${activity}.${slot} = ${r.degradedFrom} (${srcLabel}; degrades here to ${r.recipe} — ${r.reason}${auto})`
437
- : `${activity}.${slot} = ${r.recipe} (${srcLabel}${isSwitchSlot(slotType) ? '; switch' : ''}${auto})`;
413
+ : `${activity}.${slot} = ${renderedValue} (${srcLabel}${isSwitchSlot(slotType) ? '; switch' : ''}${auto})`;
438
414
  const suffix =
439
415
  wrappers.length >= 2
440
416
  ? ` → every backend every round: ${wrappers.join(' + ')}`
@@ -553,7 +529,12 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
553
529
  // Lazy: orchestration-config.mjs statically imports this module — no static cycle.
554
530
  const { loadConfig } = await import('./orchestration-config.mjs');
555
531
  try {
556
- console.log(composeActiveRecipeLine(loadConfig(cwd), readiness, await composeAutonomyFacts(cwd)));
532
+ const snapshot = settingsSnapshot();
533
+ const surveyLens = deps.surveyLens ?? ((spec) => surveyVehicle(cwd, spec, deps));
534
+ console.log(composeActiveRecipeLine(
535
+ loadConfig(cwd), readiness, await composeAutonomyFacts(cwd),
536
+ { surveyLens, postures: posturesByBackend({ settings: snapshot }) },
537
+ ));
557
538
  } catch (err) {
558
539
  console.error(`[agent-workflow-kit] ${err.message}`);
559
540
  return err.exitCode ?? 1;
@@ -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
+ }