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.
- package/CHANGELOG.md +37 -0
- package/README.es.md +28 -11
- package/README.fr.md +25 -8
- package/README.hi.md +25 -8
- package/README.it.md +28 -11
- package/README.ja.md +27 -10
- package/README.md +25 -8
- package/README.pt-BR.md +25 -8
- package/README.zh.md +25 -8
- package/bin/roleos.mjs +3 -2
- package/package.json +1 -1
- package/src/artifacts.mjs +14 -7
- package/src/audit-cmd.mjs +23 -23
- package/src/brainstorm-roles.mjs +6 -0
- package/src/citation-panel.mjs +26 -1
- package/src/composite.mjs +4 -0
- package/src/entry.mjs +2 -2
- package/src/hooks.mjs +107 -27
- package/src/knowledge/analyze-artifact-evidence.mjs +19 -9
- package/src/knowledge/fallback-policy.mjs +19 -7
- package/src/knowledge/resolve-overlay.mjs +21 -8
- package/src/knowledge/retrieve-for-dispatch.mjs +9 -4
- package/src/mission-run.mjs +11 -2
- package/src/packs-cmd.mjs +1 -1
- package/src/review.mjs +11 -2
- package/src/role-dossiers.json +1 -1
- package/src/route.mjs +41 -8
- package/src/run-cmd.mjs +0 -1
- package/src/run.mjs +67 -15
- package/src/session.mjs +3 -1
- package/src/specialist/capability-gate.mjs +35 -18
- package/src/specialist/dispatch.mjs +8 -3
- package/src/specialist/registry.mjs +6 -0
- package/src/specialist/shadow.mjs +13 -3
- package/src/specialist/state.mjs +94 -26
- package/src/state-machine.mjs +2 -2
- package/src/status.mjs +4 -2
- package/src/swarm/build-gate.mjs +11 -2
- package/src/swarm/persist-bridge.mjs +4 -3
- package/src/swarm-cmd.mjs +88 -46
- package/src/verify-citations-cmd.mjs +17 -1
- package/src/verify-citations.mjs +31 -7
- package/starter-pack/README.md +22 -14
- package/starter-pack/handbook.md +4 -4
- package/starter-pack/policy/routing-rules.md +42 -0
- package/starter-pack/policy/tool-permissions.md +21 -0
- 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
|
-
##
|
|
123
|
+
## 包含 61 个角色的目录
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
|
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
|
-
|
|
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/ ←
|
|
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
|
|
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
|
|
76
|
-
roleos swarm approve Approve the current
|
|
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.
|
|
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
|
|
527
|
-
{ role: "User Value
|
|
528
|
-
{ role: "
|
|
529
|
-
{ role: "
|
|
530
|
-
{ role: "
|
|
531
|
-
{ role: "
|
|
532
|
-
|
|
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
|
|
15
|
-
import { getMission, suggestMission } from "./mission.mjs";
|
|
14
|
+
import { join } from "node:path";
|
|
16
15
|
import {
|
|
17
|
-
|
|
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
|
-
|
|
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: ${
|
|
102
|
-
console.log(`Boundaries: ${
|
|
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 ×${
|
|
106
|
-
console.log(` - Test Truth Auditor ×${
|
|
107
|
-
console.log(` - Seam Auditor ×${
|
|
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.
|
|
235
|
-
r.
|
|
234
|
+
r.missionKey === "deep-audit" ||
|
|
235
|
+
r.task.toLowerCase().includes("audit")
|
|
236
236
|
);
|
|
237
237
|
|
|
238
238
|
if (auditRuns.length === 0) {
|
package/src/brainstorm-roles.mjs
CHANGED
|
@@ -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`);
|
package/src/citation-panel.mjs
CHANGED
|
@@ -182,7 +182,10 @@ export function runOffloadPanel(supported, options = {}) {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 —
|
|
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")); }
|
|
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).
|
|
480
|
-
// .claude/role-os/tool-contracts.json catalog entry AND the role-os library is
|
|
481
|
-
//
|
|
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
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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: `${
|
|
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 =
|
|
46
|
-
if (totalRelevant > 0 &&
|
|
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: `${
|
|
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
|
|
77
|
+
if ((bundle.provenance?.trust_posture ?? "weak") === "weak") {
|
|
66
78
|
return {
|
|
67
79
|
state: "no_strong_match",
|
|
68
80
|
action: "warn",
|