role-os 2.9.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 (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.es.md +28 -11
  3. package/README.fr.md +25 -8
  4. package/README.hi.md +25 -8
  5. package/README.it.md +28 -11
  6. package/README.ja.md +27 -10
  7. package/README.md +25 -8
  8. package/README.pt-BR.md +25 -8
  9. package/README.zh.md +25 -8
  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/entry.mjs +2 -2
  18. package/src/hooks.mjs +107 -27
  19. package/src/knowledge/analyze-artifact-evidence.mjs +19 -9
  20. package/src/knowledge/fallback-policy.mjs +19 -7
  21. package/src/knowledge/resolve-overlay.mjs +21 -8
  22. package/src/knowledge/retrieve-for-dispatch.mjs +9 -4
  23. package/src/mission-run.mjs +11 -2
  24. package/src/packs-cmd.mjs +1 -1
  25. package/src/review.mjs +11 -2
  26. package/src/role-dossiers.json +1 -1
  27. package/src/route.mjs +41 -8
  28. package/src/run-cmd.mjs +0 -1
  29. package/src/run.mjs +67 -15
  30. package/src/session.mjs +3 -1
  31. package/src/specialist/capability-gate.mjs +35 -18
  32. package/src/specialist/dispatch.mjs +8 -3
  33. package/src/specialist/registry.mjs +6 -0
  34. package/src/specialist/shadow.mjs +13 -3
  35. package/src/specialist/state.mjs +94 -26
  36. package/src/state-machine.mjs +2 -2
  37. package/src/status.mjs +4 -2
  38. package/src/swarm/build-gate.mjs +11 -2
  39. package/src/swarm/persist-bridge.mjs +4 -3
  40. package/src/swarm-cmd.mjs +88 -46
  41. package/src/verify-citations-cmd.mjs +17 -1
  42. package/src/verify-citations.mjs +31 -7
  43. package/starter-pack/README.md +22 -14
  44. package/starter-pack/handbook.md +4 -4
  45. package/starter-pack/policy/routing-rules.md +42 -0
  46. package/starter-pack/policy/tool-permissions.md +21 -0
  47. package/starter-pack/workflows/full-treatment.md +27 -16
@@ -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
  }
@@ -318,7 +318,7 @@
318
318
  "role": "Judge",
