role-os 2.7.1 → 2.9.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/src/hooks.mjs CHANGED
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
23
23
  import { join } from "node:path";
24
+ import { schemaFloor, contractFloor } from "./specialist/conformance-consult.mjs";
25
+ import { capabilityGate } from "./specialist/capability-gate.mjs";
24
26
 
25
27
  // ── Hook script generators ────────────────────────────────────────────────────
26
28
 
@@ -188,14 +190,57 @@ export function onPromptSubmit(input) {
188
190
  return {};
189
191
  }
190
192
 
193
+ const TOOL_CONTRACTS_FILE = ".claude/role-os/tool-contracts.json";
194
+
195
+ /**
196
+ * Load the Tool-Call Conformance contract catalog from the repo (the rollout's per-tool knowledge base):
197
+ * { "<tool_name>": { contract, params, constraints, state_struct? } }. Fail-safe — a missing or malformed
198
+ * file yields {} (conformance simply does not run), so this never breaks a tool call.
199
+ * @param {string} cwd
200
+ * @returns {Record<string, object>}
201
+ */
202
+ export function loadToolContracts(cwd) {
203
+ try {
204
+ const p = join(cwd, TOOL_CONTRACTS_FILE);
205
+ if (!existsSync(p)) return {};
206
+ return JSON.parse(readFileSync(p, "utf-8")) || {};
207
+ } catch {
208
+ return {};
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Deterministic conformance ADVISORY for a tool call — wedge #1's live seam. Runs the schema floor
214
+ * (L1-L3) + the computable contract floor (L4) against the catalogued tool. Returns an advisory string
215
+ * only when the floor PROVES a violation; otherwise null. Never throws, never denies — the watcher is
216
+ * advisory + fail-open by design (a false "conformant" is the costly error, never a blocked good call).
217
+ */
218
+ export function conformanceAdvisory(cwd, toolName, toolInput, opts = {}) {
219
+ try {
220
+ const catalog = opts.toolContracts || loadToolContracts(cwd);
221
+ const entry = catalog && catalog[toolName];
222
+ if (!entry) return null;
223
+ const tool = { name: toolName, contract: entry.contract, params: entry.params || [], constraints: entry.constraints || [] };
224
+ const call = toolInput && typeof toolInput === "object" ? toolInput : {};
225
+ const v = [...schemaFloor(tool, call).violations, ...contractFloor(tool, call, entry.state_struct || null).violations];
226
+ if (!v.length) return null;
227
+ return `Tool-Call Conformance (advisory): the "${toolName}" call appears NONCONFORMANT — ${v.join("; ")}. The deterministic floor proved this; review before relying on the result.`;
228
+ } catch {
229
+ return null; // a hook must never break a tool call
230
+ }
231
+ }
232
+
191
233
  /**
192
234
  * PreToolUse hook logic.
193
- * Checks tool usage against active role envelope.
235
+ * Records tool usage, flags write tools without a route card, and (wedge #1) attaches an ADVISORY
236
+ * Tool-Call Conformance verdict from the deterministic floor when the tool is catalogued. Advisory only
237
+ * — it never denies a call.
194
238
  *
195
239
  * @param {object} input - { tool_name, tool_input, session_id, cwd }
240
+ * @param {object} [opts] - { toolContracts } to inject a catalog (tests); defaults to loadToolContracts(cwd)
196
241
  * @returns {{ allow?: boolean, deny?: { reason: string }, addContext?: string }}
197
242
  */
198
- export function onPreToolUse(input) {
243
+ export function onPreToolUse(input, opts = {}) {
199
244
  const cwd = input.cwd || process.cwd();
200
245
  const state = getSessionState(cwd);
201
246
  const toolName = input.tool_name || "";
@@ -206,23 +251,33 @@ export function onPreToolUse(input) {
206
251
  saveSessionState(cwd, state);
207
252
  }
208
253
 
254
+ // Capability gate (opt-in via ROLEOS_CAPABILITY_GATE, fail-closed): an irreversible action without
255
+ // a granted capability is DENIED — bounds what a wrong verdict (honest or adversarial) can DO
256
+ // (POLA / CaMeL). Default OFF => pure no-op. Distinct from the advisory conformance floor below.
257
+ const cap = capabilityGate(cwd, toolName, input.tool_input, opts);
258
+ if (cap.denied) return { deny: { reason: cap.reason } };
259
+
260
+ const notes = [];
261
+
209
262
  // Advisory: flag write tools without route card after substantial prompts
210
263
  const writeTools = ["Bash", "Write", "Edit", "NotebookEdit"];
211
264
  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
- };
265
+ notes.push(`Write tool "${toolName}" used without a route card. If this is substantial work, consider routing first.`);
215
266
  }
216
267
 
268
+ // Wedge #1: deterministic conformance floor (advisory, fail-open) on the proposed call
269
+ const conf = conformanceAdvisory(cwd, toolName, input.tool_input, opts);
270
+ if (conf) notes.push(conf);
271
+
217
272
  // If a role is active, read-only tools are always fine
218
273
  if (state.activeRole && state.activePack) {
219
274
  const readOnlyTools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
220
275
  if (readOnlyTools.includes(toolName)) {
221
- return { allow: true };
276
+ return notes.length ? { allow: true, addContext: notes.join(" ") } : { allow: true };
222
277
  }
223
278
  }
224
279
 
225
- return {};
280
+ return notes.length ? { addContext: notes.join(" ") } : {};
226
281
  }
227
282
 
228
283
  /**
@@ -343,10 +398,15 @@ writeFileSync(join(stateDir, "session-state.json"), JSON.stringify(state, null,
343
398
 
344
399
  const hasRoleOs = existsSync(join(cwd, ".claude", "agents"));
345
400
  if (hasRoleOs) {
401
+ // SessionStart wire protocol (current Claude Code): hookSpecificOutput.additionalContext + exit 0.
346
402
  console.log(JSON.stringify({
347
- addContext: "Role OS is active. For non-trivial tasks, run /roleos-route first.",
403
+ hookSpecificOutput: {
404
+ hookEventName: "SessionStart",
405
+ additionalContext: "Role OS is active. For non-trivial tasks, run /roleos-route first.",
406
+ },
348
407
  }));
349
408
  }
409
+ process.exit(0);
350
410
  `;
351
411
  }
352
412
 
@@ -376,16 +436,21 @@ if (isSubstantial) state.substantivePrompts = (state.substantivePrompts || 0) +
376
436
  writeFileSync(statePath, JSON.stringify(state, null, 2));
377
437
 
378
438
  if (isSubstantial && (state.substantivePrompts || 0) >= 2 && !state.routeCardPresent) {
439
+ // UserPromptSubmit wire protocol (current Claude Code): hookSpecificOutput.additionalContext + exit 0.
379
440
  console.log(JSON.stringify({
380
- addContext: "No Role OS route card yet. Consider /roleos-route to classify this task.",
441
+ hookSpecificOutput: {
442
+ hookEventName: "UserPromptSubmit",
443
+ additionalContext: "No Role OS route card yet. Consider /roleos-route to classify this task.",
444
+ },
381
445
  }));
382
446
  }
447
+ process.exit(0);
383
448
  `;
384
449
  }
385
450
 
386
451
  function generatePreToolUseScript() {
387
452
  return `#!/usr/bin/env node
388
- // Role OS PreToolUse hook — enforce role-specific tool law
453
+ // Role OS PreToolUse hook — enforce role-specific tool law + Tool-Call Conformance floor (advisory)
389
454
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
390
455
  import { join } from "node:path";
391
456
 
@@ -405,13 +470,48 @@ if (!state.toolsUsed.includes(toolName)) {
405
470
  writeFileSync(statePath, JSON.stringify(state, null, 2));
406
471
  }
407
472
 
408
- // Advisory: flag write tools without route card
473
+ const notes = [];
409
474
  const writeTools = ["Bash", "Write", "Edit", "NotebookEdit"];
410
475
  if (writeTools.includes(toolName) && !state.routeCardPresent && (state.substantivePrompts || 0) >= 2) {
476
+ notes.push(\`Write tool "\${toolName}" used without route card. Consider /roleos-route.\`);
477
+ }
478
+
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.
482
+ try {
483
+ const catPath = join(cwd, ".claude", "role-os", "tool-contracts.json");
484
+ if (existsSync(catPath)) {
485
+ const catalog = JSON.parse(readFileSync(catPath, "utf-8"));
486
+ const entry = catalog && catalog[toolName];
487
+ 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("; ")}.\`);
493
+ }
494
+ }
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) */ }
506
+
507
+ // PreToolUse wire protocol (current Claude Code): inject advisory context via
508
+ // hookSpecificOutput.additionalContext + exit 0. A bare { addContext } is IGNORED; exit 2 would BLOCK.
509
+ if (notes.length) {
411
510
  console.log(JSON.stringify({
412
- addContext: \`Write tool "\${toolName}" used without route card. Consider /roleos-route.\`,
511
+ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: notes.join(" ") },
413
512
  }));
414
513
  }
514
+ process.exit(0);
415
515
  `;
416
516
  }
417
517
 
@@ -431,10 +531,15 @@ if (existsSync(statePath)) {
431
531
  }
432
532
 
433
533
  if (state.activeRole) {
534
+ // SubagentStart wire protocol (current Claude Code): hookSpecificOutput.additionalContext + exit 0.
434
535
  console.log(JSON.stringify({
435
- addContext: \`Role OS active. Role: \${state.activeRole}. Pack: \${state.activePack || "free routing"}. Follow role contract.\`,
536
+ hookSpecificOutput: {
537
+ hookEventName: "SubagentStart",
538
+ additionalContext: \`Role OS active. Role: \${state.activeRole}. Pack: \${state.activePack || "free routing"}. Follow role contract.\`,
539
+ },
436
540
  }));
437
541
  }
542
+ process.exit(0);
438
543
  `;
439
544
  }
440
545
 
@@ -461,9 +566,15 @@ if (!state.routeCardPresent) warnings.push("No route card produced.");
461
566
  if (!state.outcomeRecorded) warnings.push("No outcome artifact recorded.");
462
567
 
463
568
  if (warnings.length > 0) {
569
+ // Stop wire protocol (current Claude Code): advisory context via hookSpecificOutput.additionalContext
570
+ // + exit 0 lets the session end with a note. { decision: "block" } would PREVENT stopping (not wanted here).
464
571
  console.log(JSON.stringify({
465
- addContext: \`Role OS audit: \${warnings.join(" ")} Consider documenting the outcome.\`,
572
+ hookSpecificOutput: {
573
+ hookEventName: "Stop",
574
+ additionalContext: \`Role OS audit: \${warnings.join(" ")} Consider documenting the outcome.\`,
575
+ },
466
576
  }));
467
577
  }
578
+ process.exit(0);
468
579
  `;
469
580
  }