role-os 2.8.0 → 2.9.1

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 (49) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.es.md +35 -12
  3. package/README.fr.md +32 -9
  4. package/README.hi.md +32 -9
  5. package/README.it.md +36 -13
  6. package/README.ja.md +33 -10
  7. package/README.md +32 -9
  8. package/README.pt-BR.md +32 -9
  9. package/README.zh.md +32 -9
  10. package/bin/roleos.mjs +3 -2
  11. package/package.json +1 -1
  12. package/src/artifacts.mjs +14 -7
  13. package/src/audit-cmd.mjs +23 -23
  14. package/src/brainstorm-roles.mjs +6 -0
  15. package/src/citation-panel.mjs +26 -1
  16. package/src/composite.mjs +4 -0
  17. package/src/dispatch.mjs +3 -1
  18. package/src/dossier-block.mjs +74 -0
  19. package/src/entry.mjs +2 -2
  20. package/src/hooks.mjs +107 -27
  21. package/src/knowledge/analyze-artifact-evidence.mjs +19 -9
  22. package/src/knowledge/fallback-policy.mjs +19 -7
  23. package/src/knowledge/resolve-overlay.mjs +21 -8
  24. package/src/knowledge/retrieve-for-dispatch.mjs +9 -4
  25. package/src/mission-run.mjs +11 -2
  26. package/src/packs-cmd.mjs +1 -1
  27. package/src/review.mjs +11 -2
  28. package/src/role-dossiers.json +962 -0
  29. package/src/route.mjs +41 -8
  30. package/src/run-cmd.mjs +0 -1
  31. package/src/run.mjs +67 -15
  32. package/src/session.mjs +3 -1
  33. package/src/specialist/capability-gate.mjs +35 -18
  34. package/src/specialist/dispatch.mjs +8 -3
  35. package/src/specialist/registry.mjs +6 -0
  36. package/src/specialist/shadow.mjs +13 -3
  37. package/src/specialist/state.mjs +94 -26
  38. package/src/state-machine.mjs +2 -2
  39. package/src/status.mjs +4 -2
  40. package/src/swarm/build-gate.mjs +11 -2
  41. package/src/swarm/persist-bridge.mjs +4 -3
  42. package/src/swarm-cmd.mjs +88 -46
  43. package/src/verify-citations-cmd.mjs +17 -1
  44. package/src/verify-citations.mjs +31 -7
  45. package/starter-pack/README.md +22 -14
  46. package/starter-pack/handbook.md +4 -4
  47. package/starter-pack/policy/routing-rules.md +42 -0
  48. package/starter-pack/policy/tool-permissions.md +21 -0
  49. package/starter-pack/workflows/full-treatment.md +27 -16
package/src/hooks.mjs CHANGED
@@ -95,11 +95,7 @@ const SESSION_STATE_FILE = ".claude/hooks/session-state.json";
95
95
  */