319
319
  "aptitudes": {
320
320
  "rigor": 5,
321
- "pace": 2,
321
+ "pace": 1,
322
322
  "range": 1,
323
323
  "skepticism": 5,
324
324
  "autonomy": 3,
package/src/route.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
- import { resolve, dirname } from "node:path";
2
+ import { resolve, dirname, join } from "node:path";
3
3
  import { readFileSafe } from "./fs-utils.mjs";
4
4
  import { detectConflicts } from "./conflicts.mjs";
5
5
  import { resolveConflict, resolveSplit, formatEscalation } from "./escalation.mjs";
6
6
  import { suggestPack, getPack, checkPackMismatch, getPackRoles } from "./packs.mjs";
7
7
 
8
- // ── Full 31-Role Catalog ─────────────────────────────────────────────────────
8
+ // ── Full Role Catalog ────────────────────────────────────────────────────────
9
9
  // Every role in the OS is scoreable. Keywords from routing-rules.md + contracts.
10
10
  // Triggers are strong multi-word signals worth bonus points.
11
11
 
@@ -502,7 +502,7 @@ function scoreRole(role, content, packetType, deliverableType) {
502
502
  // ── Type detection ────────────────────────────────────────────────────────────
503
503
 
504
504
  function detectType(content) {
505
- const typeMatch = content.match(/## Packet Type\n(\w+)/);
505
+ const typeMatch = content.match(/## Packet Type\r?\n(\w+)/);
506
506
  if (typeMatch && ["feature", "integration", "identity"].includes(typeMatch[1])) {
507
507
  return typeMatch[1];
508
508
  }
@@ -521,7 +521,7 @@ function detectType(content) {
521
521
  // ── Deliverable type extraction ───────────────────────────────────────────────
522
522
 
523
523
  function extractDeliverableType(content) {
524
- const match = content.match(/## Deliverable Type\n(\w+)/);
524
+ const match = content.match(/## Deliverable Type\r?\n(\w+)/);
525
525
  if (match && DELIVERABLE_TYPES.includes(match[1])) return match[1];
526
526
  return null;
527
527
  }
@@ -553,17 +553,34 @@ function assessConfidence(scoredRoles) {
553
553
 
554
554
  // ── File reference extraction ─────────────────────────────────────────────────
555
555
 
556
- function extractFileRefs(content, packetDir) {
556
+ /**
557
+ * Find the base directory file refs should resolve against: the nearest
558
+ * ancestor of the packet that contains .claude/ (the repo root), falling
559
+ * back to the current working directory for packets outside any repo.
560
+ */
561
+ function repoBaseFor(packetFile) {
562
+ let dir = dirname(packetFile);
563
+ for (let i = 0; i < 20; i++) {
564
+ if (existsSync(join(dir, ".claude"))) return dir;
565
+ const parent = dirname(dir);
566
+ if (parent === dir) break;
567
+ dir = parent;
568
+ }
569
+ return process.cwd();
570
+ }
571
+
572
+ function extractFileRefs(content, packetFile) {
557
573
  const refs = [];
558
- const inputsMatch = content.match(/## Inputs\n([\s\S]*?)(?=\n## |\n---)/);
574
+ const inputsMatch = content.match(/## Inputs\r?\n([\s\S]*?)(?=\r?\n## |\r?\n---)/);
559
575
  if (!inputsMatch) return refs;
560
576
 
577
+ const base = repoBaseFor(packetFile);
561
578
  const inputsSection = inputsMatch[1];
562
579
  const pathPattern = /(?:^|\s|`)((?:\.\/|\.\.\/|[a-zA-Z][\w\-]*\/)[^\s`\n,)]+\.\w+)/gm;
563
580
  let match;
564
581
  while ((match = pathPattern.exec(inputsSection)) !== null) {
565
582
  const ref = match[1];
566
- const resolved = resolve(dirname(packetDir), "..", "..", ref);
583
+ const resolved = resolve(base, ref);
567
584
  refs.push({ ref, resolved, exists: existsSync(resolved) });
568
585
  }
569
586
 
@@ -610,6 +627,22 @@ const HANDOFF_HINTS = {
610
627
 
611
628
  export async function routeCommand(args) {
612
629
  const verbose = args.includes("--verbose");
630
+
631
+ // A bare --pack would be silently swallowed as a flag and the pack name
632
+ // treated as the packet file — reject loudly instead of mis-routing.
633
+ if (args.includes("--pack")) {
634
+ const err = new Error("The --pack flag requires a value: use --pack=<name> (e.g. --pack=feature)");
635
+ err.exitCode = 1;
636
+ err.hint = "Run 'roleos packs list' for available pack names.";
637
+ throw err;
638
+ }
639
+
640
+ const knownFlag = a => a === "--verbose" || a === "--debug" || a.startsWith("--pack=");
641
+ const unknownFlags = args.filter(a => a.startsWith("--") && !knownFlag(a));
642
+ if (unknownFlags.length > 0) {
643
+ console.log(`! Ignoring unrecognized flag(s): ${unknownFlags.join(", ")}`);
644
+ }
645
+
613
646
  const packFlag = args.find(a => a.startsWith("--pack="));
614
647
  const requestedPack = packFlag ? packFlag.split("=")[1] : null;
615
648
  const packetFile = args.find(a => !a.startsWith("--"));
@@ -632,7 +665,7 @@ export async function routeCommand(args) {
632
665
  const type = detectType(content);
633
666
  const deliverableType = extractDeliverableType(content);
634
667
 
635
- // Score all 32 roles
668
+ // Score every role in the catalog
636
669
  const allScored = ROLE_CATALOG.map(role => ({
637
670
  role,
638
671
  ...scoreRole(role, content, type, deliverableType),
package/src/run-cmd.mjs CHANGED
@@ -94,7 +94,6 @@ export async function runCommand(args) {
94
94
  }
95
95
 
96
96
  // roleos run "<task>"
97
- const task = args.join(" ");
98
97
  const opts = {};
99
98
 
100
99
  // Parse --mission= and --pack= flags
package/src/run.mjs CHANGED
@@ -16,9 +16,11 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
16
16
  import { join } from "node:path";
17
17
  import { decideEntry } from "./entry.mjs";
18
18
  import { getMission } from "./mission.mjs";
19
- import { TEAM_PACKS, getPack } from "./packs.mjs";
19
+ import { TEAM_PACKS, getPack, getPackRoles } from "./packs.mjs";
20
20
  import { ROLE_CATALOG } from "./route.mjs";
21
21
  import { ROLE_ARTIFACT_CONTRACTS, validateArtifact, getHandoffContract } from "./artifacts.mjs";
22
+ import { buildSwarmSteps, buildDynamicSteps } from "./mission-run.mjs";
23
+ import { isValidStepTransition } from "./state-machine.mjs";
22
24
  import { retrieveForDispatch, isKnowledgeConfigured } from "./knowledge/index.mjs";
23
25
 
24
26
  // ── Run directory ────────────────────────────────────────────────────────────
@@ -87,6 +89,9 @@ let _counter = 0;
87
89
  * @param {object} [opts]
88
90
  * @param {string} [opts.forceMission] - force a specific mission key
89
91
  * @param {string} [opts.forcePack] - force a specific pack key
92
+ * @param {object} [opts.manifest] - dispatch manifest for dynamic missions
93
+ * (swarm-manifest.json for dogfood-swarm, audit-manifest.json for deep-audit);
94
+ * scales steps per domain/component instead of using the static artifactFlow
90
95
  * @returns {PersistentRun}
91
96
  */
92
97
  export async function createPersistentRun(taskDescription, cwd, opts = {}) {
@@ -106,7 +111,7 @@ export async function createPersistentRun(taskDescription, cwd, opts = {}) {
106
111
  if (!mission) throw new Error(`Mission "${opts.forceMission}" not found`);
107
112
  level = "mission";
108
113
  missionKey = opts.forceMission;
109
- steps = buildMissionSteps(opts.forceMission);
114
+ steps = buildMissionSteps(opts.forceMission, opts.manifest);
110
115
  } else if (opts.forcePack) {
111
116
  const pack = getPack(opts.forcePack);
112
117
  if (!pack) throw new Error(`Pack "${opts.forcePack}" not found`);
@@ -172,9 +177,23 @@ export async function createPersistentRun(taskDescription, cwd, opts = {}) {
172
177
 
173
178
  // ── Step builders ────────────────────────────────────────────────────────────
174
179
 
175
- function buildMissionSteps(missionKey) {
180
+ function buildMissionSteps(missionKey, manifest = null) {
176
181
  const mission = getMission(missionKey);
177
- return mission.artifactFlow.map((step, i) => ({
182
+
183
+ // Dynamic dispatch — when a manifest is supplied, scale steps from it:
184
+ // dogfood-swarm builds per-domain steps with stage/gate metadata,
185
+ // deep-audit builds one auditor step per component/boundary.
186
+ let flow = mission.artifactFlow;
187
+ if (manifest && mission.dynamicDispatch) {
188
+ flow = missionKey === "dogfood-swarm"
189
+ ? buildSwarmSteps(mission, manifest)
190
+ : buildDynamicSteps(mission, manifest);
191
+ }
192
+
193
+ // Spread first so dispatch metadata (stage, domain, isGate, userApproval,
194
+ // parcel, consumedBy, …) carries through, then pin the run.mjs step shape.
195
+ return flow.map((step, i) => ({
196
+ ...step,
178
197
  index: i,
179
198
  role: step.role,
180
199
  produces: step.produces,
@@ -190,12 +209,26 @@ function buildMissionSteps(missionKey) {
190
209
  function buildPackSteps(packKey) {
191
210
  const pack = getPack(packKey);
192
211
  const handoff = getHandoffContract(packKey);
193
- const roles = pack.chainOrder
194
- ? pack.chainOrder.split(" ")
195
- : pack.roles;
212
+
213
+ // Derive steps from the pack's real roster (includes the final review gate
214
+ // and conditional Orchestrator) — never from chainOrder prose, which for
215
+ // brainstorm/deep-audit/swarm contains pseudo-role fragments.
216
+ const roles = getPackRoles(packKey) || pack.roles;
217
+
218
+ // Fail loudly on roles missing from the catalog — a run built around a
219
+ // nonexistent role breaks guidance, artifact contracts, and escalation.
220
+ const unknown = roles.filter(name => !ROLE_CATALOG.some(r => r.name === name));
221
+ if (unknown.length > 0) {
222
+ throw new Error(
223
+ `Pack "${packKey}" contains roles not in the role catalog: ${unknown.join(", ")}`
224
+ );
225
+ }
196
226
 
197
227
  return roles.map((roleName, i) => {
198
- const artifact = handoff?.flow?.[i]?.produces || guessArtifact(roleName);
228
+ // Resolve produces by role lookup into the handoff flow index alignment
229
+ // is wrong whenever the roster and flow are ordered differently.
230
+ const flowEntry = handoff?.flow?.find(f => f.role === roleName);
231
+ const artifact = flowEntry?.produces || guessArtifact(roleName);
199
232
  return {
200
233
  index: i,
201
234
  role: roleName,
@@ -279,6 +312,7 @@ function buildStepGuidance(roleName, produces, mission) {
279
312
 
280
313
  function guessArtifact(roleName) {
281
314
  const map = {
315
+ "Orchestrator": "decomposition-plan",
282
316
  "Product Strategist": "strategy-brief",
283
317
  "Spec Writer": "implementation-spec",
284
318
  "Backend Engineer": "change-plan",
@@ -512,6 +546,13 @@ export function escalate(run, from, to, trigger, action, cwd) {
512
546
  run.steps[i].note = `Unblocked: ${to} re-opened for escalation`;
513
547
  }
514
548
  }
549
+
550
+ // A completed run with a re-opened step is no longer complete —
551
+ // mirror reopenStep so formatNext doesn't report "All done."
552
+ if (run.status === "completed") {
553
+ run.status = "paused";
554
+ run.completedAt = null;
555
+ }
515
556
  } else {
516
557
  const inChain = run.steps.some(s => s.role === to);
517
558
  escalation.warning = inChain
@@ -540,13 +581,15 @@ export function escalate(run, from, to, trigger, action, cwd) {
540
581
  export function retry(run, stepIndex, cwd) {
541
582
  const step = run.steps[stepIndex];
542
583
  if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
543
- if (step.status !== "failed" && step.status !== "partial") {
544
- throw new Error(`Step ${stepIndex} is "${step.status}", not failed/partial`);
584
+ // blocked is retryable so an operator block (or a mistaken one) has a recovery path
585
+ if (step.status !== "failed" && step.status !== "partial" && step.status !== "blocked") {
586
+ throw new Error(`Step ${stepIndex} is "${step.status}", not failed/partial/blocked`);
545
587
  }
546
588
 
589
+ const previousStatus = step.status;
547
590
  step.status = "pending";
548
591
  step.artifact = null;
549
- step.note = `Retried (was ${step.status})`;
592
+ step.note = `Retried (was ${previousStatus})`;
550
593
  step.completedAt = null;
551
594
 
552
595
  // Unblock downstream
@@ -583,6 +626,14 @@ export function retry(run, stepIndex, cwd) {
583
626
  export function blockStep(run, stepIndex, reason, cwd) {
584
627
  const step = run.steps[stepIndex];
585
628
  if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
629
+ // Enforce the canonical state machine — blocking a completed/failed step
630
+ // would strand the run with no CLI recovery path.
631
+ if (!isValidStepTransition(step.status, "blocked")) {
632
+ throw new Error(
633
+ `Cannot block step ${stepIndex} (${step.role}) — invalid transition "${step.status}" → "blocked". ` +
634
+ `Only pending or active steps can be blocked.`
635
+ );
636
+ }
586
637
 
587
638
  step.status = "blocked";
588
639
  step.note = reason;
@@ -609,8 +660,8 @@ export function blockStep(run, stepIndex, reason, cwd) {
609
660
  export function reopenStep(run, stepIndex, reason, cwd) {
610
661
  const step = run.steps[stepIndex];
611
662
  if (!step) throw new Error(`Invalid step index: ${stepIndex}`);
612
- if (step.status !== "completed" && step.status !== "partial") {
613
- throw new Error(`Step ${stepIndex} is "${step.status}", can only reopen completed/partial`);
663
+ if (step.status !== "completed" && step.status !== "partial" && step.status !== "blocked") {
664
+ throw new Error(`Step ${stepIndex} is "${step.status}", can only reopen completed/partial/blocked`);
614
665
  }
615
666
 
616
667
  step.status = "pending";
@@ -743,7 +794,7 @@ export function formatNext(run) {
743
794
 
744
795
  if (run.status === "failed" || run.status === "partial") {
745
796
  const failedStep = run.steps.find(s => s.status === "failed" || s.status === "partial");
746
- return `Run ${run.status} at step ${failedStep?.index || "?"} (${failedStep?.role || "?"}). ` +
797
+ return `Run ${run.status} at step ${failedStep?.index ?? "?"} (${failedStep?.role || "?"}). ` +
747
798
  `Use \`roleos retry ${failedStep?.index}\` to retry or \`roleos escalate\` to reroute.`;
748
799
  }
749
800
 
@@ -920,7 +971,7 @@ export function loadRun(cwd, id) {
920
971
  /**
921
972
  * List all runs in the working directory.
922
973
  * @param {string} cwd
923
- * @returns {Array<{id: string, task: string, status: string, level: string, createdAt: string}>}
974
+ * @returns {Array<{id: string, task: string, status: string, level: string, missionKey: string|null, createdAt: string}>}
924
975
  */
925
976
  export function listRuns(cwd) {
926
977
  const dir = runsDir(cwd);
@@ -936,6 +987,7 @@ export function listRuns(cwd) {
936
987
  task: run.taskDescription,
937
988
  status: run.status,
938
989
  level: run.entryLevel,
990
+ missionKey: run.missionKey ?? null,
939
991
  createdAt: run.createdAt,
940
992
  };
941
993
  } catch { return null; }
package/src/session.mjs CHANGED
@@ -15,6 +15,8 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { writeFileSafe } from "./fs-utils.mjs";
17
17
  import { scaffoldHooks, generateHooksConfig } from "./hooks.mjs";
18
+ import { ROLE_CATALOG } from "./route.mjs";
19
+ import { TEAM_PACKS } from "./packs.mjs";
18
20
 
19
21
  // ── roleos init claude ────────────────────────────────────────────────────────
20
22
 
@@ -302,7 +304,7 @@ Before starting non-trivial work in this repo, route the task through Role OS:
302
304
  3. Use structured handoffs between roles
303
305
  4. Review with evidence-based verdicts
304
306
 
305
- Role OS provides 31 specialized roles across 8 packs (engineering, design, product, research, growth, treatment, marketing, core). It detects broken chains, auto-routes recovery, and requires structured evidence in every verdict.
307
+ Role OS provides ${ROLE_CATALOG.length} specialized roles across ${Object.keys(TEAM_PACKS).length} packs (${Object.keys(TEAM_PACKS).join(", ")}). It detects broken chains, auto-routes recovery, and requires structured evidence in every verdict.
306
308
 
307
309
  If the task is composite (feature + docs + launch), Role OS will recommend splitting into child packets with dependency ordering.
308
310
 
@@ -21,7 +21,9 @@
21
21
  * not, so its asymmetry runs the other way.)
22
22
  *
23
23
  * The grant manifest (`.claude/role-os/capabilities.json`, director-authored) maps an action id to a
24
- * grant, e.g.: { "npm:publish": { "granted": true, "scope": "@mcptoolshop/roll", "expires": "2026-07-01" } }
24
+ * grant, e.g.: { "npm:publish": { "granted": true, "expires": "2026-07-01" } }. The gate enforces
25
+ * `granted` and `expires` ONLY — any other field (e.g. a "scope" annotation) is informational/audit
26
+ * metadata and does NOT narrow the grant: a granted action id authorizes ALL matching invocations.
25
27
  */
26
28
 
27
29
  import { existsSync, readFileSync } from "node:fs";
@@ -41,17 +43,21 @@ const _bash = (re) => (toolName, call) =>
41
43
  /**
42
44
  * The GATED SET — the irreversible / world-touching actions from the NAMED_COMPENSATORS standard.
43
45
  * Each entry: { id, label, test(toolName, call) -> boolean }. Detection is deterministic + pattern-
44
- * based and errs toward FLAGGING (a benign match just needs a one-line grant), never toward missing
45
- * an irreversible action.
46
+ * based and errs toward FLAGGING (a benign match just needs a one-line grant). The patterns allow
47
+ * flags between the command word and its verb — `git -C <dir> push`, `npm --workspace <pkg> publish`,
48
+ * `gh release -R <repo> create` all match — without crossing a shell separator (| ; & or newline).
49
+ * KNOWN LIMIT: script indirection (`npm run release` wrapping a publish, a shell script invoking
50
+ * git push) is NOT detected — the gate bounds direct invocations; it is opt-in defense-in-depth,
51
+ * not an evasion-proof sandbox.
46
52
  */
47
53
  export const GATED_ACTIONS = [
48
- { id: "npm:publish", label: "npm/pnpm/yarn publish", test: _bash(/\b(?:npm|pnpm|yarn)\s+publish\b/) },
49
- { id: "pypi:publish", label: "PyPI publish (twine/uv)", test: _bash(/\btwine\s+upload\b|\buv\s+publish\b/) },
50
- { id: "gh:release", label: "gh release create", test: _bash(/\bgh\s+release\s+create\b/) },
51
- { id: "gh:pr-create", label: "gh pr create", test: _bash(/\bgh\s+pr\s+create\b/) },
52
- { id: "gh:repo-edit", label: "gh repo edit/delete", test: _bash(/\bgh\s+repo\s+(?:edit|delete)\b/) },
53
- { id: "git:push", label: "git push", test: _bash(/\bgit\s+push\b/) },
54
- { id: "pages:deploy", label: "GitHub Pages / gh-pages deploy", test: _bash(/\bgh-pages\b|\bpages\s+deploy\b/) },
54
+ { id: "npm:publish", label: "npm/pnpm/yarn publish", test: _bash(/\b(?:npm|pnpm|yarn)\b[^|;&\n]*\bpublish\b/) },
55
+ { id: "pypi:publish", label: "PyPI publish (twine/uv)", test: _bash(/\btwine\b[^|;&\n]*\bupload\b|\buv\b[^|;&\n]*\bpublish\b/) },
56
+ { id: "gh:release", label: "gh release create", test: _bash(/\bgh\b[^|;&\n]*\brelease\b[^|;&\n]*\bcreate\b/) },
57
+ { id: "gh:pr-create", label: "gh pr create", test: _bash(/\bgh\b[^|;&\n]*\bpr\b[^|;&\n]*\bcreate\b/) },
58
+ { id: "gh:repo-edit", label: "gh repo edit/delete", test: _bash(/\bgh\b[^|;&\n]*\brepo\b[^|;&\n]*\b(?:edit|delete)\b/) },
59
+ { id: "git:push", label: "git push", test: _bash(/\bgit\b[^|;&\n]*\bpush\b/) },
60
+ { id: "pages:deploy", label: "GitHub Pages / gh-pages deploy", test: _bash(/\bgh-pages\b|\bpages\b[^|;&\n]*\bdeploy\b/) },
55
61
  ];
56
62
 
57
63
  /** Read the director's capability manifest, or {} if absent/malformed (=> nothing granted). */
@@ -66,15 +72,24 @@ export function loadCapabilities(cwd) {
66
72
  }
67
73
  }
68
74
 
69
- /** Is `actionId` granted (granted:true and not expired) in the manifest? */
70
- function _granted(manifest, actionId, now) {
75
+ /**
76
+ * Evaluate `actionId`'s grant. Returns null when granted and valid; otherwise a short problem
77
+ * string for the deny reason. An unparseable `expires` is treated as INVALID (deny) — a
78
+ * fail-closed gate must never turn a typo'd date into a permanent grant.
79
+ */
80
+ function _grantProblem(manifest, actionId, now) {
71
81
  const g = manifest && manifest[actionId];
72
- if (!g || typeof g !== "object" || g.granted !== true) return false;
82
+ if (!g || typeof g !== "object" || g.granted !== true) {
83
+ return `No capability "${actionId}" is granted in ${CAPABILITIES_FILE}`;
84
+ }
73
85
  if (typeof g.expires === "string") {
74
86
  const t = Date.parse(g.expires);
75
- if (!Number.isNaN(t) && t < now) return false; // grant expired
87
+ if (Number.isNaN(t)) {
88
+ return `The grant for "${actionId}" has an unparseable "expires" value ("${g.expires}") — an invalid expiry DENIES (fail-closed), it never extends the grant; fix the date`;
89
+ }
90
+ if (t < now) return `The grant for "${actionId}" expired at ${g.expires}`;
76
91
  }
77
- return true;
92
+ return null;
78
93
  }
79
94
 
80
95
  /**
@@ -100,14 +115,16 @@ export function capabilityGate(cwd, toolName, toolInput, opts = {}) {
100
115
  if (!action) return { denied: false }; // not an irreversible action -> allow
101
116
  const manifest = opts.capabilities || loadCapabilities(cwd);
102
117
  const now = typeof opts.now === "number" ? opts.now : Date.now();
103
- if (_granted(manifest, action.id, now)) return { denied: false };
118
+ const problem = _grantProblem(manifest, action.id, now);
119
+ if (!problem) return { denied: false };
104
120
  return {
105
121
  denied: true,
106
122
  action: action.id,
107
123
  reason:
108
124
  `Capability gate: "${action.label}" is an irreversible action requiring an explicit grant. ` +
109
- `No capability "${action.id}" is granted in ${CAPABILITIES_FILE}. To authorize it, the ` +
110
- `director adds {"${action.id}": {"granted": true}} (optionally with "scope"/"expires").`,
125
+ `${problem}. To authorize it, the director adds {"${action.id}": {"granted": true}}, ` +
126
+ `optionally with an "expires" date. (Note: the gate enforces only "granted"/"expires" — ` +
127
+ `a grant authorizes ALL matching ${action.label} calls; a "scope" field is informational only.)`,
111
128
  };
112
129
  } catch {
113
130
  // A gate that errors must not silently allow an irreversible action: if a gated action matched
@@ -145,7 +145,6 @@ export async function dispatchSpecialist({
145
145
  if (specialistCall.ok) {
146
146
  result = specialistCall.verdict;
147
147
  source = "specialist";
148
- recordDispatch(state, role, windowSize, parseIsoMs(nowIso));
149
148
  } else {
150
149
  // Specialist call failed → fail open to Claude. The gate's "route" was specialist, but
151
150
  // the realized source is Claude. Both are recorded in the receipt.
@@ -157,6 +156,11 @@ export async function dispatchSpecialist({
157
156
  source = "claude";
158
157
  }
159
158
 
159
+ // Record EVERY dispatch (both routes) in the quota window, tagged with the REALIZED source.
160
+ // The window only rolls when Claude traffic is recorded too — recording only specialist
161
+ // successes froze the window and locked every role out permanently once the quota tripped.
162
+ recordDispatch(state, source, windowSize, parseIsoMs(nowIso));
163
+
160
164
  // ── Shadow probe ─────────────────────────────────────────────────────────────────────────
161
165
  // Probes only fire when the dispatch actually went to a specialist (source === "specialist").
162
166
  // A failed-open dispatch already ran Claude; there is nothing left to probe.
@@ -175,12 +179,13 @@ export async function dispatchSpecialist({
175
179
  claude_summary: summarize(claudeVerdict),
176
180
  });
177
181
  resetProbeCounter(state, role);
178
- const { probes, rate, shouldHalt } = checkHalt(eventsPath, role, N, tau);
182
+ const { probes, rate, agreed: agreedCount, shouldHalt } = checkHalt(eventsPath, role, N, tau);
179
183
  shadow = { fired: true, agreed, probes, rate, halt_triggered: shouldHalt };
180
184
  if (shouldHalt && !getHalt(state, role).halted) {
181
185
  const reason = contrastiveHaltMessage({ role, probes, rate, tau });
182
186
  setHalt(state, role, { reason, since: nowIso });
183
- appendHaltEvent(eventsPath, { role, ts: nowIso, reason, probes, agreed: probes - Math.round(probes * (1 - rate)), rate, tau });
187
+ // `agreed` is checkHalt's exact window count never recompute it lossily from the rate.
188
+ appendHaltEvent(eventsPath, { role, ts: nowIso, reason, probes, agreed: agreedCount, rate, tau });
184
189
  }
185
190
  } else {
186
191
  shadow = { fired: false, counter: c };
@@ -199,6 +199,12 @@ function validateVersion(v, tag) {
199
199
  if (v.exam_centroid !== undefined && !Array.isArray(v.exam_centroid)) {
200
200
  errors.push(`${tag}: exam_centroid, if present, must be an array of numbers`);
201
201
  }
202
+ // ood_floor feeds defaultOodFn directly (gate.mjs): a string silently falls back to the 0.4
203
+ // default and a value outside cosine range disables routing entirely — both must fail loudly
204
+ // at load time, mirroring the R5 gate_threshold check.
205
+ if (v.ood_floor !== undefined && (typeof v.ood_floor !== "number" || !(v.ood_floor >= -1 && v.ood_floor <= 1))) {
206
+ errors.push(`${tag}: R5 — ood_floor, if present, must be a number in [-1, 1] (cosine range; got ${JSON.stringify(v.ood_floor)})`);
207
+ }
202
208
  return errors;
203
209
  }
204
210