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/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
 
@@ -67,6 +67,13 @@ export function recordProbe(eventsPath, probe) {
67
67
  * narrow fine-tunes show step changes, so an early halt on a small sample would be a noise
68
68
  * trigger, not a real disagreement signal).
69
69
  *
70
+ * Only probes recorded AFTER the role's most recent clear-halt event count. A clear-halt is
71
+ * an operator decision that the disagreement evidence before it is adjudicated; without this
72
+ * boundary the stale disagreeing probes keep dominating the window and the role re-halts on
73
+ * the very next probe — the documented recovery command could never actually recover a role.
74
+ * The fresh-start window also restarts the ≥N thin-sample guard, so a cleared role gets a
75
+ * full new sample before it can halt again.
76
+ *
70
77
  * @param {string} eventsPath
71
78
  * @param {string} role
72
79
  * @param {number} [N]
@@ -74,8 +81,10 @@ export function recordProbe(eventsPath, probe) {
74
81
  * @returns {{ probes: number, agreed: number, rate: number, shouldHalt: boolean }}
75
82
  */
76
83
  export function checkHalt(eventsPath, role, N = SHADOW_DEFAULTS.N, tau = SHADOW_DEFAULTS.TAU) {
77
- const events = readEvents(eventsPath, { role, kind: "shadow-probe" });
78
- const window = events.slice(-N);
84
+ const events = readEvents(eventsPath, { role, kind: ["shadow-probe", "clear-halt"] });
85
+ const lastClear = events.map((e) => e.kind).lastIndexOf("clear-halt");
86
+ const probesSinceClear = events.slice(lastClear + 1).filter((e) => e.kind === "shadow-probe");
87
+ const window = probesSinceClear.slice(-N);
79
88
  const probes = window.length;
80
89
  const agreed = window.filter((e) => e.data && e.data.agreed === true).length;
81
90
  const rate = probes === 0 ? 1 : agreed / probes;
@@ -94,7 +103,8 @@ export function contrastiveHaltMessage({ role, probes, rate, tau }) {
94
103
  return (
95
104
  `specialist for role "${role}" halted: shadow-probe agreement ${pct}% over the last ` +
96
105
  `${probes} probes < required ${required}% (τ=${tau}). The specialist's verdicts have ` +
97
- `drifted from Claude's on the same inputs. Clear with: roleos specialist clear-halt ${role}`
106
+ // Role names contain spaces ("Token Budget Analyst") the copy-pasteable command must quote.
107
+ `drifted from Claude's on the same inputs. Clear with: roleos specialist clear-halt "${role}"`
98
108
  );
99
109
  }
100
110