role-os 1.4.0 → 1.6.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 +63 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/artifacts.mjs +437 -0
- package/src/hooks.mjs +469 -0
- package/src/session.mjs +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
#### Artifact Spine (Phase Q)
|
|
8
|
+
- 20 per-role artifact contracts: each defines artifact type, required sections, evidence references, downstream consumers, and completion rules
|
|
9
|
+
- `validateArtifact(role, content)` — structural validation against role contracts (missing sections, evidence references, content depth)
|
|
10
|
+
- 7 pack-level handoff contracts: define the expected artifact flow between steps for each pack (e.g., strategy-brief → implementation-spec → change-plan → test-package → verdict)
|
|
11
|
+
- `validatePackChain(pack, artifacts)` — validates an entire pack's artifact chain for completeness
|
|
12
|
+
- `getArtifactContract(role)` / `getHandoffContract(pack)` — lookup APIs
|
|
13
|
+
- `formatArtifactValidation()` / `formatPackChain()` — display formatters
|
|
14
|
+
|
|
15
|
+
#### Artifact contract coverage
|
|
16
|
+
- Product Strategist → strategy-brief (problem-framing, scope, non-goals, tradeoffs)
|
|
17
|
+
- Spec Writer → implementation-spec (acceptance-criteria, edge-cases, interface-spec)
|
|
18
|
+
- Backend/Frontend Engineer → change-plan (files-to-change, implementation-approach, risk-notes)
|
|
19
|
+
- Test Engineer → test-package (test-plan, test-cases, false-confidence-assessment)
|
|
20
|
+
- Security Reviewer → security-findings (findings, severity-assessment, recommendations)
|
|
21
|
+
- Critic Reviewer → verdict (verdict, evidence, required-corrections)
|
|
22
|
+
- And 14 more roles with full contracts
|
|
23
|
+
|
|
24
|
+
### Evidence
|
|
25
|
+
- 385 tests, zero failures
|
|
26
|
+
- 27 new artifact tests
|
|
27
|
+
|
|
28
|
+
## 1.5.0
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
#### Hook Spine / Runtime Enforcement (Phase R)
|
|
33
|
+
- 5 lifecycle hooks: SessionStart, UserPromptSubmit, PreToolUse, SubagentStart, Stop
|
|
34
|
+
- `scaffoldHooks()` generates all 5 hook scripts in .claude/hooks/
|
|
35
|
+
- `roleos init claude` now scaffolds hooks + settings.local.json with hook config
|
|
36
|
+
- `roleos doctor` now checks for hook scripts (check 7) and settings hooks (check 8)
|
|
37
|
+
|
|
38
|
+
#### SessionStart hook
|
|
39
|
+
- Establishes session contract on every new session
|
|
40
|
+
- Records session ID, timestamp, initializes state tracking
|
|
41
|
+
- Adds context reminding Claude to use /roleos-route for non-trivial tasks
|
|
42
|
+
|
|
43
|
+
#### UserPromptSubmit hook
|
|
44
|
+
- Classifies prompts as substantial (>50 chars + action verbs)
|
|
45
|
+
- After 2+ substantial prompts without a route card, adds context reminder
|
|
46
|
+
- Does not block — advisory enforcement
|
|
47
|
+
|
|
48
|
+
#### PreToolUse hook
|
|
49
|
+
- Records all tool usage in session state
|
|
50
|
+
- Flags write tools (Bash, Write, Edit) used without route card after substantial work
|
|
51
|
+
- Advisory, not blocking — preserves operator control
|
|
52
|
+
|
|
53
|
+
#### SubagentStart hook
|
|
54
|
+
- Injects active role contract into delegated agents
|
|
55
|
+
- Ensures subagents inherit the Role OS session context
|
|
56
|
+
|
|
57
|
+
#### Stop hook
|
|
58
|
+
- Warns when substantial sessions end without route card or outcome artifact
|
|
59
|
+
- Advisory — does not block session exit
|
|
60
|
+
- Trivial sessions (< 2 substantial prompts) are exempt
|
|
61
|
+
|
|
62
|
+
### Evidence
|
|
63
|
+
- 358 tests, zero failures
|
|
64
|
+
- 23 new hook tests covering all 5 lifecycle hooks
|
|
65
|
+
|
|
3
66
|
## 1.4.0
|
|
4
67
|
|
|
5
68
|
### Added
|
package/README.md
CHANGED
|
@@ -179,6 +179,8 @@ 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 |
|
|
183
|
+
| **Artifact spine** | 20 per-role artifact contracts. 7 pack handoff contracts. Structural validation. Chain completeness checks. Downstream roles never guess what they received. | ✓ Shipped |
|
|
182
184
|
|
|
183
185
|
## Status
|
|
184
186
|
|
|
@@ -188,7 +190,9 @@ Role OS operates **locally only**. It copies markdown templates and writes packe
|
|
|
188
190
|
- v1.1.0: 31 roles, full routing spine, conflict detection, escalation, evidence, dispatch, 7 proven team packs. 35 execution trials. 212 tests.
|
|
189
191
|
- v1.2.0: Calibrated packs promoted to default entry. Auto-selection, mismatch detection, alternative suggestion, free-routing fallback. 246 tests.
|
|
190
192
|
- v1.3.0: Outcome calibration, mixed-task decomposition, composite execution, adaptive replanning. 317 tests.
|
|
191
|
-
-
|
|
193
|
+
- v1.4.0: Session spine — `roleos init claude`, `roleos doctor`, route cards, /roleos-route + /roleos-review + /roleos-status commands. 335 tests.
|
|
194
|
+
- v1.5.0: Hook spine — 5 lifecycle hooks for runtime enforcement. 358 tests.
|
|
195
|
+
- **v1.6.0**: Artifact spine — 20 per-role artifact contracts, 7 pack handoff contracts, structural validation, chain completeness checks. 385 tests.
|
|
192
196
|
|
|
193
197
|
## License
|
|
194
198
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "role-os",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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": {
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Spine — Phase Q (v1.6.0)
|
|
3
|
+
*
|
|
4
|
+
* Every important role produces a declared artifact with a known shape.
|
|
5
|
+
* Downstream roles never guess what they received.
|
|
6
|
+
*
|
|
7
|
+
* 1. Per-role artifact contracts (required sections, evidence, references)
|
|
8
|
+
* 2. Structural validation (completeness, not semantic perfection)
|
|
9
|
+
* 3. Pack-level handoff contracts (expected artifact flow between steps)
|
|
10
|
+
* 4. Composite artifact ledger integration
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Per-role artifact contracts ───────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Each contract defines what a role MUST produce for its handoff to be valid.
|
|
17
|
+
* Not every role needs a contract — only chain-critical roles that hand off
|
|
18
|
+
* to other roles.
|
|
19
|
+
*/
|
|
20
|
+
export const ROLE_ARTIFACT_CONTRACTS = {
|
|
21
|
+
"Product Strategist": {
|
|
22
|
+
artifactType: "strategy-brief",
|
|
23
|
+
requiredSections: ["problem-framing", "scope", "non-goals", "tradeoffs"],
|
|
24
|
+
optionalSections: ["user-value", "risks", "success-criteria"],
|
|
25
|
+
requiredEvidence: [],
|
|
26
|
+
consumedBy: ["Spec Writer", "Orchestrator"],
|
|
27
|
+
completionRule: "All 4 required sections must be present and non-empty.",
|
|
28
|
+
},
|
|
29
|
+
"Spec Writer": {
|
|
30
|
+
artifactType: "implementation-spec",
|
|
31
|
+
requiredSections: ["acceptance-criteria", "edge-cases", "interface-spec"],
|
|
32
|
+
optionalSections: ["data-schema", "nfrs", "open-questions"],
|
|
33
|
+
requiredEvidence: ["strategy-brief"],
|
|
34
|
+
consumedBy: ["Backend Engineer", "Frontend Developer"],
|
|
35
|
+
completionRule: "At least 3 acceptance criteria. Edge cases identified. Interface shape defined.",
|
|
36
|
+
},
|
|
37
|
+
"Backend Engineer": {
|
|
38
|
+
artifactType: "change-plan",
|
|
39
|
+
requiredSections: ["files-to-change", "implementation-approach", "risk-notes"],
|
|
40
|
+
optionalSections: ["test-strategy", "migration-notes"],
|
|
41
|
+
requiredEvidence: ["implementation-spec"],
|
|
42
|
+
consumedBy: ["Test Engineer", "Critic Reviewer"],
|
|
43
|
+
completionRule: "Files named. Approach described. Risks surfaced.",
|
|
44
|
+
},
|
|
45
|
+
"Frontend Developer": {
|
|
46
|
+
artifactType: "change-plan",
|
|
47
|
+
requiredSections: ["files-to-change", "implementation-approach", "risk-notes"],
|
|
48
|
+
optionalSections: ["component-structure", "accessibility-notes"],
|
|
49
|
+
requiredEvidence: ["implementation-spec"],
|
|
50
|
+
consumedBy: ["Test Engineer", "Critic Reviewer"],
|
|
51
|
+
completionRule: "Files named. Approach described. Risks surfaced.",
|
|
52
|
+
},
|
|
53
|
+
"Test Engineer": {
|
|
54
|
+
artifactType: "test-package",
|
|
55
|
+
requiredSections: ["test-plan", "test-cases", "false-confidence-assessment"],
|
|
56
|
+
optionalSections: ["edge-case-coverage", "regression-defense"],
|
|
57
|
+
requiredEvidence: ["change-plan"],
|
|
58
|
+
consumedBy: ["Critic Reviewer"],
|
|
59
|
+
completionRule: "Test plan present. At least 3 test cases. False confidence assessment honest.",
|
|
60
|
+
},
|
|
61
|
+
"Security Reviewer": {
|
|
62
|
+
artifactType: "security-findings",
|
|
63
|
+
requiredSections: ["findings", "severity-assessment", "recommendations"],
|
|
64
|
+
optionalSections: ["threat-model", "exploitation-scenarios"],
|
|
65
|
+
requiredEvidence: ["change-plan"],
|
|
66
|
+
consumedBy: ["Backend Engineer", "Critic Reviewer"],
|
|
67
|
+
completionRule: "Each finding has severity + recommendation. No finding without remediation path.",
|
|
68
|
+
},
|
|
69
|
+
"Coverage Auditor": {
|
|
70
|
+
artifactType: "coverage-report",
|
|
71
|
+
requiredSections: ["well-defended", "poorly-defended", "false-confidence", "priority-recommendations"],
|
|
72
|
+
optionalSections: ["missing-defenses", "regression-vectors"],
|
|
73
|
+
requiredEvidence: [],
|
|
74
|
+
consumedBy: ["Test Engineer", "Critic Reviewer"],
|
|
75
|
+
completionRule: "Coverage honestly assessed. False confidence identified. Priorities ranked.",
|
|
76
|
+
},
|
|
77
|
+
"Docs Architect": {
|
|
78
|
+
artifactType: "doc-map",
|
|
79
|
+
requiredSections: ["page-structure", "content-gaps", "navigation-design"],
|
|
80
|
+
optionalSections: ["getting-started-flow", "search-requirements"],
|
|
81
|
+
requiredEvidence: [],
|
|
82
|
+
consumedBy: ["Metadata Curator", "Release Engineer"],
|
|
83
|
+
completionRule: "Pages listed. Gaps identified. Navigation designed.",
|
|
84
|
+
},
|
|
85
|
+
"Launch Strategist": {
|
|
86
|
+
artifactType: "launch-brief",
|
|
87
|
+
requiredSections: ["launch-sequence", "proof-packaging", "channel-selection", "success-criteria"],
|
|
88
|
+
optionalSections: ["risk-assessment", "what-not-to-say"],
|
|
89
|
+
requiredEvidence: [],
|
|
90
|
+
consumedBy: ["Launch Copywriter"],
|
|
91
|
+
completionRule: "Sequence defined. Proof mapped. Channels selected. Success measurable.",
|
|
92
|
+
},
|
|
93
|
+
"Launch Copywriter": {
|
|
94
|
+
artifactType: "copy-package",
|
|
95
|
+
requiredSections: ["release-notes", "short-announcement"],
|
|
96
|
+
optionalSections: ["npm-description", "social-variants", "messaging-angle"],
|
|
97
|
+
requiredEvidence: ["launch-brief"],
|
|
98
|
+
consumedBy: ["Critic Reviewer"],
|
|
99
|
+
completionRule: "Release notes present. At least one announcement variant.",
|
|
100
|
+
},
|
|
101
|
+
"Repo Researcher": {
|
|
102
|
+
artifactType: "repo-map",
|
|
103
|
+
requiredSections: ["entrypoints", "module-map", "build-test-commands"],
|
|
104
|
+
optionalSections: ["seams", "dependencies"],
|
|
105
|
+
requiredEvidence: [],
|
|
106
|
+
consumedBy: ["Backend Engineer", "Coverage Auditor", "Security Reviewer"],
|
|
107
|
+
completionRule: "Entrypoints listed. Module responsibilities described. Commands documented.",
|
|
108
|
+
},
|
|
109
|
+
"Metadata Curator": {
|
|
110
|
+
artifactType: "metadata-audit",
|
|
111
|
+
requiredSections: ["manifest-audit", "registry-alignment"],
|
|
112
|
+
optionalSections: ["badge-verification", "discovery-surface", "recommendations"],
|
|
113
|
+
requiredEvidence: [],
|
|
114
|
+
consumedBy: ["Release Engineer"],
|
|
115
|
+
completionRule: "Package.json audited. Registry alignment checked.",
|
|
116
|
+
},
|
|
117
|
+
"Release Engineer": {
|
|
118
|
+
artifactType: "release-plan",
|
|
119
|
+
requiredSections: ["version-decision", "changelog-draft", "pre-publish-checklist"],
|
|
120
|
+
optionalSections: ["packaging-check", "release-steps"],
|
|
121
|
+
requiredEvidence: [],
|
|
122
|
+
consumedBy: ["Deployment Verifier", "Critic Reviewer"],
|
|
123
|
+
completionRule: "Version decided with rationale. Changelog written. Checklist present.",
|
|
124
|
+
},
|
|
125
|
+
"Deployment Verifier": {
|
|
126
|
+
artifactType: "deployment-report",
|
|
127
|
+
requiredSections: ["live-state-assessment"],
|
|
128
|
+
optionalSections: ["npm-verification", "github-verification", "badge-verification", "translation-check"],
|
|
129
|
+
requiredEvidence: [],
|
|
130
|
+
consumedBy: ["Critic Reviewer"],
|
|
131
|
+
completionRule: "All deployed surfaces checked. Stale/mismatched items named.",
|
|
132
|
+
},
|
|
133
|
+
"Critic Reviewer": {
|
|
134
|
+
artifactType: "verdict",
|
|
135
|
+
requiredSections: ["verdict", "evidence", "required-corrections"],
|
|
136
|
+
optionalSections: ["notes", "next-owner", "chain-assessment"],
|
|
137
|
+
requiredEvidence: [],
|
|
138
|
+
consumedBy: [],
|
|
139
|
+
completionRule: "Verdict stated. Evidence cited. Corrections listed if not accept.",
|
|
140
|
+
},
|
|
141
|
+
"UX Researcher": {
|
|
142
|
+
artifactType: "ux-evaluation",
|
|
143
|
+
requiredSections: ["friction-inventory", "severity-ranking", "recommendations"],
|
|
144
|
+
optionalSections: ["flow-analysis", "heuristic-evaluation"],
|
|
145
|
+
requiredEvidence: [],
|
|
146
|
+
consumedBy: ["Product Strategist", "UI Designer"],
|
|
147
|
+
completionRule: "Friction points identified. Severity ranked. Evidence-based recommendations.",
|
|
148
|
+
},
|
|
149
|
+
"Competitive Analyst": {
|
|
150
|
+
artifactType: "landscape-analysis",
|
|
151
|
+
requiredSections: ["competitor-inventory", "differentiation", "honest-disadvantages"],
|
|
152
|
+
optionalSections: ["positioning-gaps", "recommendations"],
|
|
153
|
+
requiredEvidence: [],
|
|
154
|
+
consumedBy: ["Product Strategist"],
|
|
155
|
+
completionRule: "Competitors listed. Differentiation clear. Disadvantages honest.",
|
|
156
|
+
},
|
|
157
|
+
"Feedback Synthesizer": {
|
|
158
|
+
artifactType: "signal-synthesis",
|
|
159
|
+
requiredSections: ["theme-extraction", "theme-ranking", "confidence-assessment"],
|
|
160
|
+
optionalSections: ["contradictions", "complaint-to-action"],
|
|
161
|
+
requiredEvidence: [],
|
|
162
|
+
consumedBy: ["Product Strategist"],
|
|
163
|
+
completionRule: "Themes extracted. Ranking justified. Confidence assessed.",
|
|
164
|
+
},
|
|
165
|
+
"Refactor Engineer": {
|
|
166
|
+
artifactType: "refactor-plan",
|
|
167
|
+
requiredSections: ["structural-assessment", "module-boundaries", "migration-path"],
|
|
168
|
+
optionalSections: ["duplication-inventory", "proposed-structure"],
|
|
169
|
+
requiredEvidence: [],
|
|
170
|
+
consumedBy: ["Test Engineer", "Critic Reviewer"],
|
|
171
|
+
completionRule: "Current structure assessed. Target boundaries defined. Migration preserves tests.",
|
|
172
|
+
},
|
|
173
|
+
"Support Triage Lead": {
|
|
174
|
+
artifactType: "triage-report",
|
|
175
|
+
requiredSections: ["classification", "priority-assignment", "routing"],
|
|
176
|
+
optionalSections: ["recurring-patterns", "systemic-recommendations"],
|
|
177
|
+
requiredEvidence: [],
|
|
178
|
+
consumedBy: ["Feedback Synthesizer", "Docs Architect"],
|
|
179
|
+
completionRule: "Each item classified. Priority assigned. Route to owner named.",
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ── Artifact validation ───────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Validate an artifact against its role contract.
|
|
187
|
+
* Checks structural completeness, not semantic quality.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} roleName
|
|
190
|
+
* @param {string} artifactContent - The artifact text
|
|
191
|
+
* @returns {{ valid: boolean, missing: string[], warnings: string[], contract: object|null }}
|
|
192
|
+
*/
|
|
193
|
+
export function validateArtifact(roleName, artifactContent) {
|
|
194
|
+
const contract = ROLE_ARTIFACT_CONTRACTS[roleName];
|
|
195
|
+
if (!contract) {
|
|
196
|
+
return { valid: true, missing: [], warnings: ["No artifact contract defined for this role."], contract: null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const lower = artifactContent.toLowerCase();
|
|
200
|
+
const missing = [];
|
|
201
|
+
const warnings = [];
|
|
202
|
+
|
|
203
|
+
// Check required sections
|
|
204
|
+
for (const section of contract.requiredSections) {
|
|
205
|
+
// Look for the section as a heading or key phrase
|
|
206
|
+
const patterns = [
|
|
207
|
+
section.toLowerCase(),
|
|
208
|
+
section.replace(/-/g, " ").toLowerCase(),
|
|
209
|
+
`## ${section.replace(/-/g, " ")}`.toLowerCase(),
|
|
210
|
+
`### ${section.replace(/-/g, " ")}`.toLowerCase(),
|
|
211
|
+
];
|
|
212
|
+
const found = patterns.some(p => lower.includes(p));
|
|
213
|
+
if (!found) {
|
|
214
|
+
missing.push(section);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check required evidence references
|
|
219
|
+
for (const evidence of contract.requiredEvidence) {
|
|
220
|
+
const patterns = [
|
|
221
|
+
evidence.toLowerCase(),
|
|
222
|
+
evidence.replace(/-/g, " ").toLowerCase(),
|
|
223
|
+
];
|
|
224
|
+
const found = patterns.some(p => lower.includes(p));
|
|
225
|
+
if (!found) {
|
|
226
|
+
warnings.push(`Expected reference to "${evidence}" from upstream role.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check minimum content length (not just headers)
|
|
231
|
+
const contentLines = artifactContent.split("\n").filter(l => l.trim() && !l.trim().startsWith("#"));
|
|
232
|
+
if (contentLines.length < 5) {
|
|
233
|
+
warnings.push("Artifact appears thin — fewer than 5 content lines.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
valid: missing.length === 0,
|
|
238
|
+
missing,
|
|
239
|
+
warnings,
|
|
240
|
+
contract,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Pack-level handoff contracts ──────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Defines the expected artifact flow for each pack.
|
|
248
|
+
* Each step produces an artifact that the next step consumes.
|
|
249
|
+
*/
|
|
250
|
+
export const PACK_HANDOFF_CONTRACTS = {
|
|
251
|
+
feature: {
|
|
252
|
+
flow: [
|
|
253
|
+
{ role: "Product Strategist", produces: "strategy-brief", consumedBy: "Spec Writer" },
|
|
254
|
+
{ role: "Spec Writer", produces: "implementation-spec", consumedBy: "Backend Engineer" },
|
|
255
|
+
{ role: "Backend Engineer", produces: "change-plan", consumedBy: "Test Engineer" },
|
|
256
|
+
{ role: "Test Engineer", produces: "test-package", consumedBy: "Critic Reviewer" },
|
|
257
|
+
{ role: "Critic Reviewer", produces: "verdict", consumedBy: null },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
bugfix: {
|
|
261
|
+
flow: [
|
|
262
|
+
{ role: "Repo Researcher", produces: "repo-map", consumedBy: "Backend Engineer" },
|
|
263
|
+
{ role: "Backend Engineer", produces: "change-plan", consumedBy: "Test Engineer" },
|
|
264
|
+
{ role: "Test Engineer", produces: "test-package", consumedBy: "Critic Reviewer" },
|
|
265
|
+
{ role: "Critic Reviewer", produces: "verdict", consumedBy: null },
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
security: {
|
|
269
|
+
flow: [
|
|
270
|
+
{ role: "Security Reviewer", produces: "security-findings", consumedBy: "Critic Reviewer" },
|
|
271
|
+
{ role: "Dependency Auditor", produces: "metadata-audit", consumedBy: "Critic Reviewer" },
|
|
272
|
+
{ role: "Critic Reviewer", produces: "verdict", consumedBy: null },
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
docs: {
|
|
276
|
+
flow: [
|
|
277
|
+
{ role: "Support Triage Lead", produces: "triage-report", consumedBy: "Feedback Synthesizer" },
|
|
278
|
+
{ role: "Feedback Synthesizer", produces: "signal-synthesis", consumedBy: "Docs Architect" },
|
|
279
|
+
{ role: "Docs Architect", produces: "doc-map", consumedBy: "Metadata Curator" },
|
|
280
|
+
{ role: "Metadata Curator", produces: "metadata-audit", consumedBy: "Critic Reviewer" },
|
|
281
|
+
{ role: "Critic Reviewer", produces: "verdict", consumedBy: null },
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
launch: {
|
|
285
|
+
flow: [
|
|
286
|
+
{ role: "Launch Strategist", produces: "launch-brief", consumedBy: "Launch Copywriter" },
|
|
287
|
+
{ role: "Launch Copywriter", produces: "copy-package", consumedBy: "Critic Reviewer" },
|
|
288
|
+
{ role: "Critic Reviewer", produces: "verdict", consumedBy: null },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
research: {
|
|
292
|
+
flow: [
|
|
293
|
+
{ role: "Product Strategist", produces: "strategy-brief", consumedBy: "UX Researcher" },
|
|
294
|
+
{ role: "UX Researcher", produces: "ux-evaluation", consumedBy: "Competitive Analyst" },
|
|
295
|
+
{ role: "Competitive Analyst", produces: "landscape-analysis", consumedBy: "Feedback Synthesizer" },
|
|
296
|
+
{ role: "Feedback Synthesizer", produces: "signal-synthesis", consumedBy: "Critic Reviewer" },
|
|
297
|
+
{ role: "Critic Reviewer", produces: "verdict", consumedBy: null },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
treatment: {
|
|
301
|
+
flow: [
|
|
302
|
+
{ role: "Repo Researcher", produces: "repo-map", consumedBy: "Security Reviewer" },
|
|
303
|
+
{ role: "Security Reviewer", produces: "security-findings", consumedBy: "Coverage Auditor" },
|
|
304
|
+
{ role: "Coverage Auditor", produces: "coverage-report", consumedBy: "Docs Architect" },
|
|
305
|
+
{ role: "Docs Architect", produces: "doc-map", consumedBy: "Metadata Curator" },
|
|
306
|
+
{ role: "Metadata Curator", produces: "metadata-audit", consumedBy: "Release Engineer" },
|
|
307
|
+
{ role: "Release Engineer", produces: "release-plan", consumedBy: "Deployment Verifier" },
|
|
308
|
+
{ role: "Deployment Verifier", produces: "deployment-report", consumedBy: "Critic Reviewer" },
|
|
309
|
+
{ role: "Critic Reviewer", produces: "verdict", consumedBy: null },
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Validate a pack's artifact chain — check that each step's output
|
|
316
|
+
* matches what the next step expects.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} packName
|
|
319
|
+
* @param {Record<string, string>} artifacts - Map of role name → artifact content
|
|
320
|
+
* @returns {{ valid: boolean, steps: object[] }}
|
|
321
|
+
*/
|
|
322
|
+
export function validatePackChain(packName, artifacts) {
|
|
323
|
+
const contract = PACK_HANDOFF_CONTRACTS[packName];
|
|
324
|
+
if (!contract) {
|
|
325
|
+
return { valid: false, steps: [{ role: "unknown", status: "error", detail: `No handoff contract for pack "${packName}"` }] };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const steps = [];
|
|
329
|
+
let chainValid = true;
|
|
330
|
+
|
|
331
|
+
for (const step of contract.flow) {
|
|
332
|
+
const content = artifacts[step.role];
|
|
333
|
+
if (!content) {
|
|
334
|
+
steps.push({
|
|
335
|
+
role: step.role,
|
|
336
|
+
produces: step.produces,
|
|
337
|
+
status: "missing",
|
|
338
|
+
detail: `No artifact from ${step.role}.`,
|
|
339
|
+
});
|
|
340
|
+
chainValid = false;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const validation = validateArtifact(step.role, content);
|
|
345
|
+
steps.push({
|
|
346
|
+
role: step.role,
|
|
347
|
+
produces: step.produces,
|
|
348
|
+
status: validation.valid ? "valid" : "incomplete",
|
|
349
|
+
missing: validation.missing,
|
|
350
|
+
warnings: validation.warnings,
|
|
351
|
+
detail: validation.valid
|
|
352
|
+
? `${step.produces} is structurally complete.`
|
|
353
|
+
: `Missing sections: ${validation.missing.join(", ")}`,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (!validation.valid) chainValid = false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { valid: chainValid, steps };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get the artifact contract for a specific role.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} roleName
|
|
366
|
+
* @returns {object|null}
|
|
367
|
+
*/
|
|
368
|
+
export function getArtifactContract(roleName) {
|
|
369
|
+
return ROLE_ARTIFACT_CONTRACTS[roleName] || null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Get the handoff contract for a specific pack.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} packName
|
|
376
|
+
* @returns {object|null}
|
|
377
|
+
*/
|
|
378
|
+
export function getHandoffContract(packName) {
|
|
379
|
+
return PACK_HANDOFF_CONTRACTS[packName] || null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Format artifact validation result for display.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} roleName
|
|
386
|
+
* @param {object} validation
|
|
387
|
+
* @returns {string}
|
|
388
|
+
*/
|
|
389
|
+
export function formatArtifactValidation(roleName, validation) {
|
|
390
|
+
const lines = [`Artifact validation — ${roleName}`];
|
|
391
|
+
|
|
392
|
+
if (validation.valid) {
|
|
393
|
+
lines.push(` ✓ Structurally complete`);
|
|
394
|
+
} else {
|
|
395
|
+
lines.push(` ✗ Incomplete — missing: ${validation.missing.join(", ")}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (validation.warnings.length > 0) {
|
|
399
|
+
for (const w of validation.warnings) {
|
|
400
|
+
lines.push(` ! ${w}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (validation.contract) {
|
|
405
|
+
lines.push(` Contract: ${validation.contract.artifactType}`);
|
|
406
|
+
lines.push(` Consumed by: ${validation.contract.consumedBy.join(", ") || "terminal"}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return lines.join("\n");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Format pack chain validation for display.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} packName
|
|
416
|
+
* @param {object} chainValidation
|
|
417
|
+
* @returns {string}
|
|
418
|
+
*/
|
|
419
|
+
export function formatPackChain(packName, chainValidation) {
|
|
420
|
+
const lines = [
|
|
421
|
+
`\nPack Chain Validation — ${packName}`,
|
|
422
|
+
`─────────────────────────────────`,
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
for (const step of chainValidation.steps) {
|
|
426
|
+
const icon = step.status === "valid" ? "✓" : step.status === "missing" ? "○" : "!";
|
|
427
|
+
lines.push(` ${icon} ${step.role} → ${step.produces}: ${step.detail}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
lines.push(``);
|
|
431
|
+
lines.push(chainValidation.valid
|
|
432
|
+
? `Chain valid — all artifacts structurally complete.`
|
|
433
|
+
: `Chain incomplete — see above for missing artifacts.`
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
return lines.join("\n");
|
|
437
|
+
}
|
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 };
|