role-os 1.4.0 → 1.5.0

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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Added
6
+
7
+ #### Hook Spine / Runtime Enforcement (Phase R)
8
+ - 5 lifecycle hooks: SessionStart, UserPromptSubmit, PreToolUse, SubagentStart, Stop
9
+ - `scaffoldHooks()` generates all 5 hook scripts in .claude/hooks/
10
+ - `roleos init claude` now scaffolds hooks + settings.local.json with hook config
11
+ - `roleos doctor` now checks for hook scripts (check 7) and settings hooks (check 8)
12
+
13
+ #### SessionStart hook
14
+ - Establishes session contract on every new session
15
+ - Records session ID, timestamp, initializes state tracking
16
+ - Adds context reminding Claude to use /roleos-route for non-trivial tasks
17
+
18
+ #### UserPromptSubmit hook
19
+ - Classifies prompts as substantial (>50 chars + action verbs)
20
+ - After 2+ substantial prompts without a route card, adds context reminder
21
+ - Does not block — advisory enforcement
22
+
23
+ #### PreToolUse hook
24
+ - Records all tool usage in session state
25
+ - Flags write tools (Bash, Write, Edit) used without route card after substantial work
26
+ - Advisory, not blocking — preserves operator control
27
+
28
+ #### SubagentStart hook
29
+ - Injects active role contract into delegated agents
30
+ - Ensures subagents inherit the Role OS session context
31
+
32
+ #### Stop hook
33
+ - Warns when substantial sessions end without route card or outcome artifact
34
+ - Advisory — does not block session exit
35
+ - Trivial sessions (< 2 substantial prompts) are exempt
36
+
37
+ ### Evidence
38
+ - 358 tests, zero failures
39
+ - 23 new hook tests covering all 5 lifecycle hooks
40
+
3
41
  ## 1.4.0
4
42
 
5
43
  ### Added
package/README.md CHANGED
@@ -179,6 +179,7 @@ Role OS operates **locally only**. It copies markdown templates and writes packe
179
179
  | **Composite execution** | Runs child packets in dependency order with artifact passing, branch recovery, and synthesis. | ✓ Shipped |
180
180
  | **Adaptive replanning** | Mid-run scope changes, findings, or new requirements update the plan without restarting. | ✓ Shipped |
181
181
  | **Session spine** | `roleos init claude` scaffolds CLAUDE.md, /roleos-route, /roleos-review, /roleos-status. `roleos doctor` verifies wiring. Route cards prove engagement. | ✓ Shipped |
182
+ | **Hook spine** | 5 lifecycle hooks (SessionStart, PromptSubmit, PreToolUse, SubagentStart, Stop). Advisory enforcement: route card reminders, write-tool gating, subagent role injection, completion audit. | ✓ Shipped |
182
183
 
183
184
  ## Status
184
185
 
@@ -188,7 +189,8 @@ Role OS operates **locally only**. It copies markdown templates and writes packe
188
189
  - v1.1.0: 31 roles, full routing spine, conflict detection, escalation, evidence, dispatch, 7 proven team packs. 35 execution trials. 212 tests.
189
190
  - v1.2.0: Calibrated packs promoted to default entry. Auto-selection, mismatch detection, alternative suggestion, free-routing fallback. 246 tests.
190
191
  - v1.3.0: Outcome calibration, mixed-task decomposition, composite execution, adaptive replanning. 317 tests.
191
- - **v1.4.0**: Session spine — `roleos init claude`, `roleos doctor`, route cards, /roleos-route + /roleos-review + /roleos-status commands. Adoption certainty, not just routing cleverness. 335 tests.
192
+ - v1.4.0: Session spine — `roleos init claude`, `roleos doctor`, route cards, /roleos-route + /roleos-review + /roleos-status commands. 335 tests.
193
+ - **v1.5.0**: Hook spine — 5 lifecycle hooks for runtime enforcement. Advisory route card reminders, write-tool gating, subagent role injection, completion audit. 358 tests.
192
194
 
193
195
  ## License
194
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "role-os",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Role OS — a multi-Claude operating system where 31 specialized roles execute work through contracts, conflict detection, escalation, and structured evidence. 7 proven team packs for common task families.",
5
5
  "homepage": "https://mcp-tool-shop-org.github.io/role-os/",
