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
package/README.zh.md CHANGED
@@ -120,18 +120,21 @@ roleos reopen 0 "found issue in review"
120
120
 
121
121
  顺序:首先进行发布检查,然后进行完整的治疗。如果没有通过硬性闸,则不能发布 v1.0.0 版本。
122
122
 
123
- ## 10 个包中的 61 个角色
123
+ ## 包含 61 个角色的目录
124
124
 
125
- | | 角色 |
126
- |------|-------|
127
- | **Core** (3) | 协调员、产品战略家、审核者 |
125
+ 该目录将这 61 个角色分为 11 个类别。(Dispatch 使用一套独立的 10 个“团队包”——功能、bug 修复、安全、文档、发布、研究、处理、深度审计、头脑风暴、协作——这些团队包会从这些类别中选择相应的角色。)
126
+
127
+ | 类别 | 角色 |
128
+ |--------|-------|
129
+ | **Core** (2) | 协调者,批评评审员 |
130
+ | **Product** (4) | 产品策略师,反馈整合者,路线图优先级排序者,规范撰写者 |
128
131
  | **Engineering** (7) | 前端开发人员、后端工程师、测试工程师、重构工程师、性能工程师、依赖关系审计员、安全审查员 |
129
132
  | **Design** (2) | UI 设计师,品牌守护者 |
130
133
  | **Marketing** (1) | 发布文案撰写员 |
131
134
  | **Treatment** (7) | 仓库研究员、仓库翻译员、文档架构师、元数据管理员、覆盖审计员、部署验证员、发布工程师 |
132
- | **Product** (3) | 反馈综合分析员、路线图优先级排序者、规范编写者 |
133
135
  | **Research** (4) | 用户体验研究员、竞争对手分析师、趋势研究员、用户访谈结果综合分析员 |
134
136
  | **Growth** (4) | 发布策略制定者、内容策略制定者、社区经理、支持问题分流负责人 |
137
+ | **Brainstorm** (19) | 背景调查员、用户价值调查员、创意突破调查员、机制调查员、市场调查员、逆向思维调查员、可行性调查员、质量标准调查员、背景分析师、用户价值分析师、机制分析师、定位分析师、逆向思维分析师、标准化者、整合者、产品拓展者、场景拓展者、护城河拓展者、评估者 |
135
138
  | **Deep Audit** (4) | 组件审计员、测试真实性审计员、接口审计员、审计综合分析员 |
136
139
  | **Swarm** (7) | 蜂群协调员、蜂群后端代理、蜂群桥接代理、蜂群测试代理、蜂群基础设施代理、蜂群前端代理、蜂群综合分析员 |
137
140
 
@@ -140,7 +143,13 @@ roleos reopen 0 "found issue in review"
140
143
  ## 快速入门
141
144
 
142
145
  ```bash
143
- npx role-os init
146
+ # Install (puts `roleos` on your PATH):
147
+ npm install -g role-os
148
+
149
+ # Scaffold the role spine into your repo:
150
+ roleos init
151
+ # (one-off alternative without installing: `npx role-os init`,
152
+ # then prefix every command below with `npx role-os` instead of `roleos`)
144
153
 
145
154
  # Describe what you need — Role OS picks the right level:
146
155
  roleos run "fix the crash in save handler"
@@ -262,13 +271,21 @@ role-os/
262
271
  brainstorm.mjs ← Evidence modes, request validation, finding/synthesis/judge schemas
263
272
  brainstorm-roles.mjs ← Role-native schemas, input partitioning, blindspot enforcement, cross-exam
264
273
  brainstorm-render.mjs ← Two-layer rendering: lexical bans, render schemas, debate transcript
265
- test/ ← 1150 tests across 37 test files
274
+ test/ ← 1435 tests across 65 test files
266
275
  starter-pack/ ← Drop-in role contracts, policies, schemas, workflows
267
276
  ```
268
277
 
269
278
  ## 安全性
270
279
 