96
96
  export function getSessionState(cwd) {
97
97
  const path = join(cwd, SESSION_STATE_FILE);
98
- if (existsSync(path)) {
99
- try { return JSON.parse(readFileSync(path, "utf-8")); }
100
- catch { /* fall through */ }
101
- }
102
- return {
98
+ const defaults = {
103
99
  sessionId: null,
104
100
  routeCardPresent: false,
105
101
  activeRole: null,
@@ -110,6 +106,18 @@ export function getSessionState(cwd) {
110
106
  outcomeRecorded: false,
111
107
  startedAt: null,
112
108
  };
109
+ if (existsSync(path)) {
110
+ try {
111
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
112
+ // Merge over the default shape: a PARTIAL state file (e.g. created from {} by the generated
113
+ // prompt-submit script when SessionStart never ran) must not crash the library hook functions
114
+ // — the generated scripts guard field-by-field; the library tolerates the same files.
115
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
116
+ return { ...defaults, ...parsed };
117
+ }
118
+ } catch { /* fall through */ }
119
+ }
120
+ return defaults;
113
121
  }
114
122
 
115
123
  /**
@@ -450,20 +458,93 @@ process.exit(0);
450
458
 
451
459
  function generatePreToolUseScript() {
452
460
  return `#!/usr/bin/env node
453
- // Role OS PreToolUse hook — enforce role-specific tool law + Tool-Call Conformance floor (advisory)
461
+ // Role OS PreToolUse hook — role tool law + capability gate (fail-closed) + conformance floor (advisory).
462
+ // SELF-CONTAINED: stdlib-only. A bare role-os import specifier resolves in NO npx/global-install
463
+ // consumer repo (the package has no "exports" self-reference), so the fail-closed gate logic is
464
+ // INLINED below — a security control must never depend on a best-effort import. Internal failures
465
+ // warn on stderr (once per repo, marker-file throttled); they are never a silent catch.
454
466
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
455
467
  import { join } from "node:path";
468
+ import { pathToFileURL } from "node:url";
456
469
 
457
470
  const input = JSON.parse(readFileSync(0, "utf-8").toString() || "{}");
458
471
  const cwd = input.cwd || process.cwd();
459
472
  const statePath = join(cwd, ".claude", "hooks", "session-state.json");
473
+ const toolName = input.tool_name || "";
474
+
475
+ // One-time stderr warning: degraded hook behavior must be VISIBLE, but must not spam every call.
476
+ function warnOnce(key, message) {
477
+ let line = "[role-os hook] " + message;
478
+ try {
479
+ const marker = join(cwd, ".claude", "hooks", "." + key + ".warned");
480
+ if (existsSync(marker)) return; // already surfaced in this repo
481
+ writeFileSync(marker, new Date().toISOString() + " " + message + "\\n");
482
+ } catch (err) {
483
+ line += " (warn-marker write failed: " + err.message + ")";
484
+ }
485
+ process.stderr.write(line + "\\n");
486
+ }
487
+
488
+ // ── Capability gate (opt-in via ROLEOS_CAPABILITY_GATE, FAIL-CLOSED) ─────────────────────────────
489
+ // Inlined gated set + grant law — keep in sync with src/specialist/capability-gate.mjs in the
490
+ // role-os repo. A gated irreversible action (NAMED_COMPENSATORS list) with no matching grant in
491
+ // .claude/role-os/capabilities.json is DENIED (exit 2 blocks). Default OFF => pure no-op. The
492
+ // patterns allow flags between command word and verb without crossing a shell separator; an
493
+ // unparseable "expires" DENIES (a typo'd date must never become a permanent grant).
494
+ const gateEnabled = process.env.ROLEOS_CAPABILITY_GATE === "1" || process.env.ROLEOS_CAPABILITY_GATE === "true";
495
+ if (gateEnabled && toolName === "Bash") {
496
+ const command = (input.tool_input && typeof input.tool_input.command === "string") ? input.tool_input.command : "";
497
+ const GATED = [
498
+ { id: "npm:publish", label: "npm/pnpm/yarn publish", re: /\\b(?:npm|pnpm|yarn)\\b[^|;&\\n]*\\bpublish\\b/ },
499
+ { id: "pypi:publish", label: "PyPI publish (twine/uv)", re: /\\btwine\\b[^|;&\\n]*\\bupload\\b|\\buv\\b[^|;&\\n]*\\bpublish\\b/ },
500
+ { id: "gh:release", label: "gh release create", re: /\\bgh\\b[^|;&\\n]*\\brelease\\b[^|;&\\n]*\\bcreate\\b/ },
501
+ { id: "gh:pr-create", label: "gh pr create", re: /\\bgh\\b[^|;&\\n]*\\bpr\\b[^|;&\\n]*\\bcreate\\b/ },
502
+ { id: "gh:repo-edit", label: "gh repo edit/delete", re: /\\bgh\\b[^|;&\\n]*\\brepo\\b[^|;&\\n]*\\b(?:edit|delete)\\b/ },
503
+ { id: "git:push", label: "git push", re: /\\bgit\\b[^|;&\\n]*\\bpush\\b/ },
504
+ { id: "pages:deploy", label: "GitHub Pages / gh-pages deploy", re: /\\bgh-pages\\b|\\bpages\\b[^|;&\\n]*\\bdeploy\\b/ },
505
+ ];
506
+ const action = command ? GATED.find((a) => a.re.test(command)) : undefined;
507
+ if (action) {
508
+ let problem = null;
509
+ try {
510
+ const capPath = join(cwd, ".claude", "role-os", "capabilities.json");
511
+ const manifest = existsSync(capPath) ? JSON.parse(readFileSync(capPath, "utf-8")) : {};
512
+ const g = manifest && typeof manifest === "object" ? manifest[action.id] : undefined;
513
+ if (!g || typeof g !== "object" || g.granted !== true) {
514
+ problem = 'No capability "' + action.id + '" is granted in .claude/role-os/capabilities.json';
515
+ } else if (typeof g.expires === "string") {
516
+ const t = Date.parse(g.expires);
517
+ if (Number.isNaN(t)) {
518
+ problem = 'the grant for "' + action.id + '" has an unparseable "expires" ("' + g.expires + '") — an invalid expiry DENIES (fail-closed), it never extends the grant; fix the date';
519
+ } else if (t < Date.now()) {
520
+ problem = 'the grant for "' + action.id + '" expired at ' + g.expires;
521
+ }
522
+ }
523
+ } catch (err) {
524
+ // FAIL CLOSED: a gated action whose grant cannot be evaluated is denied, with the cause named.
525
+ problem = "the grant could not be evaluated (" + err.message + ") — failing closed on an irreversible action";
526
+ }
527
+ if (problem) {
528
+ process.stderr.write(
529
+ 'Capability gate: "' + action.label + '" is an irreversible action requiring an explicit grant. ' +
530
+ problem + '. To authorize it, the director adds {"' + action.id + '": {"granted": true}} to ' +
531
+ '.claude/role-os/capabilities.json, optionally with an "expires" date. (The gate enforces only ' +
532
+ '"granted"/"expires" — a grant authorizes ALL matching ' + action.label + ' calls; a "scope" field is informational only.)\\n'
533
+ );
534
+ process.exit(2);
535
+ }
536
+ }
537
+ }
460
538
 
461
539
  let state = {};
462
540
  if (existsSync(statePath)) {
463
- try { state = JSON.parse(readFileSync(statePath, "utf-8")); } catch {}
541
+ try { state = JSON.parse(readFileSync(statePath, "utf-8")); }
542
+ catch (err) {
543
+ warnOnce("session-state-unreadable", "session-state.json was unreadable (" + err.message + ") — continuing with fresh session state.");
544
+ state = {};
545
+ }
464
546
  }
465
547
 
466
- const toolName = input.tool_name || "";
467
548
  if (!state.toolsUsed) state.toolsUsed = [];
468
549
  if (!state.toolsUsed.includes(toolName)) {
469
550
  state.toolsUsed.push(toolName);
@@ -476,33 +557,32 @@ if (writeTools.includes(toolName) && !state.routeCardPresent && (state.substanti
476
557
  notes.push(\`Write tool "\${toolName}" used without route card. Consider /roleos-route.\`);
477
558
  }
478
559
 
479
- // Tool-Call Conformance floor (advisory, deterministic). Best-effort: runs only when this tool has a
480
- // .claude/role-os/tool-contracts.json catalog entry AND the role-os library is resolvable. ANY failure
481
- // is a silent no-op a hook must never break a tool call; the watcher is advisory + fail-open.
560
+ // Tool-Call Conformance floor (advisory, deterministic, fail-open). Runs only when this tool has a
561
+ // .claude/role-os/tool-contracts.json catalog entry AND the role-os library is installed where this
562
+ // repo can resolve it (local node_modules). Unlike the inlined capability gate above (fail-closed),
563
+ // the advisory floor may degrade — but it must SAY so once on stderr, never silently no-op.
482
564
  try {
483
565
  const catPath = join(cwd, ".claude", "role-os", "tool-contracts.json");
484
566
  if (existsSync(catPath)) {
485
567
  const catalog = JSON.parse(readFileSync(catPath, "utf-8"));
486
568
  const entry = catalog && catalog[toolName];
487
569
  if (entry) {
488
- const { schemaFloor, contractFloor } = await import("role-os/src/specialist/conformance-consult.mjs");
489
- const tool = { name: toolName, contract: entry.contract, params: entry.params || [], constraints: entry.constraints || [] };
490
- const call = (input.tool_input && typeof input.tool_input === "object") ? input.tool_input : {};
491
- const v = [...schemaFloor(tool, call).violations, ...contractFloor(tool, call, entry.state_struct || null).violations];
492
- if (v.length) notes.push(\`Tool-Call Conformance (advisory): "\${toolName}" appears NONCONFORMANT — \${v.join("; ")}.\`);
570
+ const libPath = join(cwd, "node_modules", "role-os", "src", "specialist", "conformance-consult.mjs");
571
+ if (!existsSync(libPath)) {
572
+ warnOnce("conformance-lib-unresolvable", "tool-contracts.json catalogs this tool but the role-os library is not installed locally (" + libPath + " not found) the advisory conformance floor is OFF. Run: npm i -D role-os");
573
+ } else {
574
+ const { schemaFloor, contractFloor } = await import(pathToFileURL(libPath).href);
575
+ const tool = { name: toolName, contract: entry.contract, params: entry.params || [], constraints: entry.constraints || [] };
576
+ const call = (input.tool_input && typeof input.tool_input === "object") ? input.tool_input : {};
577
+ const v = [...schemaFloor(tool, call).violations, ...contractFloor(tool, call, entry.state_struct || null).violations];
578
+ if (v.length) notes.push(\`Tool-Call Conformance (advisory): "\${toolName}" appears NONCONFORMANT — \${v.join("; ")}.\`);
579
+ }
493
580
  }
494
581
  }
495
- } catch { /* role-os not resolvable here, or internal error -> no-op (never block a tool call) */ }
496
-
497
- // Capability gate (opt-in via ROLEOS_CAPABILITY_GATE, FAIL-CLOSED): an irreversible action without a
498
- // granted capability is DENIED (exit 2 blocks). Default OFF => no-op. Bounds what a wrong verdict can
499
- // DO (POLA / CaMeL). Best-effort: if role-os is not resolvable here a hook-resolution failure must not
500
- // itself block a call; the in-process onPreToolUse path still enforces it where role-os is resolvable.
501
- try {
502
- const { capabilityGate } = await import("role-os/src/specialist/capability-gate.mjs");
503
- const cap = capabilityGate(cwd, toolName, input.tool_input || {});
504
- if (cap.denied) { process.stderr.write(cap.reason + "\\n"); process.exit(2); }
505
- } catch { /* role-os not resolvable / internal error -> no-op (in-process path enforces) */ }
582
+ } catch (err) {
583
+ // Advisory stays fail-open (never blocks a call) but never SILENT: surface the failure once.
584
+ warnOnce("conformance-floor-error", "Tool-Call Conformance advisory errored (" + err.message + ") the advisory floor was skipped.");
585
+ }
506
586
 