6
6
  "bugs": {
package/src/hooks.mjs ADDED
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Hook Spine — v1.5.0 Runtime Enforcement
3
+ *
4
+ * Hooks verify, gate, and record. The actual routing intelligence
5
+ * stays in explicit artifacts (route cards, pack selection).
6
+ * Hooks are the proof layer, not the decision layer.
7
+ *
8
+ * Four guarantees:
9
+ * 1. No substantial task begins without a route artifact
10
+ * 2. No tool execution drifts outside the selected role envelope
11
+ * 3. No subagent runs without inheriting the role contract
12
+ * 4. No session ends without an outcome artifact
13
+ *
14
+ * Hook lifecycle events used:
15
+ * - SessionStart: establish session contract
16
+ * - UserPromptSubmit: classify before improvising
17
+ * - PreToolUse: enforce role-specific tool law
18
+ * - SubagentStart: inject role contract into delegation
19
+ * - Stop: prevent false completion
20
+ */
21
+
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
23
+ import { join } from "node:path";
24
+
25
+ // ── Hook script generators ────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Generate the settings.json hooks configuration.
29
+ * Each hook is a shell command that runs a Node script from .claude/hooks/.
30
+ *
31
+ * @returns {object} The hooks configuration object for settings.json
32
+ */
33
+ export function generateHooksConfig() {
34
+ return {
35
+ hooks: {
36
+ SessionStart: [
37
+ {
38
+ matcher: "",
39
+ hooks: [{
40
+ type: "command",
41
+ command: "node .claude/hooks/session-start.mjs",
42
+ }],
43
+ },
44
+ ],
45
+ UserPromptSubmit: [
46
+ {
47
+ matcher: "",
48
+ hooks: [{
49
+ type: "command",
50
+ command: "node .claude/hooks/prompt-submit.mjs",
51
+ }],
52
+ },
53
+ ],
54
+ PreToolUse: [
55
+ {
56
+ matcher: "",
57
+ hooks: [{
58
+ type: "command",
59
+ command: "node .claude/hooks/pre-tool-use.mjs",
60
+ }],
61
+ },
62
+ ],
63
+ SubagentStart: [
64
+ {
65
+ matcher: "",
66
+ hooks: [{
67
+ type: "command",
68
+ command: "node .claude/hooks/subagent-start.mjs",
69
+ }],
70
+ },
71
+ ],
72
+ Stop: [
73
+ {
74
+ matcher: "",
75
+ hooks: [{
76
+ type: "command",
77
+ command: "node .claude/hooks/stop.mjs",
78
+ }],
79
+ },
80
+ ],
81
+ },
82
+ };
83
+ }
84
+
85
+ // ── Session state ─────────────────────────────────────────────────────────────
86
+
87
+ const SESSION_STATE_FILE = ".claude/hooks/session-state.json";
88
+
89
+ /**
90
+ * Read or create session state.
91
+ * @param {string} cwd
92
+ * @returns {object}
93
+ */
94
+ export function getSessionState(cwd) {
95
+ const path = join(cwd, SESSION_STATE_FILE);
96
+ if (existsSync(path)) {
97
+ try { return JSON.parse(readFileSync(path, "utf-8")); }
98
+ catch { /* fall through */ }
99
+ }
100
+ return {
101
+ sessionId: null,
102
+ routeCardPresent: false,
103
+ activeRole: null,
104
+ activePack: null,
105
+ toolsUsed: [],
106
+ promptCount: 0,
107
+ substantivePrompts: 0,
108
+ outcomeRecorded: false,
109
+ startedAt: null,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Save session state.
115
+ * @param {string} cwd
116
+ * @param {object} state
117
+ */
118
+ export function saveSessionState(cwd, state) {
119
+ const dir = join(cwd, ".claude", "hooks");
120
+ mkdirSync(dir, { recursive: true });
121
+ writeFileSync(join(cwd, SESSION_STATE_FILE), JSON.stringify(state, null, 2));
122
+ }
123
+
124
+ // ── Hook logic ────────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * SessionStart hook logic.
128
+ * Establishes session contract, checks for existing route artifacts.
129
+ *
130
+ * @param {object} input - Hook input (session_id, cwd, etc.)
131
+ * @returns {{ addContext?: string }}
132
+ */
133
+ export function onSessionStart(input) {
134
+ const cwd = input.cwd || process.cwd();
135
+ const state = getSessionState(cwd);
136
+
137
+ state.sessionId = input.session_id || `session-${Date.now()}`;
138
+ state.startedAt = new Date().toISOString();
139
+ state.routeCardPresent = false;
140
+ state.promptCount = 0;
141
+ state.substantivePrompts = 0;
142
+ state.outcomeRecorded = false;
143
+
144
+ saveSessionState(cwd, state);
145
+
146
+ // Check if Role OS is initialized
147
+ const hasRoleOs = existsSync(join(cwd, ".claude", "agents"));
148
+
149
+ if (hasRoleOs) {
150
+ return {
151
+ addContext: "Role OS is active in this repo. For non-trivial tasks, run /roleos-route to produce a route card before beginning work. The route card proves the task was classified and the right team was chosen.",
152
+ };
153
+ }
154
+
155
+ return {};
156
+ }
157
+
158
+ /**
159
+ * UserPromptSubmit hook logic.
160
+ * Detects substantial prompts and warns if no route artifact exists.
161
+ *
162
+ * @param {object} input - { prompt, session_id, cwd }
163
+ * @returns {{ addContext?: string, block?: { reason: string } }}
164
+ */
165
+ export function onPromptSubmit(input) {
166
+ const cwd = input.cwd || process.cwd();
167
+ const state = getSessionState(cwd);
168
+ const prompt = input.prompt || "";
169
+
170
+ state.promptCount++;
171
+
172
+ // Classify as substantial if > 50 chars and contains action words
173
+ const isSubstantial = prompt.length > 50 && /\b(implement|add|fix|build|create|refactor|review|ship|deploy|test|write|update|change|remove|migrate)\b/i.test(prompt);
174
+
175
+ if (isSubstantial) {
176
+ state.substantivePrompts++;
177
+ }
178
+
179
+ saveSessionState(cwd, state);
180
+
181
+ // If this is the 2nd+ substantial prompt without a route card, remind
182
+ if (isSubstantial && state.substantivePrompts >= 2 && !state.routeCardPresent) {
183
+ return {
184
+ addContext: "Note: This is a substantial task and no Role OS route card has been produced yet. Consider running /roleos-route to classify the task and choose the right team. A route card ensures the work is staffed correctly.",
185
+ };
186
+ }
187
+
188
+ return {};
189
+ }
190
+
191
+ /**
192
+ * PreToolUse hook logic.
193
+ * Checks tool usage against active role envelope.
194
+ *
195
+ * @param {object} input - { tool_name, tool_input, session_id, cwd }
196
+ * @returns {{ allow?: boolean, deny?: { reason: string }, addContext?: string }}
197
+ */
198
+ export function onPreToolUse(input) {
199
+ const cwd = input.cwd || process.cwd();
200
+ const state = getSessionState(cwd);
201
+ const toolName = input.tool_name || "";
202
+
203
+ // Record tool usage
204
+ if (!state.toolsUsed.includes(toolName)) {
205
+ state.toolsUsed.push(toolName);
206
+ saveSessionState(cwd, state);
207
+ }
208
+
209
+ // Advisory: flag write tools without route card after substantial prompts
210
+ const writeTools = ["Bash", "Write", "Edit", "NotebookEdit"];
211
+ if (writeTools.includes(toolName) && !state.routeCardPresent && (state.substantivePrompts || 0) >= 2) {
212
+ return {
213
+ addContext: `Write tool "${toolName}" used without a route card. If this is substantial work, consider routing first.`,
214
+ };
215
+ }
216
+
217
+ // If a role is active, read-only tools are always fine
218
+ if (state.activeRole && state.activePack) {
219
+ const readOnlyTools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
220
+ if (readOnlyTools.includes(toolName)) {
221
+ return { allow: true };
222
+ }
223
+ }
224
+
225
+ return {};
226
+ }
227
+
228
+ /**
229
+ * SubagentStart hook logic.
230
+ * Injects role contract context into delegated agents.
231
+ *
232
+ * @param {object} input - { agent_id, agent_type, session_id, cwd }
233
+ * @returns {{ addContext?: string }}
234
+ */
235
+ export function onSubagentStart(input) {
236
+ const cwd = input.cwd || process.cwd();
237
+ const state = getSessionState(cwd);
238
+
239
+ if (state.activeRole) {
240
+ return {
241
+ addContext: `This subagent is operating under Role OS. Active role: ${state.activeRole}. Pack: ${state.activePack || "free routing"}. Follow the role contract and produce structured handoffs.`,
242
+ };
243
+ }
244
+
245
+ return {};
246
+ }
247
+
248
+ /**
249
+ * Stop hook logic.
250
+ * Prevents false completion — warns if no route card or outcome exists.
251
+ *
252
+ * @param {object} input - { session_id, cwd, stop_reason }
253
+ * @returns {{ block?: { reason: string }, addContext?: string }}
254
+ */
255
+ export function onStop(input) {
256
+ const cwd = input.cwd || process.cwd();
257
+ const state = getSessionState(cwd);
258
+
259
+ // Only enforce on sessions that had substantial work
260
+ if (state.substantivePrompts < 2) {
261
+ return {}; // Trivial session, let it end
262
+ }
263
+
264
+ const warnings = [];
265
+
266
+ if (!state.routeCardPresent) {
267
+ warnings.push("No route card was produced during this session.");
268
+ }
269
+
270
+ if (!state.outcomeRecorded) {
271
+ warnings.push("No outcome artifact was recorded.");
272
+ }
273
+
274
+ if (warnings.length > 0) {
275
+ return {
276
+ addContext: `Role OS session audit: ${warnings.join(" ")} Consider documenting the outcome before ending.`,
277
+ };
278
+ }
279
+
280
+ return {};
281
+ }
282
+
283
+ // ── Hook script file generators ───────────────────────────────────────────────
284
+
285
+ /**
286
+ * Generate hook script files for .claude/hooks/.
287
+ * These are the actual scripts that settings.json points to.
288
+ *
289
+ * @param {string} cwd
290
+ * @returns {{ created: string[], skipped: string[] }}
291
+ */
292
+ export function scaffoldHooks(cwd) {
293
+ const created = [];
294
+ const skipped = [];
295
+ const hooksDir = join(cwd, ".claude", "hooks");
296
+ mkdirSync(hooksDir, { recursive: true });
297
+
298
+ const scripts = {
299
+ "session-start.mjs": generateSessionStartScript(),
300
+ "prompt-submit.mjs": generatePromptSubmitScript(),
301
+ "pre-tool-use.mjs": generatePreToolUseScript(),
302
+ "subagent-start.mjs": generateSubagentStartScript(),
303
+ "stop.mjs": generateStopScript(),
304
+ };
305
+
306
+ for (const [name, content] of Object.entries(scripts)) {
307
+ const path = join(hooksDir, name);
308
+ if (!existsSync(path)) {
309
+ writeFileSync(path, content);
310
+ created.push(`.claude/hooks/${name}`);
311
+ } else {
312
+ skipped.push(`.claude/hooks/${name}`);
313
+ }
314
+ }
315
+
316
+ return { created, skipped };
317
+ }
318
+
319
+ function generateSessionStartScript() {
320
+ return `#!/usr/bin/env node
321
+ // Role OS SessionStart hook — establishes session contract
322
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
323
+ import { join } from "node:path";
324
+
325
+ const input = JSON.parse(readFileSync("/dev/stdin", "utf-8").toString() || "{}");
326
+ const cwd = input.cwd || process.cwd();
327
+ const stateDir = join(cwd, ".claude", "hooks");
328
+ mkdirSync(stateDir, { recursive: true });
329
+
330
+ const state = {
331
+ sessionId: input.session_id || \`session-\${Date.now()}\`,
332
+ startedAt: new Date().toISOString(),
333
+ routeCardPresent: false,
334
+ activeRole: null,
335
+ activePack: null,
336
+ toolsUsed: [],
337
+ promptCount: 0,
338
+ substantivePrompts: 0,
339
+ outcomeRecorded: false,
340
+ };
341
+
342
+ writeFileSync(join(stateDir, "session-state.json"), JSON.stringify(state, null, 2));
343
+
344
+ const hasRoleOs = existsSync(join(cwd, ".claude", "agents"));
345
+ if (hasRoleOs) {
346
+ console.log(JSON.stringify({
347
+ addContext: "Role OS is active. For non-trivial tasks, run /roleos-route first.",
348
+ }));
349
+ }
350
+ `;
351
+ }
352
+
353
+ function generatePromptSubmitScript() {
354
+ return `#!/usr/bin/env node
355
+ // Role OS UserPromptSubmit hook — classify before improvising
356
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
357
+ import { join } from "node:path";
358
+
359
+ const input = JSON.parse(readFileSync("/dev/stdin", "utf-8").toString() || "{}");
360
+ const cwd = input.cwd || process.cwd();
361
+ const statePath = join(cwd, ".claude", "hooks", "session-state.json");
362
+
363
+ let state = {};
364
+ if (existsSync(statePath)) {
365
+ try { state = JSON.parse(readFileSync(statePath, "utf-8")); } catch {}
366
+ }
367
+
368
+ const prompt = input.prompt || "";
369
+ state.promptCount = (state.promptCount || 0) + 1;
370
+
371
+ const isSubstantial = prompt.length > 50 &&
372
+ /\\b(implement|add|fix|build|create|refactor|review|ship|deploy|test|write|update|change|remove|migrate)\\b/i.test(prompt);
373
+
374
+ if (isSubstantial) state.substantivePrompts = (state.substantivePrompts || 0) + 1;
375
+
376
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
377
+
378
+ if (isSubstantial && (state.substantivePrompts || 0) >= 2 && !state.routeCardPresent) {
379
+ console.log(JSON.stringify({
380
+ addContext: "No Role OS route card yet. Consider /roleos-route to classify this task.",
381
+ }));
382
+ }
383
+ `;
384
+ }
385
+
386
+ function generatePreToolUseScript() {
387
+ return `#!/usr/bin/env node
388
+ // Role OS PreToolUse hook — enforce role-specific tool law
389
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
390
+ import { join } from "node:path";
391
+
392
+ const input = JSON.parse(readFileSync("/dev/stdin", "utf-8").toString() || "{}");
393
+ const cwd = input.cwd || process.cwd();
394
+ const statePath = join(cwd, ".claude", "hooks", "session-state.json");
395
+
396
+ let state = {};
397
+ if (existsSync(statePath)) {
398
+ try { state = JSON.parse(readFileSync(statePath, "utf-8")); } catch {}
399
+ }
400
+
401
+ const toolName = input.tool_name || "";
402
+ if (!state.toolsUsed) state.toolsUsed = [];
403
+ if (!state.toolsUsed.includes(toolName)) {
404
+ state.toolsUsed.push(toolName);
405
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
406
+ }
407
+
408
+ // Advisory: flag write tools without route card
409
+ const writeTools = ["Bash", "Write", "Edit", "NotebookEdit"];
410
+ if (writeTools.includes(toolName) && !state.routeCardPresent && (state.substantivePrompts || 0) >= 2) {
411
+ console.log(JSON.stringify({
412
+ addContext: \`Write tool "\${toolName}" used without route card. Consider /roleos-route.\`,
413
+ }));
414
+ }
415
+ `;
416
+ }
417
+
418
+ function generateSubagentStartScript() {
419
+ return `#!/usr/bin/env node
420
+ // Role OS SubagentStart hook — inject role contract
421
+ import { readFileSync, existsSync } from "node:fs";
422
+ import { join } from "node:path";
423
+
424
+ const input = JSON.parse(readFileSync("/dev/stdin", "utf-8").toString() || "{}");
425
+ const cwd = input.cwd || process.cwd();
426
+ const statePath = join(cwd, ".claude", "hooks", "session-state.json");
427
+
428
+ let state = {};
429
+ if (existsSync(statePath)) {
430
+ try { state = JSON.parse(readFileSync(statePath, "utf-8")); } catch {}
431
+ }
432
+
433
+ if (state.activeRole) {
434
+ console.log(JSON.stringify({
435
+ addContext: \`Role OS active. Role: \${state.activeRole}. Pack: \${state.activePack || "free routing"}. Follow role contract.\`,
436
+ }));
437
+ }
438
+ `;
439
+ }
440
+
441
+ function generateStopScript() {
442
+ return `#!/usr/bin/env node
443
+ // Role OS Stop hook — prevent false completion
444
+ import { readFileSync, existsSync } from "node:fs";
445
+ import { join } from "node:path";
446
+
447
+ const input = JSON.parse(readFileSync("/dev/stdin", "utf-8").toString() || "{}");
448
+ const cwd = input.cwd || process.cwd();
449
+ const statePath = join(cwd, ".claude", "hooks", "session-state.json");
450
+
451
+ let state = {};
452
+ if (existsSync(statePath)) {
453
+ try { state = JSON.parse(readFileSync(statePath, "utf-8")); } catch {}
454
+ }
455
+
456
+ // Only enforce on sessions with substantial work
457
+ if ((state.substantivePrompts || 0) < 2) process.exit(0);
458
+
459
+ const warnings = [];
460
+ if (!state.routeCardPresent) warnings.push("No route card produced.");
461
+ if (!state.outcomeRecorded) warnings.push("No outcome artifact recorded.");
462
+
463
+ if (warnings.length > 0) {
464
+ console.log(JSON.stringify({
465
+ addContext: \`Role OS audit: \${warnings.join(" ")} Consider documenting the outcome.\`,
466
+ }));
467
+ }
468
+ `;
469
+ }
package/src/session.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { writeFileSafe } from "./fs-utils.mjs";
17
+ import { scaffoldHooks, generateHooksConfig } from "./hooks.mjs";
17
18
 
18
19
  // ── roleos init claude ────────────────────────────────────────────────────────
19
20
 
@@ -76,6 +77,34 @@ export function scaffoldClaude(cwd, options = {}) {
76
77
  skipped.push(".claude/commands/roleos-status.md");
77
78
  }
78
79
 
80
+ // 5. Hook scripts
81
+ const hookResult = scaffoldHooks(cwd);
82
+ created.push(...hookResult.created);
83
+ skipped.push(...hookResult.skipped);
84
+
85
+ // 6. Settings.json with hooks config
86
+ const settingsPath = join(cwd, ".claude", "settings.local.json");
87
+ if (!existsSync(settingsPath) || options.force) {
88
+ const hooksConfig = generateHooksConfig();
89
+ writeFileSync(settingsPath, JSON.stringify(hooksConfig, null, 2));
90
+ created.push(".claude/settings.local.json");
91
+ } else {
92
+ // Check if hooks are already configured
93
+ try {
94
+ const existing = JSON.parse(readFileSync(settingsPath, "utf-8"));
95
+ if (!existing.hooks) {
96
+ const hooksConfig = generateHooksConfig();
97
+ existing.hooks = hooksConfig.hooks;
98
+ writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
99
+ created.push(".claude/settings.local.json (hooks added)");
100
+ } else {
101
+ skipped.push(".claude/settings.local.json (hooks already configured)");
102
+ }
103
+ } catch {
104
+ skipped.push(".claude/settings.local.json (could not parse existing)");
105
+ }
106
+ }
107
+
79
108
  return { created, skipped };
80
109
  }
81
110
 
@@ -162,6 +191,35 @@ export function doctor(cwd) {
162
191
  detail: existsSync(packetsDir) ? "exists" : "no packets yet — run roleos packet new",
163
192
  });
164
193
 
194
+ // Check 7: Hook scripts exist
195
+ const hooksDir = join(cwd, ".claude", "hooks");
196
+ const hookFiles = ["session-start.mjs", "prompt-submit.mjs", "pre-tool-use.mjs", "subagent-start.mjs", "stop.mjs"];
197
+ const existingHooks = hookFiles.filter(f => existsSync(join(hooksDir, f)));
198
+ if (existingHooks.length === hookFiles.length) {
199
+ checks.push({ name: "hook scripts", status: "pass", detail: `all ${hookFiles.length} hooks present` });
200
+ } else if (existingHooks.length > 0) {
201
+ checks.push({ name: "hook scripts", status: "warn", detail: `${existingHooks.length}/${hookFiles.length} hooks present` });
202
+ } else {
203
+ checks.push({ name: "hook scripts", status: "warn", detail: "no hooks — run roleos init claude for runtime enforcement" });
204
+ }
205
+
206
+ // Check 8: Settings has hooks configured
207
+ const settingsPath = join(cwd, ".claude", "settings.local.json");
208
+ if (existsSync(settingsPath)) {
209
+ try {
210
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
211
+ if (settings.hooks) {
212
+ checks.push({ name: "hooks in settings", status: "pass", detail: "configured" });
213
+ } else {
214
+ checks.push({ name: "hooks in settings", status: "warn", detail: "settings.local.json exists but no hooks section" });
215
+ }
216
+ } catch {
217
+ checks.push({ name: "hooks in settings", status: "warn", detail: "settings.local.json exists but could not parse" });
218
+ }
219
+ } else {
220
+ checks.push({ name: "hooks in settings", status: "warn", detail: "no settings.local.json — hooks not active" });
221
+ }
222
+
165
223
  const healthy = checks.every(c => c.status !== "fail");
166
224
 
167
225
  return { checks, healthy };