271
- Role OS **仅在本地运行**。它会复制 Markdown 模板并将数据包/结果文件写入到您的仓库的 `.claude/` 目录中。它不会访问网络、处理密钥或收集遥测数据。没有危险的操作——所有文件写入默认使用“如果存在则跳过”的方式。有关完整策略,请参阅 [SECURITY.md](SECURITY.md)。
280
+ 默认情况下,Role OS 仅在**本地文件系统上运行**。它会复制 Markdown 模板,并将数据包/结果/运行文件写入到您的仓库的 `.claude/` 目录中。默认操作不会进行任何网络请求,也不会处理任何敏感信息,也不会收集任何遥测数据。不执行任何危险的操作——所有文件写入操作默认使用“如果存在则跳过”的方式。
281
+
282
+ 有三个**可选**功能会在您明确启用时连接到网络:
283
+
284
+ - **`roleos verify-citations`** — 调用外部 `prism` CLI,该工具会根据公共 arXiv/Crossref API 解析引用标识符(发送正在验证的引用 ID/URL)。
285
+ - **专家级别** (`roleos specialist`,已注册的角色)— 将 Dispatch 提示发布到您在 `.role-os/specialists.json` 中配置的 `backend_url`(通常是本地模型端点)。
286
+ - **预算/合规性咨询** (`ROLEOS_BUDGET_CONSULT` / `ROLEOS_CONFORMANCE_CONSULT`) — 通过 HTTP 将步骤/工具调用上下文发送到本地模型,以获取建议结果。
287
+
288
+ 这三个功能默认情况下都是关闭的,并且会回退到本地确定性行为。有关完整策略,请参阅 [SECURITY.md](SECURITY.md)。
272
289
 
273
290
  ## 操作系统
274
291
 
package/bin/roleos.mjs CHANGED
@@ -50,6 +50,7 @@ Usage:
50
50
  roleos friction [id] Measure operator friction
51
51
  roleos init Scaffold Role OS into .claude/
52
52
  roleos init --force Update canonical files (protects context/)
53
+ roleos init claude [--force] Scaffold Claude Code session integration (CLAUDE.md, commands, hooks)
53
54
  roleos packet new <type> Create a new packet (feature|integration|identity)
54
55
  roleos route <packet-file> [--verbose] Recommend the smallest valid chain
55
56
  roleos review <packet-file> <verdict> Record a review verdict
@@ -72,8 +73,8 @@ Usage:
72
73
  roleos swarm manifest Show the swarm manifest
73
74
  roleos swarm manifest --generate Auto-detect domains and generate manifest
74
75
  roleos swarm status Show swarm run progress
75
- roleos swarm findings List findings by severity
76
- roleos swarm approve Approve the current feature gate
76
+ roleos swarm findings List findings captured from wave reports
77
+ roleos swarm approve Approve the current user gate
77
78
  roleos swarm verify Verify manifest and run state
78
79
  roleos verify-citations <dispatch> Verify a research dispatch's citations via prism (gate)
79
80
  roleos specialist list List all specialists in the registry (active version + cert)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "role-os",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "description": "Role OS — a multi-Claude operating system where 61 specialized roles execute work through contracts, conflict detection, escalation, and structured evidence. 10 team packs, 9 missions including dogfood swarm (multi-pass convergence), deep audit with manifest-scaled dynamic dispatch, and brainstorm with traceable disagreement.",
5
5
  "homepage": "https://mcp-tool-shop-org.github.io/role-os/",
6
6
  "bugs": {
package/src/artifacts.mjs CHANGED
@@ -521,15 +521,22 @@ export const PACK_HANDOFF_CONTRACTS = {
521
521
  { role: "Critic Reviewer", produces: "verdict", consumedBy: null },
522
522
  ],
523
523
  },