507
587
  // PreToolUse wire protocol (current Claude Code): inject advisory context via
508
588
  // hookSpecificOutput.additionalContext + exit 0. A bare { addContext } is IGNORED; exit 2 would BLOCK.
@@ -206,11 +206,12 @@ export function analyzeArtifactEvidence({
206
206
  locations.push("title-match");
207
207
  }
208
208
  // Check for source ID reference
209
- if (artifactLower.includes(chunk.source_id.toLowerCase())) {
209
+ const sourceId = (chunk.source_id ?? "").toLowerCase();
210
+ if (sourceId && artifactLower.includes(sourceId)) {
210
211
  locations.push("source-id");
211
212
  }
212
213
  // Check for key content phrases (first 50 chars of content)
213
- const contentSnippet = chunk.content.slice(0, 50).toLowerCase();
214
+ const contentSnippet = (chunk.content ?? "").slice(0, 50).toLowerCase();
214
215
  if (contentSnippet.length > 20 && artifactLower.includes(contentSnippet)) {
215
216
  locations.push("content-echo");
216
217
  }
@@ -236,7 +237,7 @@ export function analyzeArtifactEvidence({
236
237
  const knownRefs = new Set([
237
238
  ...(bundle?.selected?.map((c) => (c.citation?.reference ?? c.chunk_id).toLowerCase()) ?? []),
238
239
  ...(bundle?.selected?.map((c) => c.title?.toLowerCase()).filter(Boolean) ?? []),
239
- ...(bundle?.selected?.map((c) => c.source_id.toLowerCase()) ?? []),
240
+ ...(bundle?.selected?.map((c) => (c.source_id ?? "").toLowerCase()).filter(Boolean) ?? []),
240
241
  ...known_external_refs.map((r) => r.toLowerCase()),
241
242
  ]);
242
243
 
@@ -268,7 +269,14 @@ export function analyzeArtifactEvidence({
268
269
 
269
270
  if (!postureCompliance.compliant) {
270
271
  verdict = verdict === "fail" ? "fail" : "warn";
271
- reasons.push(`Posture compliance failed: missing ${postureCompliance.missing_signals.join(", ")}`);
272
+ const parts = [];
273
+ if (postureCompliance.missing_signals.length > 0) {
274
+ parts.push(`missing ${postureCompliance.missing_signals.join(", ")}`);
275
+ }
276
+ if ((postureCompliance.banned_violations ?? []).length > 0) {
277
+ parts.push(`banned phrase(s): ${postureCompliance.banned_violations.join(", ")}`);
278
+ }
279
+ reasons.push(`Posture compliance failed: ${parts.join("; ") || "expected posture signals absent"}`);
272
280
  }
273
281
 
274
282
  if (driftViolations.length > 0) {
@@ -390,16 +398,18 @@ function checkDrift(artifactLower, roleId) {
390
398
  function extractCitationPatterns(text) {
391
399
  const patterns = [];
392
400
 
393
- // Bracketed references: [Something]
394
- const bracketMatches = text.match(/\[([^\]]{5,80})\]/g) ?? [];
401
+ // Bracketed references: [Something] — but not markdown links [text](url)
402
+ // (checkbox brackets like [x] fall below the 5-char minimum already)
403
+ const bracketMatches = text.match(/\[([^\]]{5,80})\](?!\()/g) ?? [];
395
404
  for (const m of bracketMatches) {
396
405
  patterns.push(m.slice(1, -1));
397
406
  }
398
407
 
399
- // "Source: X" or "Reference: X"
400
- const sourceMatches = text.match(/(?:source|reference|per|according to):?\s+([^\n.]{5,80})/gi) ?? [];
408
+ // "Source: X" or "Reference: X" — word boundaries so "per" can't match
409
+ // inside words like "Super" or "paper"
410
+ const sourceMatches = text.match(/\b(?:source|reference|per|according to)\b:?\s+([^\n.]{5,80})/gi) ?? [];
401
411
  for (const m of sourceMatches) {
402
- const cleaned = m.replace(/^(?:source|reference|per|according to):?\s+/i, "").trim();
412
+ const cleaned = m.replace(/^(?:source|reference|per|according to)\b:?\s+/i, "").trim();
403
413
  if (cleaned) patterns.push(cleaned);
404
414
  }
405
415
 
@@ -13,12 +13,24 @@
13
13
  * @returns {{ state: string, action: string, message: string }}
14
14
  */
15
15
  export function applyFallbackPolicy(bundle, overlay) {
16
+ // Malformed bundle from a buggy/version-skewed retrieve() → named degraded
17
+ // state instead of a TypeError that callers would swallow silently.
18
+ if (!bundle || typeof bundle !== "object" || !Array.isArray(bundle.selected)) {
19
+ return {
20
+ state: "malformed_bundle",
21
+ action: "warn",
22
+ message: "Retrieval bundle is malformed (missing or invalid selected[]) — knowledge degraded",
23
+ };
24
+ }
25
+
26
+ const summary = bundle.summary ?? {};
27
+
16
28
  // No overlay → shared corpus only
17
29
  if (!overlay) {
18
30
  return {
19
31
  state: "no_overlay",
20
32
  action: "continue",
21
- message: `No overlay for role ${bundle.role_id} — using shared corpus only`,
33
+ message: `No overlay for role ${bundle.role_id ?? "unknown"} — using shared corpus only`,
22
34
  };
23
35
  }
24
36
 
@@ -32,22 +44,22 @@ export function applyFallbackPolicy(bundle, overlay) {
32
44
  }
33
45
 
34
46
  // Check for forbidden source hits
35
- if (bundle.summary.forbidden_hits > 0) {
47
+ if ((summary.forbidden_hits ?? 0) > 0) {
36
48
  // Forbidden sources were removed, but log the diagnostic
37
49
  return {
38
50
  state: "forbidden_hit",
39
51
  action: "continue",
40
- message: `${bundle.summary.forbidden_hits} forbidden source(s) removed from results`,
52
+ message: `${summary.forbidden_hits} forbidden source(s) removed from results`,
41
53
  };
42
54
  }
43
55
 
44
56
  // Check for stale-dominant results
45
- const totalRelevant = bundle.summary.selected_count + bundle.summary.stale_count;
46
- if (totalRelevant > 0 && bundle.summary.stale_count / totalRelevant > 0.5) {
57
+ const totalRelevant = (summary.selected_count ?? 0) + (summary.stale_count ?? 0);
58
+ if (totalRelevant > 0 && (summary.stale_count ?? 0) / totalRelevant > 0.5) {
47
59
  return {
48
60
  state: "stale_dominant",
49
61
  action: "warn",
50
- message: `${bundle.summary.stale_count} of ${totalRelevant} relevant candidates are stale`,
62
+ message: `${summary.stale_count} of ${totalRelevant} relevant candidates are stale`,
51
63
  };
52
64
  }
53
65
 
@@ -62,7 +74,7 @@ export function applyFallbackPolicy(bundle, overlay) {
62
74
  }
63
75
 
64
76
  // Check for weak trust posture
65
- if (bundle.provenance.trust_posture === "weak") {
77
+ if ((bundle.provenance?.trust_posture ?? "weak") === "weak") {
66
78
  return {
67
79
  state: "no_strong_match",
68
80
  action: "warn",
@@ -11,13 +11,26 @@ import { fileURLToPath } from "node:url";
11
11
 
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
 
14
- // Default overlay search paths (relative to knowledge-core root)
15
- const OVERLAY_PATHS = [
16
- // Local knowledge-core checkout (development)
17
- join(resolve(__dirname, "..", ".."), "knowledge-core", "knowledge", "roles"),
18
- // Fallback: role-os local knowledge dir
19
- join(resolve(__dirname, ".."), "knowledge", "roles"),
20
- ];
14
+ // Default overlay search paths. ROLEOS_KNOWLEDGE_ROLES overrides everything;
15
+ // otherwise we look for a knowledge-core checkout SIBLING to the role-os repo
16
+ // (e.g. <workspace>/knowledge-core next to <workspace>/role-os), then a nested
17
+ // checkout, then role-os's own local knowledge dir.
18
+ function defaultOverlayPaths() {
19
+ const paths = [];
20
+ if (process.env.ROLEOS_KNOWLEDGE_ROLES) {
21
+ paths.push(resolve(process.env.ROLEOS_KNOWLEDGE_ROLES));
22
+ }
23
+ paths.push(
24
+ // Sibling knowledge-core checkout (development) — __dirname is <role-os>/src/knowledge,
25
+ // so three levels up is the workspace that contains both repos.
26
+ join(resolve(__dirname, "..", "..", ".."), "knowledge-core", "knowledge", "roles"),
27
+ // Nested knowledge-core checkout inside role-os
28
+ join(resolve(__dirname, "..", ".."), "knowledge-core", "knowledge", "roles"),
29
+ // Fallback: role-os local knowledge dir
30
+ join(resolve(__dirname, ".."), "knowledge", "roles"),
31
+ );
32
+ return paths;
33
+ }
21
34
 
22
35
  /**
23
36
  * Resolve the overlay for a role.
@@ -28,7 +41,7 @@ const OVERLAY_PATHS = [
28
41
  * @returns {{ overlay: object, path: string } | null}
29
42
  */
30
43
  export function resolveOverlay(roleId, options = {}) {
31
- const paths = options.searchPaths || OVERLAY_PATHS;
44
+ const paths = options.searchPaths || defaultOverlayPaths();
32
45
 
33
46
  for (const dir of paths) {
34
47
  const filePath = join(dir, `${roleId}.json`);
@@ -71,7 +71,7 @@ export async function retrieveForDispatch({ roleId, taskText, packetContextSumma
71
71
 
72
72
  return {
73
73
  bundle,
74
- status: deriveStatus(fallback),
74
+ status: deriveStatus(fallback, bundle),
75
75
  fallback,
76
76
  };
77
77
  }
@@ -82,18 +82,23 @@ export async function retrieveForDispatch({ roleId, taskText, packetContextSumma
82
82
 
83
83
  return {
84
84
  bundle,
85
- status: deriveStatus(fallback),
85
+ status: deriveStatus(fallback, bundle),
86
86
  fallback,
87
87
  };
88
88
  }
89
89
 
90
90
  /**
91
91
  * Derive packet knowledge status from fallback state.
92
+ *
93
+ * no_overlay with selected chunks maps to "weak" (shared-corpus-only evidence),
94
+ * matching fallback-policy's "continue — using shared corpus only" action.
95
+ * "none" is reserved for genuinely empty retrievals, since renderKnowledgeBlock()
96
+ * drops the knowledge block entirely for status "none".
92
97
  */
93
- function deriveStatus(fallback) {
98
+ function deriveStatus(fallback, bundle) {
94
99
  switch (fallback.state) {
95
100
  case "healthy": return "strong";
96
- case "no_overlay": return "none";
101
+ case "no_overlay": return (bundle?.selected?.length ?? 0) > 0 ? "weak" : "none";
97
102
  case "no_strong_match": return "weak";
98
103
  case "stale_dominant": return "stale";
99
104
  case "conflicting": return "conflicted";
@@ -114,11 +114,12 @@ export function createRun(missionKey, taskDescription, options = {}) {
114
114
 
115
115
  /**
116
116
  * Build steps from manifest for dynamic dispatch missions.
117
+ * Exported so the persistent runner (run.mjs) can scale steps from a manifest.
117
118
  * @param {Object} mission
118
119
  * @param {Object} manifest - The audit-manifest.json content
119
120
  * @returns {MissionStep[]}
120
121
  */
121
- function buildDynamicSteps(mission, manifest) {
122
+ export function buildDynamicSteps(mission, manifest) {
122
123
  const dd = mission.dynamicDispatch;
123
124
  const steps = [];
124
125
 
@@ -198,11 +199,12 @@ function buildDynamicSteps(mission, manifest) {
198
199
  /**
199
200
  * Build steps from swarm manifest for dogfood-swarm missions.
200
201
  * Creates domain agent steps per stage with coordinator gates.
202
+ * Exported so the persistent runner (run.mjs) can scale steps from a manifest.
201
203
  * @param {Object} mission
202
204
  * @param {Object} manifest - The swarm-manifest.json content
203
205
  * @returns {MissionStep[]}
204
206
  */
205
- function buildSwarmSteps(mission, manifest) {
207
+ export function buildSwarmSteps(mission, manifest) {
206
208
  const steps = [];
207
209
  const domains = manifest.domains || [];
208
210
  const stages = manifest.stages || ["health-a", "health-b", "health-c", "feature", "treatment"];
@@ -412,6 +414,13 @@ export function recordEscalation(run, from, to, trigger, action) {
412
414
  targetStep.note = `Re-opened by escalation: ${trigger}`;
413
415
  targetStep.completedAt = null;
414
416
  escalation.reopened = true;
417
+
418
+ // A completed run with a re-opened step is no longer complete —
419
+ // reset run status so the pending step is actionable again.
420
+ if (run.status === "completed") {
421
+ run.status = "running";
422
+ run.completedAt = null;
423
+ }
415
424
  } else {
416
425
  // S4-F2: Target role not found in chain (or no completed step to re-open).
417
426
  // Warn the operator instead of silently doing nothing.
package/src/packs-cmd.mjs CHANGED
@@ -57,7 +57,7 @@ function runSuggest(packetFile) {
57
57
  }
58
58
  }
59
59
 
60
- console.log(`\nNext: roleos route ${packetFile} --pack ${result.pack}\n`);
60
+ console.log(`\nNext: roleos route ${packetFile} --pack=${result.pack}\n`);
61
61
  }
62
62
 
63
63
  // ── Show ──────────────────────────────────────────────────────────────────────
package/src/review.mjs CHANGED
@@ -32,9 +32,18 @@ export async function reviewCommand(args) {
32
32
  }
33
33
 
34
34
  // Extract task ID from the packet
35
- const taskIdMatch = content.match(/## Task ID\n(.+)/);
35
+ const taskIdMatch = content.match(/## Task ID\r?\n(.+)/);
36
36
  const taskId = taskIdMatch ? taskIdMatch[1].trim() : basename(packetFile, ".md");
37
37
 
38
+ // Extract the producing role from the packet — reject escalations route the
39
+ // retry back to the role that PRODUCED the output, never the reviewer.
40
+ // Falls back to Orchestrator when the section is absent or unassigned.
41
+ const assignedRoleMatch = content.match(/## Assigned Role\r?\n(.+)/);
42
+ const assignedRole = assignedRoleMatch ? assignedRoleMatch[1].trim() : null;
43
+ const producingRole = assignedRole && !assignedRole.startsWith("<!--")
44
+ ? assignedRole
45
+ : "Orchestrator";
46
+
38
47
  console.log(`\nroleos review — ${verdict}\n`);
39
48
  console.log(`Packet: ${packetFile}`);
40
49
  console.log(`Task ID: ${taskId}\n`);
@@ -99,7 +108,7 @@ ${nextOwner}
99
108
  console.log(`\nEscalation (auto-routed):`);
100
109
  console.log(formatEscalation(escalation));
101
110
  } else if (verdict === "reject") {
102
- const escalation = resolveRejected(reason, reviewer);
111
+ const escalation = resolveRejected(reason, producingRole);
103
112
  console.log(`\nEscalation (auto-routed):`);
104
113
  console.log(formatEscalation(escalation));
105
114
  }