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/CHANGELOG.md +38 -0
- package/README.es.md +14 -1
- package/README.fr.md +130 -117
- package/README.hi.md +125 -112
- package/README.it.md +14 -1
- package/README.ja.md +14 -1
- package/README.md +14 -1
- package/README.pt-BR.md +14 -1
- package/README.zh.md +136 -123
- package/package.json +1 -1
- package/src/dispatch.mjs +3 -1
- package/src/dossier-block.mjs +74 -0
- package/src/hooks.mjs +125 -14
- package/src/role-dossiers.json +962 -0
- package/src/specialist/capability-gate.mjs +124 -0
- package/src/specialist/conformance-consult.mjs +322 -0
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|