524
+ // v0.4 pipeline — mirrors the brainstorm mission's artifactFlow:
525
+ // Analysts (parallel) → Normalizer → Contrarian → Normalizer (rebut) →
526
+ // Synthesizer → Product Expander → Judge.
524
527
  brainstorm: {
525
528
  flow: [
526
- { role: "Context Scout", produces: "scout-finding", consumedBy: "Normalizer" },
527
- { role: "User Value Scout", produces: "scout-finding", consumedBy: "Normalizer" },
528
- { role: "Creative Leap Scout", produces: "scout-finding", consumedBy: "Normalizer" },
529
- { role: "Normalizer", produces: "normalized-finding-set", consumedBy: "Synthesizer" },
530
- { role: "Synthesizer", produces: "synthesis-report", consumedBy: "Product Expander" },
531
- { role: "Product Expander", produces: "expanded-concept", consumedBy: "Judge" },
532
- { role: "Judge", produces: "judge-report", consumedBy: null },
529
+ { role: "Context Analyst", produces: "context-map", consumedBy: "Normalizer" },
530
+ { role: "User Value Analyst", produces: "user-value-map", consumedBy: "Normalizer" },
531
+ { role: "Mechanics Analyst", produces: "mechanics-map", consumedBy: "Normalizer" },
532
+ { role: "Positioning Analyst", produces: "positioning-map", consumedBy: "Normalizer" },
533
+ { role: "Normalizer", produces: "provenance-atoms", consumedBy: "Contrarian Analyst" },
534
+ { role: "Contrarian Analyst", produces: "challenge-set", consumedBy: "Normalizer" },
535
+ // Rebut pass: Normalizer routes analyst responses (defend/narrow/retract)
536
+ { role: "Normalizer", produces: "rebuttal-set", consumedBy: "Synthesizer" },
537
+ { role: "Synthesizer", produces: "synthesis-report", consumedBy: "Product Expander" },
538
+ { role: "Product Expander", produces: "expanded-concept", consumedBy: "Judge" },
539
+ { role: "Judge", produces: "judge-report", consumedBy: null },
533
540
  ],
534
541
  },
