@rune-kit/rune 2.18.1 → 2.21.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.
Files changed (46) hide show
  1. package/README.md +31 -12
  2. package/compiler/__tests__/comprehension.test.js +916 -0
  3. package/compiler/__tests__/governance-collector.test.js +376 -0
  4. package/compiler/__tests__/setup.test.js +5 -0
  5. package/compiler/analytics.js +5 -0
  6. package/compiler/bin/rune.js +165 -1
  7. package/compiler/comprehension-client.js +2348 -0
  8. package/compiler/comprehension.js +1254 -0
  9. package/compiler/governance-collector.js +382 -0
  10. package/compiler/schemas/comprehension.schema.json +87 -0
  11. package/compiler/schemas/governance.schema.json +78 -0
  12. package/compiler/transforms/branding.js +1 -1
  13. package/hooks/context-watch/index.cjs +24 -13
  14. package/hooks/hooks.json +2 -2
  15. package/hooks/lib/context-key.cjs +37 -0
  16. package/hooks/metrics-collector/index.cjs +37 -8
  17. package/hooks/pre-tool-guard/index.cjs +44 -0
  18. package/hooks/session-start/index.cjs +4 -10
  19. package/package.json +1 -1
  20. package/skills/adversary/SKILL.md +36 -3
  21. package/skills/adversary/references/cross-model-escalation.md +85 -0
  22. package/skills/adversary/references/reasoning-modes.md +95 -0
  23. package/skills/autopsy/SKILL.md +41 -0
  24. package/skills/ba/SKILL.md +106 -27
  25. package/skills/ba/references/ears-format.md +91 -0
  26. package/skills/brainstorm/SKILL.md +32 -7
  27. package/skills/completion-gate/SKILL.md +21 -10
  28. package/skills/converge/SKILL.md +178 -0
  29. package/skills/converge/references/eval-fixtures.md +59 -0
  30. package/skills/cook/SKILL.md +41 -6
  31. package/skills/debug/SKILL.md +4 -2
  32. package/skills/deploy/SKILL.md +23 -2
  33. package/skills/deploy/references/observability.md +146 -0
  34. package/skills/design/SKILL.md +15 -1
  35. package/skills/graft/SKILL.md +24 -8
  36. package/skills/onboard/SKILL.md +45 -0
  37. package/skills/perf/SKILL.md +4 -1
  38. package/skills/plan/SKILL.md +70 -7
  39. package/skills/plan/references/boundary-artifacts.md +127 -0
  40. package/skills/plan/references/plan-templates.md +28 -9
  41. package/skills/plan/references/vertical-slice.md +8 -6
  42. package/skills/preflight/SKILL.md +9 -3
  43. package/skills/review/SKILL.md +4 -2
  44. package/skills/skill-router/SKILL.md +2 -0
  45. package/skills/test/SKILL.md +15 -8
  46. package/skills/verification/SKILL.md +39 -7
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Governance Collector
3
+ *
4
+ * Assembles .rune/governance.json from three best-effort sources:
5
+ * 1. Gate fires — skill-frequency counts from analytics (.rune/metrics/)
6
+ * 2. Signals — static mesh signal definitions from the visualizer
7
+ * 3. Compliance — obligations declared in Business pack PACK.md files
8
+ *
9
+ * Phase-3 known capture gaps (updated from Phase 1):
10
+ *
11
+ * GAP-1 Gate outcomes (passed / bypassed / blocked) — PARTIALLY FILLED in Phase 3.
12
+ * pre-tool-guard now appends a structured record to
13
+ * .rune/metrics/gate-outcomes.jsonl when it decides to BLOCK (exit code 2).
14
+ * This file is read here and populates the `blocked` count + most-recent `ts`
15
+ * for the "privacy-mesh" gate.
16
+ * STILL uncaptured: `passed` and `bypassed` outcomes remain 0 because they
17
+ * are not observable at the hook layer without instrumenting every allow/pass
18
+ * path — which would add noise without signal value.
19
+ *
20
+ * GAP-2 Gate timestamps — no PER-INVOCATION timestamp is captured. Best-effort:
21
+ * the `ts` field carries the date-only `last_used` from skills.json when
22
+ * available, else null. A gate-outcome hook would provide real per-fire ts.
23
+ *
24
+ * GAP-6 `fired` is an INVOCATION count (cumulative total_invocations from
25
+ * skills.json, or windowed session-PRESENCE as fallback) — NOT a count of
26
+ * gate pass/fail events, and not strictly windowed when sourced from the
27
+ * cumulative totals file. Pair with GAP-1: outcomes remain uncaptured.
28
+ *
29
+ * GAP-3 Signal runtime counts — collectGraphData() returns static mesh
30
+ * definitions (emit / listen pairs) from the INSTALLED RUNE mesh
31
+ * (runeRoot/skills/**), not the buyer's app, and not runtime emission
32
+ * counts. Signal `count` is always 0 until a runtime signal log is wired.
33
+ *
34
+ * GAP-4 Compliance status — Business pack PACK.md files declare obligations
35
+ * (Constraints + Done-When items) but there is no automated outcome
36
+ * capture. All compliance entries are marked status:"unknown".
37
+ *
38
+ * GAP-5 Decision provenance — no provenance capture source exists in Phase 1.
39
+ * The decisions[] array is always []. A future journal/ADR hook or
40
+ * xlabs_log_decision integration would feed this field.
41
+ *
42
+ * None of the above gaps cause this function to throw — they degrade gracefully.
43
+ */
44
+
45
+ import { existsSync } from 'node:fs';
46
+ import { readdir, readFile } from 'node:fs/promises';
47
+ import path from 'node:path';
48
+ import { collectGraphData } from './visualizer.js';
49
+
50
+ // ─── Gate skill list ───────────────────────────────────────────────────────────
51
+ // These are the governance / quality-gate skills tracked in the mesh.
52
+ // Add new gate skills here as they are introduced.
53
+ const GATE_SKILLS = [
54
+ 'sentinel',
55
+ 'sentinel-env',
56
+ 'preflight',
57
+ 'completion-gate',
58
+ 'logic-guardian',
59
+ 'constraint-check',
60
+ 'hallucination-guard',
61
+ 'integrity-check',
62
+ ];
63
+
64
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
65
+
66
+ function readJsonl(content) {
67
+ return content
68
+ .trim()
69
+ .split('\n')
70
+ .filter(Boolean)
71
+ .map((line) => {
72
+ try {
73
+ return JSON.parse(line);
74
+ } catch {
75
+ return null;
76
+ }
77
+ })
78
+ .filter(Boolean);
79
+ }
80
+
81
+ /**
82
+ * Load sessions.jsonl + skills.json from .rune/metrics/ in runeRoot.
83
+ * Returns empty structures on any error — never throws.
84
+ */
85
+ async function loadMetrics(runeRoot) {
86
+ const metricsDir = path.join(runeRoot, '.rune', 'metrics');
87
+
88
+ let sessions = [];
89
+ try {
90
+ const sessionsPath = path.join(metricsDir, 'sessions.jsonl');
91
+ if (existsSync(sessionsPath)) {
92
+ sessions = readJsonl(await readFile(sessionsPath, 'utf-8'));
93
+ }
94
+ } catch {
95
+ /* degrade silently */
96
+ }
97
+
98
+ let skillTotals = {};
99
+ try {
100
+ const skillsPath = path.join(metricsDir, 'skills.json');
101
+ if (existsSync(skillsPath)) {
102
+ const raw = JSON.parse(await readFile(skillsPath, 'utf-8'));
103
+ skillTotals = raw.skills || {};
104
+ }
105
+ } catch {
106
+ /* degrade silently */
107
+ }
108
+
109
+ return { sessions, skillTotals };
110
+ }
111
+
112
+ /**
113
+ * Filter sessions to the lookback window (inclusive).
114
+ */
115
+ function filterByDays(sessions, days) {
116
+ if (!days || days <= 0) return sessions;
117
+ const cutoff = new Date();
118
+ cutoff.setDate(cutoff.getDate() - days);
119
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
120
+ return sessions.filter((s) => s.date >= cutoffStr);
121
+ }
122
+
123
+ /**
124
+ * Return the body of a "## <heading>" markdown section up to the next "## "
125
+ * heading or end-of-file. Index-based (no fragile regex end-anchor — JS has no \Z).
126
+ */
127
+ function extractSection(content, heading) {
128
+ const start = content.search(new RegExp(`^##\\s+${heading}\\s*$`, 'm'));
129
+ if (start === -1) return '';
130
+ const body = content.slice(start).replace(/^##[^\n]*\n/, ''); // drop heading line
131
+ const next = body.search(/^##\s/m);
132
+ return next === -1 ? body : body.slice(0, next);
133
+ }
134
+
135
+ // ─── Gate-outcome capture reader ─────────────────────────────────────────────
136
+
137
+ /**
138
+ * Read .rune/metrics/gate-outcomes.jsonl and return a map of
139
+ * gate → { blocked: number, latestTs: string|null }
140
+ *
141
+ * Only "blocked" outcomes are currently captured (pre-tool-guard Phase 3).
142
+ * passed/bypassed remain uncaptured — see GAP-1.
143
+ *
144
+ * Never throws — degrades to empty map on any error.
145
+ */
146
+ async function loadGateOutcomes(runeRoot) {
147
+ const outFile = path.join(runeRoot, '.rune', 'metrics', 'gate-outcomes.jsonl');
148
+ const result = new Map(); // gate → { blocked, latestTs }
149
+
150
+ try {
151
+ if (!existsSync(outFile)) return result;
152
+ const raw = await readFile(outFile, 'utf-8');
153
+ for (const record of readJsonl(raw)) {
154
+ if (!record || typeof record.gate !== 'string') continue;
155
+ const gate = record.gate;
156
+ const existing = result.get(gate) || { blocked: 0, latestTs: null };
157
+
158
+ if (record.outcome === 'blocked') {
159
+ existing.blocked += 1;
160
+ // Keep the most-recent ts (ISO string compare works lexicographically)
161
+ if (record.ts && (!existing.latestTs || record.ts > existing.latestTs)) {
162
+ existing.latestTs = record.ts;
163
+ }
164
+ }
165
+ result.set(gate, existing);
166
+ }
167
+ } catch {
168
+ /* degrade silently */
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ // ─── Gate assembly ────────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Assemble gates[] — one entry per governance gate skill with activity.
178
+ *
179
+ * `fired` = cumulative invocation count from skills.json `total_invocations`
180
+ * (the TRUE fire count the producer accumulates), falling back to windowed
181
+ * session-PRESENCE when skills.json is absent. See GAP-6 — this is an
182
+ * invocation count, NOT a pass/fail outcome.
183
+ */
184
+ async function assembleGates(runeRoot, days) {
185
+ const [{ sessions, skillTotals }, gateOutcomes] = await Promise.all([
186
+ loadMetrics(runeRoot),
187
+ loadGateOutcomes(runeRoot),
188
+ ]);
189
+ const filtered = filterByDays(sessions, days);
190
+
191
+ // Windowed session-PRESENCE: number of sessions in the window that invoked the
192
+ // gate. NOTE: sessions.skills_used is DEDUPED per session by the producer
193
+ // (post-session-reflect writes Object.keys(skillCounts)), so this counts
194
+ // "sessions that used the gate", not individual fires. Used only as a fallback.
195
+ const sessionPresence = {};
196
+ for (const session of filtered) {
197
+ if (!Array.isArray(session.skills_used)) continue;
198
+ for (const skill of session.skills_used) {
199
+ if (GATE_SKILLS.includes(skill)) {
200
+ sessionPresence[skill] = (sessionPresence[skill] || 0) + 1;
201
+ }
202
+ }
203
+ }
204
+
205
+ // skills.json shape (from post-session-reflect): { <skill>: { total_invocations, last_used } }
206
+ const totalFires = (name) => {
207
+ const rec = skillTotals[name];
208
+ return rec && typeof rec === 'object' ? rec.total_invocations || 0 : 0;
209
+ };
210
+ const lastUsedTs = (name) => {
211
+ const rec = skillTotals[name];
212
+ return rec && typeof rec === 'object' && rec.last_used ? `${rec.last_used}T00:00:00.000Z` : null;
213
+ };
214
+
215
+ const allGateNames = new Set([
216
+ ...Object.keys(sessionPresence),
217
+ ...GATE_SKILLS.filter((g) => totalFires(g) > 0),
218
+ ...gateOutcomes.keys(),
219
+ ]);
220
+
221
+ if (allGateNames.size === 0) return [];
222
+
223
+ return Array.from(allGateNames).map((name) => {
224
+ const outcomes = gateOutcomes.get(name) || { blocked: 0, latestTs: null };
225
+ // Prefer outcome ts over skills.json date when a real block event was recorded
226
+ const ts = outcomes.latestTs || lastUsedTs(name);
227
+ return {
228
+ name,
229
+ // Cumulative true fire count; windowed session-presence as fallback (GAP-6).
230
+ fired: totalFires(name) || sessionPresence[name] || 0,
231
+ // GAP-1 (Phase 3 partial fill): blocked IS now captured from gate-outcomes.jsonl.
232
+ // passed/bypassed remain 0 — not observable at the hook layer.
233
+ passed: 0,
234
+ bypassed: 0,
235
+ blocked: outcomes.blocked,
236
+ // Best-effort ts: real block event ts when available, else last_used from skills.json.
237
+ ts,
238
+ };
239
+ });
240
+ }
241
+
242
+ // ─── Signal assembly ──────────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * Extract signal definitions from the static mesh via collectGraphData.
246
+ * Returns signals[] with from/to derived from signalEdges.
247
+ *
248
+ * GAP-3: runtime counts are always 0 until signal emission is logged.
249
+ */
250
+ async function assembleSignals(runeRoot) {
251
+ let graphData;
252
+ try {
253
+ graphData = await collectGraphData(runeRoot);
254
+ } catch {
255
+ return []; // degrade if skills dir is missing or parse fails
256
+ }
257
+
258
+ const { signalEdges } = graphData;
259
+ if (!Array.isArray(signalEdges) || signalEdges.length === 0) return [];
260
+
261
+ // Deduplicate: one entry per (signal name, from, to) triple.
262
+ const seen = new Map();
263
+ for (const edge of signalEdges) {
264
+ const key = `${edge.signal}::${edge.source}::${edge.target}`;
265
+ if (!seen.has(key)) {
266
+ seen.set(key, {
267
+ name: edge.signal,
268
+ from: edge.source || null,
269
+ to: edge.target || null,
270
+ // GAP-3: no runtime count available in Phase 1
271
+ count: 0,
272
+ });
273
+ }
274
+ }
275
+
276
+ return Array.from(seen.values());
277
+ }
278
+
279
+ // ─── Compliance assembly ──────────────────────────────────────────────────────
280
+
281
+ /**
282
+ * Extract compliance obligations from Business PACK.md files.
283
+ * Looks for Business pack at <runeRoot>/../Business/extensions/pro-*.
284
+ *
285
+ * Obligations are extracted from the ## Constraints and ## Done When sections —
286
+ * the same sections Business PACK.md authors already maintain.
287
+ *
288
+ * GAP-4: outcomes are always "unknown" until automated verification exists.
289
+ */
290
+ async function assembleCompliance(runeRoot) {
291
+ const bizExtDir = path.join(runeRoot, '..', 'Business', 'extensions');
292
+ if (!existsSync(bizExtDir)) return [];
293
+
294
+ let packDirs;
295
+ try {
296
+ const entries = await readdir(bizExtDir, { withFileTypes: true });
297
+ packDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
298
+ } catch {
299
+ return [];
300
+ }
301
+
302
+ const obligations = [];
303
+
304
+ for (const packDir of packDirs) {
305
+ const packFile = path.join(bizExtDir, packDir, 'PACK.md');
306
+ if (!existsSync(packFile)) continue;
307
+
308
+ let content;
309
+ try {
310
+ content = await readFile(packFile, 'utf-8');
311
+ } catch {
312
+ continue; // skip unreadable pack
313
+ }
314
+
315
+ // Derive a human-readable pack name from the directory name.
316
+ const packName = packDir.replace(/^pro-/, '@rune-business/');
317
+
318
+ // Extract obligations from ## Constraints. Real Business PACK.md files use
319
+ // numbered ("1. MUST …") OR bulleted ("- MUST …") lists, so accept both.
320
+ const constraintsBody = extractSection(content, 'Constraints');
321
+ const listItem = /^(?:[-*]|\d+\.)\s+(.*)$/;
322
+ for (const raw of constraintsBody.split('\n')) {
323
+ const m = raw.trim().match(listItem);
324
+ if (!m) continue;
325
+ const text = m[1].trim();
326
+ if (!/^(MUST|NEVER)\b/.test(text)) continue;
327
+ obligations.push({
328
+ pack: packName,
329
+ obligation: text,
330
+ // GAP-4: no automated outcome verification yet
331
+ status: 'unknown',
332
+ });
333
+ }
334
+
335
+ // Extract checkbox items from ## Done When (only checked boxes are "met").
336
+ const doneWhenBody = extractSection(content, 'Done When');
337
+ for (const raw of doneWhenBody.split('\n')) {
338
+ const line = raw.trim();
339
+ const cb = line.match(/^[-*]\s+\[([ xX])\]\s+(.*)$/);
340
+ if (!cb) continue;
341
+ const met = cb[1] === 'x' || cb[1] === 'X';
342
+ obligations.push({
343
+ pack: packName,
344
+ obligation: cb[2].trim(),
345
+ // Only mark "met" if the checkbox is checked in the PACK.md itself;
346
+ // otherwise unknown because we cannot verify runtime outcomes.
347
+ status: met ? 'met' : 'unknown',
348
+ });
349
+ }
350
+ }
351
+
352
+ return obligations;
353
+ }
354
+
355
+ // ─── Public API ───────────────────────────────────────────────────────────────
356
+
357
+ /**
358
+ * Assemble a governance.json object conforming to governance.schema.json.
359
+ *
360
+ * @param {string} runeRoot - Absolute path to the Free repo root (the directory
361
+ * that contains the `skills/` and `.rune/` directories).
362
+ * @param {number} [days=30] - Lookback window in days for gate fire counts.
363
+ * @returns {Promise<object>} Governance record (never throws — degrades gracefully).
364
+ */
365
+ export async function assembleGovernance(runeRoot, days = 30) {
366
+ const [gates, signals, compliance] = await Promise.all([
367
+ assembleGates(runeRoot, days).catch(() => []),
368
+ assembleSignals(runeRoot).catch(() => []),
369
+ assembleCompliance(runeRoot).catch(() => []),
370
+ ]);
371
+
372
+ return {
373
+ generated_at: new Date().toISOString(),
374
+ window_days: days,
375
+ gates,
376
+ signals,
377
+ compliance,
378
+ // GAP-5: decision provenance has no capture source in Phase 1.
379
+ // A future journal ADR hook or xlabs_log_decision integration would feed this.
380
+ decisions: [],
381
+ };
382
+ }
@@ -0,0 +1,87 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://rune-kit.dev/schemas/comprehension.schema.json",
4
+ "title": "Rune Comprehension Graph",
5
+ "description": "Buyer-codebase comprehension graph emitted by onboard/autopsy. Powers the dashboard Understand tab (modules, domains, flows, steps) and the Verdict health score. Written to .rune/comprehension.json.",
6
+ "type": "object",
7
+ "required": ["project", "generated_at", "modules", "edges"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "project": { "type": "string", "description": "Project name" },
11
+ "generated_at": { "type": "string", "format": "date-time" },
12
+ "source": { "type": "string", "enum": ["onboard", "autopsy"], "description": "Which skill produced this emit" },
13
+ "health_score": { "type": ["integer", "null"], "minimum": 0, "maximum": 100, "description": "0-100, or null when the producer (e.g. onboard) does not compute a score" },
14
+ "layers": {
15
+ "type": "array",
16
+ "description": "Architectural layers detected (e.g. API, Service, Data, UI, Utility)",
17
+ "items": {
18
+ "type": "object",
19
+ "required": ["id", "name"],
20
+ "additionalProperties": false,
21
+ "properties": {
22
+ "id": { "type": "string" },
23
+ "name": { "type": "string" },
24
+ "color": { "type": "string", "description": "Optional palette key (code/service/data/domain/docs/infra/concept)" }
25
+ }
26
+ }
27
+ },
28
+ "domains": {
29
+ "type": "array",
30
+ "description": "Business domains → flows → steps (non-tech readable process view)",
31
+ "items": {
32
+ "type": "object",
33
+ "required": ["name", "flows"],
34
+ "additionalProperties": false,
35
+ "properties": {
36
+ "name": { "type": "string" },
37
+ "summary": { "type": "string" },
38
+ "flows": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "object",
42
+ "required": ["name", "steps"],
43
+ "additionalProperties": false,
44
+ "properties": {
45
+ "name": { "type": "string" },
46
+ "steps": { "type": "array", "items": { "type": "string" } }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ },
53
+ "modules": {
54
+ "type": "array",
55
+ "description": "Graph nodes — files/modules/services/etc.",
56
+ "items": {
57
+ "type": "object",
58
+ "required": ["id", "name"],
59
+ "additionalProperties": false,
60
+ "properties": {
61
+ "id": { "type": "string" },
62
+ "name": { "type": "string" },
63
+ "layer": { "type": "string" },
64
+ "type": { "type": "string", "description": "file | function | class | module | service | table | endpoint | domain | flow | step | doc | config" },
65
+ "complexity": { "type": "string", "enum": ["simple", "moderate", "complex"] },
66
+ "files": { "type": "integer", "minimum": 0 },
67
+ "summary": { "type": "string" }
68
+ }
69
+ }
70
+ },
71
+ "edges": {
72
+ "type": "array",
73
+ "description": "Graph edges between modules",
74
+ "items": {
75
+ "type": "object",
76
+ "required": ["from", "to", "category"],
77
+ "additionalProperties": false,
78
+ "properties": {
79
+ "from": { "type": "string" },
80
+ "to": { "type": "string" },
81
+ "category": { "type": "string", "enum": ["structural", "behavioral", "data-flow", "dependency", "semantic", "infrastructure", "domain", "knowledge"] },
82
+ "weight": { "type": "number" }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,78 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://rune-kit.dev/schemas/governance.schema.json",
4
+ "title": "Rune Governance Record",
5
+ "description": "Governance + value evidence assembled by governance-collector from metrics, session-bridge state, journal ADRs, and the signal mesh. Powers the dashboard Verdict + Govern tabs (gates, signals, compliance, decision provenance). Written to .rune/governance.json.",
6
+ "type": "object",
7
+ "required": ["generated_at", "gates", "signals"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "generated_at": { "type": "string", "format": "date-time" },
11
+ "window_days": { "type": "integer", "minimum": 1, "description": "Lookback window for the assembled record" },
12
+ "gates": {
13
+ "type": "array",
14
+ "description": "HARD-GATE fire events (sentinel, preflight, completion-gate, logic-guardian, …)",
15
+ "items": {
16
+ "type": "object",
17
+ "required": ["name", "fired"],
18
+ "additionalProperties": false,
19
+ "properties": {
20
+ "name": { "type": "string" },
21
+ "fired": { "type": "integer", "minimum": 0, "description": "INVOCATION count of the gate skill (cumulative total_invocations from skills.json; windowed session-presence as fallback). NOT a pass/fail outcome count — see passed/bypassed/blocked." },
22
+ "passed": { "type": "integer", "minimum": 0 },
23
+ "bypassed": { "type": "integer", "minimum": 0 },
24
+ "blocked": { "type": "integer", "minimum": 0 },
25
+ "ts": { "type": "string", "format": "date-time", "description": "Most recent fire" }
26
+ }
27
+ }
28
+ },
29
+ "signals": {
30
+ "type": "array",
31
+ "description": "Mesh signal emissions captured (cause→effect across skills/packs)",
32
+ "items": {
33
+ "type": "object",
34
+ "required": ["name"],
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "name": { "type": "string" },
38
+ "from": { "type": "string" },
39
+ "to": { "type": "string" },
40
+ "count": { "type": "integer", "minimum": 0 },
41
+ "ts": { "type": "string", "format": "date-time" }
42
+ }
43
+ }
44
+ },
45
+ "compliance": {
46
+ "type": "array",
47
+ "description": "Obligations declared by installed Business packs vs met/gap status",
48
+ "items": {
49
+ "type": "object",
50
+ "required": ["pack", "obligation", "status"],
51
+ "additionalProperties": false,
52
+ "properties": {
53
+ "pack": { "type": "string" },
54
+ "obligation": { "type": "string" },
55
+ "status": { "type": "string", "enum": ["met", "partial", "gap", "unknown"] },
56
+ "evidence": { "type": "string" }
57
+ }
58
+ }
59
+ },
60
+ "decisions": {
61
+ "type": "array",
62
+ "description": "Decision provenance — trace an output back through signal chain → gate → model → cost",
63
+ "items": {
64
+ "type": "object",
65
+ "required": ["output"],
66
+ "additionalProperties": false,
67
+ "properties": {
68
+ "output": { "type": "string", "description": "Commit, file, or artifact" },
69
+ "signal_chain": { "type": "array", "items": { "type": "string" } },
70
+ "gate": { "type": "string" },
71
+ "model": { "type": "string" },
72
+ "cost": { "type": "number", "description": "Estimated USD" },
73
+ "ts": { "type": "string", "format": "date-time" }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
@@ -12,7 +12,7 @@
12
12
  export const BRANDING_FOOTER = [
13
13
  '',
14
14
  '---',
15
- '> **Rune Skill Mesh** — 64 skills, 203 connections + 40 signals, 14 extension packs',
15
+ '> **Rune Skill Mesh** — 64 skills, 204 connections + 40 signals, 14 extension packs',
16
16
  '> [Landing Page](https://rune-kit.github.io/rune) · [Source](https://github.com/rune-kit/rune) (MIT)',
17
17
  '> **Rune Pro** ($49 lifetime) — product, sales, data-science, support packs → [rune-kit/rune-pro](https://github.com/rune-kit/rune-pro)',
18
18
  '> **Rune Business** ($149 lifetime) — finance, legal, HR, enterprise-search packs → [rune-kit/rune-business](https://github.com/rune-kit/rune-business)',
@@ -1,6 +1,7 @@
1
1
  // Rune Context Watch Hook
2
2
  // Lightweight tool call counter — detects context pressure and suggests rune:context-engine
3
- // Runs as PreToolUse hook on Edit/Write (high-cost operations)
3
+ // Runs as PreToolUse hook on ALL tools (matcher ".*") — total tool volume is the
4
+ // context-pressure proxy and feeds tool_distribution metrics at session end.
4
5
  //
5
6
  // H3 Intelligence: also tracks tool type distribution and session start timestamp
6
7
  // for metrics aggregation at session end.
@@ -11,18 +12,19 @@
11
12
  // Data source: stdin JSON from Claude Code (not env vars)
12
13
 
13
14
  const fs = require('fs');
14
- const path = require('path');
15
- const os = require('os');
15
+ const { stateFile } = require('../lib/context-key.cjs');
16
16
 
17
- // Counter file scoped to current working directory (hash of cwd)
18
- const cwd = process.cwd();
19
- const hash = Buffer.from(cwd).toString('base64url').slice(0, 16);
20
- const counterFile = path.join(os.tmpdir(), `rune-context-watch-${hash}.json`);
17
+ // The counter file is keyed by Claude Code session_id (parsed from stdin in the
18
+ // handler below) so it resets per session and never bleeds across sessions or
19
+ // projects. See lib/context-key.cjs.
21
20
 
22
- // Thresholds
23
- const FIRST_WARNING = 40;
21
+ // Thresholds — counts ALL tool calls (matcher ".*"), not just Edit/Write, so the
22
+ // totals reflect true context pressure AND feed accurate tool_distribution metrics.
23
+ // Aligned with the context-engine skill's own model (ORANGE ~80, RED ~120 tool calls)
24
+ // so the hook and the skill it delegates to agree.
25
+ const FIRST_WARNING = 80;
24
26
  const REPEAT_INTERVAL = 20;
25
- const CRITICAL_THRESHOLD = 80;
27
+ const CRITICAL_THRESHOLD = 120;
26
28
 
27
29
  // Read stdin JSON to get tool name (Claude Code passes hook data via stdin)
28
30
  let stdinData = '';
@@ -30,14 +32,19 @@ process.stdin.setEncoding('utf-8');
30
32
  process.stdin.on('data', chunk => { stdinData += chunk; });
31
33
  process.stdin.on('end', () => {
32
34
  let toolName = 'unknown';
35
+ let sessionId;
33
36
  try {
34
37
  const parsed = JSON.parse(stdinData);
35
38
  toolName = parsed.tool || parsed.tool_name || 'unknown';
39
+ sessionId = parsed.session_id;
36
40
  } catch {
37
41
  // Fallback to env var for backwards compatibility
38
42
  toolName = process.env.CLAUDE_TOOL_NAME || 'unknown';
39
43
  }
40
44
 
45
+ // Session-keyed counter (resets per session, no cross-session/project bleed).
46
+ const counterFile = stateFile('rune-context-watch', sessionId);
47
+
41
48
  // Read current state
42
49
  let state = { count: 0, lastWarning: 0, sessionStart: null, sessionId: null, toolCounts: {} };
43
50
  try {
@@ -57,16 +64,20 @@ process.stdin.on('end', () => {
57
64
  state.sessionId = `s-${now.slice(0, 10).replace(/-/g, '')}-${now.slice(11, 19).replace(/:/g, '')}`;
58
65
  }
59
66
 
60
- // Increment total and per-tool counters
67
+ // Increment total (drives context-pressure warnings) and per-tool distribution.
68
+ // Skip the per-tool bucket when the tool name is unattributable, so a parse
69
+ // hiccup doesn't pollute tool_distribution with an "unknown" entry.
61
70
  state.count += 1;
62
- state.toolCounts[toolName] = (state.toolCounts[toolName] || 0) + 1;
71
+ if (toolName !== 'unknown') {
72
+ state.toolCounts[toolName] = (state.toolCounts[toolName] || 0) + 1;
73
+ }
63
74
 
64
75
  // Check thresholds
65
76
  const count = state.count;
66
77
  const sinceLast = count - state.lastWarning;
67
78
 
68
79
  if (count >= CRITICAL_THRESHOLD && sinceLast >= REPEAT_INTERVAL) {
69
- console.log(`\n🔴 [Rune context-watch] ${count} tool calls — context likely RED (>85%).`);
80
+ console.log(`\n🔴 [Rune context-watch] ${count} tool calls — context likely RED (tool count is directional, not a %).`);
70
81
  console.log(' RECOMMENDED: Invoke rune:context-engine for state save + /compact.');
71
82
  console.log(' Risk: auto-compaction may lose critical decisions without state save.\n');
72
83
  state.lastWarning = count;
package/hooks/hooks.json CHANGED
@@ -36,7 +36,7 @@
36
36
  ]
37
37
  },
38
38
  {
39
- "matcher": "Edit|Write",
39
+ "matcher": ".*",
40
40
  "hooks": [
41
41
  {
42
42
  "type": "command",
@@ -73,7 +73,7 @@
73
73
  ]
74
74
  },
75
75
  {
76
- "matcher": "Skill",
76
+ "matcher": "Skill|Task|Agent",
77
77
  "hooks": [
78
78
  {
79
79
  "type": "command",