535
542
  treatment: {
package/src/audit-cmd.mjs CHANGED
@@ -11,19 +11,9 @@
11
11
  */
12
12
 
13
13
  import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
14
- import { join, resolve } from "node:path";
15
- import { getMission, suggestMission } from "./mission.mjs";
14
+ import { join } from "node:path";
16
15
  import {
17
- createRun,
18
- startNextStep,
19
- getRunPosition,
20
- getArtifactChain,
21
- generateCompletionReport,
22
- formatCompletionReport,
23
- } from "./mission-run.mjs";
24
- import {
25
- createPersistentRun, findActiveRun, listRuns, loadRun,
26
- startNext, explainRun, getPosition, saveRun,
16
+ createPersistentRun, listRuns, loadRun, getPosition,
27
17
  } from "./run.mjs";
28
18
 
29
19
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -61,7 +51,7 @@ export async function auditCommand(args) {
61
51
 
62
52
  // ── roleos audit [run] ───────────────────────────────────────────────────────
63
53
 
64
- function cmdRun(extraArgs) {
54
+ async function cmdRun(extraArgs) {
65
55
  const cwd = process.cwd();
66
56
  const manifestPath = join(cwd, MANIFEST_FILE);
67
57
 
@@ -91,20 +81,28 @@ function cmdRun(extraArgs) {
91
81
  ? extraArgs.join(" ")
92
82
  : `Deep audit of ${manifest.repo || "current repo"}`;
93
83
 
94
- // Create a persistent run via the deep-audit mission
95
- const run = createPersistentRun(taskDesc, cwd, { forceMission: "deep-audit" });
84
+ // Create a persistent run via the deep-audit mission.
85
+ // Forwarding the manifest routes step construction through buildDynamicSteps,
86
+ // so auditor steps scale with components/boundaries instead of the static flow.
87
+ const run = await createPersistentRun(taskDesc, cwd, {
88
+ forceMission: "deep-audit",
89
+ manifest,
90
+ });
91
+
92
+ const componentCount = manifest.components?.length || 0;
93
+ const boundaryCount = manifest.boundary_clusters?.length ?? manifest.boundaries?.length ?? 0;
96
94
 
97
95
  console.log(`\nDeep Audit Started`);
98
96
  console.log(`──────────────────`);
99
97
  console.log(`Run: ${run.id}`);
100
98
  console.log(`Repo: ${manifest.repo || "unknown"}`);
101
- console.log(`Components: ${manifest.components?.length || 0}`);
102
- console.log(`Boundaries: ${manifest.boundaries?.length || 0}`);
99
+ console.log(`Components: ${componentCount}`);
100
+ console.log(`Boundaries: ${boundaryCount}`);
103
101
  console.log(`Steps: ${run.steps.length}`);
104
102
  console.log(`\nThe audit will dispatch:`);
105
- console.log(` - Component Auditor ×${manifest.components?.length || 0}`);
106
- console.log(` - Test Truth Auditor ×${manifest.components?.length || 0}`);
107
- console.log(` - Seam Auditor ×${manifest.boundaries?.length || 0}`);
103
+ console.log(` - Component Auditor ×${componentCount}`);
104
+ console.log(` - Test Truth Auditor ×${componentCount}`);
105
+ console.log(` - Seam Auditor ×${boundaryCount}`);
108
106
  console.log(` - Audit Synthesizer ×1`);
109
107
  console.log(` - Critic Reviewer ×1`);
110
108
  console.log(`\nRun 'roleos next' to begin the first step.`);
@@ -228,11 +226,13 @@ function generateManifest(cwd, manifestPath) {
228
226
  function cmdStatus() {
229
227
  const cwd = process.cwd();
230
228
 
231
- // Find the most recent deep-audit run
229
+ // Find the most recent deep-audit run.
230
+ // missionKey is authoritative; task keywords cover legacy runs created
231
+ // before missionKey was exposed by listRuns.
232
232
  const runs = listRuns(cwd);
233
233
  const auditRuns = runs.filter(r =>
234
- r.task.toLowerCase().includes("audit") ||
235
- r.level === "mission"
234
+ r.missionKey === "deep-audit" ||
235
+ r.task.toLowerCase().includes("audit")
236
236
  );
237
237
 
238
238
  if (auditRuns.length === 0) {
@@ -308,6 +308,12 @@ export function validateRoleNativeOutput(roleName, output) {
308
308
  // Validate item shape for object items
309
309
  if (typeof spec.items === "object" && !Array.isArray(spec.items)) {
310
310
  for (let i = 0; i < value.length; i++) {
311
+ // Guard malformed (null / non-object) items — the validator must
312
+ // report bad LLM output, not crash on it.
313
+ if (value[i] === null || typeof value[i] !== "object" || Array.isArray(value[i])) {
314
+ issues.push(`${fieldName}[${i}] must be an object`);
315
+ continue;
316
+ }
311
317
  for (const [itemField, itemType] of Object.entries(spec.items)) {
312
318
  if (value[i][itemField] === undefined) {
313
319
  issues.push(`${fieldName}[${i}].${itemField} is required`);
@@ -182,7 +182,10 @@ export function runOffloadPanel(supported, options = {}) {
182
182
  }
183
183
  }
184
184
 
185
- const checked = perCitation.filter((p) => p.panel_verdict === "supported" || disagreements.some((d) => d.id === p.id)).length;
185
+ // `checked` = citations the panel actually ADJUDICATED (a real verdict came back). "error" and
186
+ // "no_evidence" entries were never re-judged — counting them (or join-by-nullable-id tricks)
187
+ // would overstate the second seat's coverage in the receipt.
188
+ const checked = perCitation.filter((p) => p.panel_verdict !== "error" && p.panel_verdict !== "no_evidence").length;
186
189
  return {
187
190
  requested: true,
188
191
  reachable,
@@ -217,6 +220,9 @@ function contrastiveDetail(disagreements) {
217
220
  * - gate passing + panel DISAGREES on ≥1 supported citation -> escalate (local_panel_disagreement)
218
221
  * - gate passing + panel UNREACHABLE (and it was requested) -> escalate (local_panel_unreachable)
219
222
  * ("an unreachable gate is a closed gate" — same invariant prism uses)
223
+ * - gate passing + panel ERRORED on ≥1 citation it was asked to re-check -> escalate
224
+ * (local_panel_incomplete — per-citation errors are per-citation unreachability; a citation
225
+ * the second seat never adjudicated cannot be stamped fully verified)
220
226
  * - gate already blocking/advisory -> unchanged (panel adds notes only)
221
227
  *
222
228
  * @param {object} gate GateResult from gateCitations / runCitationGate
@@ -247,5 +253,24 @@ export function applyLocalPanel(gate, panel) {
247
253
  detail: contrastiveDetail(panel.disagreements),
248
254
  };
249
255
  }
256
+ // A flaky session that adjudicates 1 of 10 citations must not let the other 9 pass as
257
+ // "fully verified": every per-citation error closes the gate for the whole accept.
258
+ // ("no_evidence" entries stay notes — prism surfaced nothing to re-judge, and absence of
259
+ // evidence is not a contradiction; they are visible in perCitation and excluded from `checked`.)
260
+ const unadjudicated = (panel.perCitation || []).filter((p) => p.panel_verdict === "error");
261
+ if (unadjudicated.length > 0) {
262
+ const names = unadjudicated.slice(0, 5).map((p) => p.identifier || p.id || "(unidentified)").join(", ");
263
+ return {
264
+ ...annotated,
265
+ verdict: "escalate",
266
+ pass: false,
267
+ advisory: true,
268
+ reason: "local_panel_incomplete",
269
+ detail:
270
+ `the local panel errored on ${unadjudicated.length} citation(s) it was asked to re-check (${names}) — ` +
271
+ `these were never adjudicated by the second seat, so the accept cannot stand` +
272
+ (panel.detail ? `; ${panel.detail}` : ""),
273
+ };
274
+ }
250
275
  return annotated; // panel agrees (or had nothing to challenge) -> pass stands
251
276
  }
package/src/composite.mjs CHANGED
@@ -124,6 +124,10 @@ export function initExecution(runPlan) {
124
124
  export function advance(exec) {
125
125
  if (exec.status === "completed" || exec.status === "failed") return null;
126
126
 
127
+ // Preserve the blocked status — blockChild's contract is that the parent
128
+ // stays blocked until recoverChild; advancing must not mask the block.
129
+ if (exec.status === "blocked") return null;
130
+
127
131
  if (!exec.startedAt) exec.startedAt = new Date().toISOString();
128
132
  exec.status = "running";
129
133
 
package/src/entry.mjs CHANGED
@@ -274,7 +274,7 @@ function applyLadder(text, missionSug, missionScore, packSug, packScore, agreeme
274
274
  confidence: packScore,
275
275
  } : null,
276
276
  isComposite,
277
- warnings: [...warnings, "Free routing selected — task will be scored against all 31 roles"],
277
+ warnings: [...warnings, `Free routing selected — task will be scored against all ${ROLE_CATALOG.length} roles`],
278
278
  };
279
279
  }
280
280
 
@@ -284,7 +284,7 @@ function buildFreeRoutingHints(text, missionSug, packSug, isComposite, composite
284
284
  reason = `Task looks composite (${composite.detectedCategories.map(c => c.category).join(" + ")}). ` +
285
285
  `No single mission or pack covers all parts — use free routing with decomposition.`;
286
286
  } else if (!missionSug && !packSug) {
287
- reason = "No mission or pack matched. Task is novel — free routing will score all 31 roles.";
287
+ reason = `No mission or pack matched. Task is novel — free routing will score all ${ROLE_CATALOG.length} roles.`;
288
288
  } else {
289
289
  reason = "Mission and pack matches were too weak to commit. Free routing will let role scoring decide.";
290
290
  }
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",