continuous-improvement 3.9.2 → 3.11.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 (150) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +61 -0
  3. package/QUICKSTART.md +13 -8
  4. package/README.md +84 -153
  5. package/bin/backfill.mjs +0 -0
  6. package/bin/check-doc-runtime-claims.mjs +0 -0
  7. package/bin/check-docs-substrings.mjs +73 -0
  8. package/bin/check-everything-mirror.mjs +0 -0
  9. package/bin/check-routing-targets.mjs +0 -0
  10. package/bin/check-scripts-citation-drift.mjs +210 -0
  11. package/bin/check-skill-count.mjs +110 -0
  12. package/bin/check-skill-law-tag.mjs +0 -0
  13. package/bin/check-skill-mirror.mjs +0 -0
  14. package/bin/check-skill-tiers.mjs +0 -0
  15. package/bin/check-test-imports-only.mjs +126 -0
  16. package/bin/check-third-party-shape.mjs +0 -0
  17. package/bin/companion-preference-status.mjs +231 -0
  18. package/bin/gateguard-clear.mjs +53 -0
  19. package/bin/generate-plugin-manifests.mjs +7 -1
  20. package/bin/harvest-friction.mjs +0 -0
  21. package/bin/hook-stats.mjs +0 -0
  22. package/bin/install.mjs +182 -26
  23. package/bin/lint-transcript.mjs +0 -0
  24. package/bin/mcp-server.mjs +215 -2
  25. package/bin/observe.mjs +0 -0
  26. package/bin/pre-commit-block-strays.sh +0 -0
  27. package/bin/refresh-third-party.mjs +315 -313
  28. package/bin/unified-cli.mjs +0 -0
  29. package/commands/audit.md +34 -0
  30. package/commands/companion-preference.md +58 -0
  31. package/commands/discipline.md +14 -0
  32. package/commands/distill.md +39 -0
  33. package/commands/goal-check.md +53 -0
  34. package/commands/grill-me.md +40 -0
  35. package/commands/grill-with-docs.md +38 -0
  36. package/commands/handoff.md +42 -0
  37. package/commands/proceed-with-the-recommendation.md +20 -0
  38. package/commands/recall.md +49 -0
  39. package/commands/reconcile.md +47 -0
  40. package/commands/superpowers.md +2 -2
  41. package/commands/verify-install.md +55 -0
  42. package/hooks/companion-preference.mjs +190 -0
  43. package/hooks/gateguard.mjs +60 -25
  44. package/hooks/goal-drift-stop.mjs +183 -0
  45. package/hooks/observe.sh +15 -1
  46. package/hooks/route-prompt.mjs +180 -0
  47. package/hooks/route-table.json +35 -0
  48. package/hooks/three-section-close.mjs +134 -132
  49. package/lib/cli-anything.mjs +0 -0
  50. package/lib/compound-engineering.mjs +0 -0
  51. package/lib/gateguard-state.mjs +54 -2
  52. package/lib/goal-drift-gate.mjs +50 -0
  53. package/lib/goal-state.mjs +285 -0
  54. package/lib/observe-event.mjs +0 -0
  55. package/lib/plugin-metadata.mjs +126 -12
  56. package/lib/pm-marketplace.mjs +0 -0
  57. package/lib/pm-skills.mjs +0 -0
  58. package/lib/recall-index.mjs +175 -0
  59. package/lib/resolve-home-dir.mjs +0 -0
  60. package/lib/skill-distill.mjs +222 -0
  61. package/lib/skill-tiers.mjs +0 -0
  62. package/lib/unified-plugin.mjs +0 -0
  63. package/llms.txt +1 -1
  64. package/package.json +11 -7
  65. package/plugins/beginner.json +8 -3
  66. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
  67. package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
  68. package/plugins/continuous-improvement/agents/README.md +2 -2
  69. package/plugins/continuous-improvement/bin/backfill.mjs +0 -0
  70. package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
  71. package/plugins/continuous-improvement/bin/mcp-server.mjs +215 -2
  72. package/plugins/continuous-improvement/bin/observe.mjs +0 -0
  73. package/plugins/continuous-improvement/commands/audit.md +34 -0
  74. package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
  75. package/plugins/continuous-improvement/commands/discipline.md +14 -0
  76. package/plugins/continuous-improvement/commands/distill.md +39 -0
  77. package/plugins/continuous-improvement/commands/goal-check.md +53 -0
  78. package/plugins/continuous-improvement/commands/grill-me.md +40 -0
  79. package/plugins/continuous-improvement/commands/grill-with-docs.md +38 -0
  80. package/plugins/continuous-improvement/commands/handoff.md +42 -0
  81. package/plugins/continuous-improvement/commands/proceed-with-the-recommendation.md +20 -0
  82. package/plugins/continuous-improvement/commands/recall.md +49 -0
  83. package/plugins/continuous-improvement/commands/reconcile.md +47 -0
  84. package/plugins/continuous-improvement/commands/superpowers.md +2 -2
  85. package/plugins/continuous-improvement/commands/verify-install.md +55 -0
  86. package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
  87. package/plugins/continuous-improvement/hooks/gateguard.mjs +60 -25
  88. package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
  89. package/plugins/continuous-improvement/hooks/hooks.json +18 -2
  90. package/plugins/continuous-improvement/hooks/observe.sh +15 -1
  91. package/plugins/continuous-improvement/hooks/route-prompt.mjs +180 -0
  92. package/plugins/continuous-improvement/hooks/route-table.json +35 -0
  93. package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
  94. package/plugins/continuous-improvement/lib/gateguard-state.mjs +137 -0
  95. package/plugins/continuous-improvement/lib/goal-drift-gate.mjs +50 -0
  96. package/plugins/continuous-improvement/lib/goal-state.mjs +285 -0
  97. package/plugins/continuous-improvement/lib/observe-event.mjs +0 -0
  98. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +126 -12
  99. package/plugins/continuous-improvement/lib/recall-index.mjs +175 -0
  100. package/plugins/continuous-improvement/lib/resolve-home-dir.mjs +0 -0
  101. package/plugins/continuous-improvement/lib/skill-distill.mjs +222 -0
  102. package/plugins/continuous-improvement/skills/README.md +8 -0
  103. package/plugins/continuous-improvement/skills/audit/SKILL.md +73 -0
  104. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +13 -17
  105. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +36 -17
  106. package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +81 -0
  107. package/plugins/continuous-improvement/skills/grill-me/SKILL.md +66 -0
  108. package/plugins/continuous-improvement/skills/grill-with-docs/SKILL.md +252 -0
  109. package/plugins/continuous-improvement/skills/handoff/SKILL.md +59 -0
  110. package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +1 -1
  111. package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +82 -6
  112. package/plugins/continuous-improvement/skills/ralph/SKILL.md +1 -0
  113. package/plugins/continuous-improvement/skills/recall/SKILL.md +50 -0
  114. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +80 -0
  115. package/plugins/continuous-improvement/skills/recovery-classification/SKILL.md +1 -0
  116. package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +1 -0
  117. package/plugins/continuous-improvement/skills/skill-distillation/SKILL.md +57 -0
  118. package/plugins/continuous-improvement/skills/state-reconciliation/SKILL.md +1 -0
  119. package/plugins/continuous-improvement/skills/strategic-compact/SKILL.md +1 -0
  120. package/plugins/continuous-improvement/skills/superpowers/SKILL.md +76 -2
  121. package/plugins/continuous-improvement/skills/token-budget-advisor/SKILL.md +1 -1
  122. package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +28 -20
  123. package/plugins/continuous-improvement/skills/wild-risa-balance/SKILL.md +1 -0
  124. package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
  125. package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +3 -2
  126. package/plugins/expert.json +27 -2
  127. package/skills/README.md +3 -0
  128. package/skills/audit.md +73 -0
  129. package/skills/deploy-receipt.md +13 -17
  130. package/skills/gateguard.md +36 -17
  131. package/skills/goal-monitor.md +81 -0
  132. package/skills/grill-me.md +66 -0
  133. package/skills/grill-with-docs.md +252 -0
  134. package/skills/handoff.md +59 -0
  135. package/skills/para-memory-files.md +1 -1
  136. package/skills/proceed-with-the-recommendation.md +82 -6
  137. package/skills/ralph.md +1 -0
  138. package/skills/recall.md +50 -0
  139. package/skills/reconcile.md +80 -0
  140. package/skills/recovery-classification.md +1 -0
  141. package/skills/safety-guard.md +1 -0
  142. package/skills/skill-distillation.md +57 -0
  143. package/skills/state-reconciliation.md +1 -0
  144. package/skills/strategic-compact.md +1 -0
  145. package/skills/superpowers.md +76 -2
  146. package/skills/token-budget-advisor.md +1 -1
  147. package/skills/verification-loop.md +28 -20
  148. package/skills/wild-risa-balance.md +1 -0
  149. package/skills/workspace-surface-audit.md +1 -1
  150. package/skills/worktree-safety.md +3 -2
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime UserPromptSubmit prompt-router hook.
4
+ *
5
+ * Reads the user prompt, matches it against patterns in
6
+ * hooks/route-table.json, and emits a single `<system-reminder>` recommending
7
+ * the matched skill. When no row matches, the hook emits nothing — the prompt
8
+ * passes through untouched.
9
+ *
10
+ * Stdin : JSON { prompt, session_id?, transcript_path?, cwd? }
11
+ * Stdout : JSON { hookSpecificOutput: { hookEventName: "UserPromptSubmit",
12
+ * additionalContext: string } }
13
+ * OR empty when there is no match.
14
+ * Exit : 0 always — routing decisions live in stdout, never in exit code.
15
+ *
16
+ * Fail-open: any unexpected error reading stdin / parsing the route table /
17
+ * compiling a pattern → emit nothing and exit 0. The hook never blocks a
18
+ * prompt on its own bugs.
19
+ *
20
+ * Telemetry: each match appends one JSONL line to
21
+ * ~/.claude/instincts/<project-hash>/route-prompt.jsonl with the row index,
22
+ * skill, and pattern. Telemetry failures never change the decision.
23
+ *
24
+ * Route table schema (hooks/route-table.json):
25
+ * {
26
+ * "rows": [
27
+ * { "pattern": "regex source", "flags": "i", "skill": "skill-name",
28
+ * "reason": "one-line description" }
29
+ * ]
30
+ * }
31
+ * Rows are evaluated in order; first match wins.
32
+ */
33
+ import { createHash } from "node:crypto";
34
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { dirname, join } from "node:path";
37
+ import { execFileSync } from "node:child_process";
38
+ import { fileURLToPath } from "node:url";
39
+ function readStdin() {
40
+ try {
41
+ return readFileSync(0, "utf8");
42
+ }
43
+ catch {
44
+ return "";
45
+ }
46
+ }
47
+ function resolveHome() {
48
+ return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
49
+ }
50
+ function resolveProjectRoot() {
51
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR;
52
+ if (fromEnv)
53
+ return fromEnv;
54
+ try {
55
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
56
+ encoding: "utf8",
57
+ stdio: ["ignore", "pipe", "ignore"],
58
+ }).trim();
59
+ if (root)
60
+ return root;
61
+ }
62
+ catch {
63
+ // not in a git repo
64
+ }
65
+ return "global";
66
+ }
67
+ function telemetryPath(home) {
68
+ const hash = createHash("sha256")
69
+ .update(resolveProjectRoot())
70
+ .digest("hex")
71
+ .slice(0, 12);
72
+ return join(home, ".claude", "instincts", hash, "route-prompt.jsonl");
73
+ }
74
+ function writeTelemetry(home, event) {
75
+ try {
76
+ const path = telemetryPath(home);
77
+ const dir = dirname(path);
78
+ if (!existsSync(dir))
79
+ mkdirSync(dir, { recursive: true });
80
+ appendFileSync(path, `${JSON.stringify(event)}\n`);
81
+ }
82
+ catch {
83
+ // fail-open
84
+ }
85
+ }
86
+ function loadRouteTable() {
87
+ // Hook lives at <plugin-root>/hooks/route-prompt.mjs at runtime; the table
88
+ // sits next to it. import.meta.url gives the file:// URL of the compiled
89
+ // .mjs.
90
+ try {
91
+ const here = fileURLToPath(new URL(".", import.meta.url));
92
+ const tablePath = join(here, "route-table.json");
93
+ const raw = readFileSync(tablePath, "utf8");
94
+ const parsed = JSON.parse(raw);
95
+ if (!parsed || !Array.isArray(parsed.rows))
96
+ return null;
97
+ return parsed;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ function findMatch(prompt, table) {
104
+ for (let i = 0; i < table.rows.length; i++) {
105
+ const row = table.rows[i];
106
+ if (!row || typeof row.pattern !== "string" || typeof row.skill !== "string") {
107
+ continue;
108
+ }
109
+ let rx;
110
+ try {
111
+ rx = new RegExp(row.pattern, row.flags ?? "");
112
+ }
113
+ catch {
114
+ continue;
115
+ }
116
+ if (rx.test(prompt)) {
117
+ return { row, index: i };
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ function buildContext(row) {
123
+ const reason = row.reason
124
+ ? `${row.reason}\nConsider invoking \`/${row.skill}\` now.`
125
+ : `Consider invoking \`/${row.skill}\` now.`;
126
+ return `<system-reminder>\nPrompt-router suggestion: ${reason}\n</system-reminder>`;
127
+ }
128
+ function emit(output) {
129
+ if (output !== null) {
130
+ process.stdout.write(`${JSON.stringify(output)}\n`);
131
+ }
132
+ process.exit(0);
133
+ }
134
+ function main() {
135
+ const raw = readStdin();
136
+ let payload;
137
+ try {
138
+ payload = JSON.parse(raw);
139
+ }
140
+ catch {
141
+ emit(null);
142
+ return;
143
+ }
144
+ const prompt = payload.prompt;
145
+ if (typeof prompt !== "string" || prompt.length === 0) {
146
+ emit(null);
147
+ return;
148
+ }
149
+ const table = loadRouteTable();
150
+ if (!table) {
151
+ emit(null);
152
+ return;
153
+ }
154
+ const match = findMatch(prompt, table);
155
+ const home = resolveHome();
156
+ if (!match) {
157
+ writeTelemetry(home, {
158
+ ts: new Date().toISOString(),
159
+ hook: "route-prompt",
160
+ matched: false,
161
+ });
162
+ emit(null);
163
+ return;
164
+ }
165
+ writeTelemetry(home, {
166
+ ts: new Date().toISOString(),
167
+ hook: "route-prompt",
168
+ matched: true,
169
+ row_index: match.index,
170
+ skill: match.row.skill,
171
+ pattern: match.row.pattern,
172
+ });
173
+ emit({
174
+ hookSpecificOutput: {
175
+ hookEventName: "UserPromptSubmit",
176
+ additionalContext: buildContext(match.row),
177
+ },
178
+ });
179
+ }
180
+ main();
@@ -0,0 +1,35 @@
1
+ {
2
+ "description": "Declarative prompt-router for the continuous-improvement UserPromptSubmit hook. Each row maps a regex (prompt-side trigger) to a skill the agent should consider invoking. First match wins; rows are evaluated in order. Edit this file to grow the routing table — no code change required.",
3
+ "rows": [
4
+ {
5
+ "pattern": "\\b(write|add|new)\\s+(a\\s+)?(failing\\s+)?test\\b|\\btdd\\b|\\bred[- ]green[- ]refactor\\b",
6
+ "flags": "i",
7
+ "skill": "tdd-workflow",
8
+ "reason": "Prompt mentions writing tests, TDD, or RED-GREEN-REFACTOR."
9
+ },
10
+ {
11
+ "pattern": "\\b(verify|verification|prove\\s+it\\s+works|smallest\\s+check|check\\s+it\\s+passes)\\b",
12
+ "flags": "i",
13
+ "skill": "verification-loop",
14
+ "reason": "Prompt asks for verification before declaring done."
15
+ },
16
+ {
17
+ "pattern": "\\b(plan|blueprint|design\\s+(the\\s+)?approach|step[- ]by[- ]step\\s+plan|roadmap)\\b",
18
+ "flags": "i",
19
+ "skill": "planning-with-files",
20
+ "reason": "Prompt asks for a plan, blueprint, or design before implementation."
21
+ },
22
+ {
23
+ "pattern": "\\b(security\\s+review|threat\\s+model|owasp|vulnerab|auth(entication|orization)\\s+check)\\b",
24
+ "flags": "i",
25
+ "skill": "security-review",
26
+ "reason": "Prompt touches security review, threat modeling, or auth surface."
27
+ },
28
+ {
29
+ "pattern": "\\b(code\\s+review|review\\s+(my|the|this)\\s+(code|pr|diff|patch)|critique\\s+(the|this)\\s+code)\\b",
30
+ "flags": "i",
31
+ "skill": "code-review",
32
+ "reason": "Prompt asks for a code review on existing changes."
33
+ }
34
+ ]
35
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // three-section-close.mjs — Stop hook that requires every substantive
2
+ // three-section-close.mts — Stop hook that requires every substantive
3
3
  // assistant reply to close with three named sections:
4
4
  // "What has been done", "What is next", "Recommendation".
5
5
  //
@@ -19,163 +19,165 @@
19
19
  // Telemetry: best-effort JSONL line per gated invocation written to
20
20
  // ~/.claude/hook-telemetry/<project-hash>.jsonl. Never throws, never
21
21
  // blocks the response. No network. No transcript content recorded.
22
-
23
22
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
24
23
  import { createHash } from "node:crypto";
25
24
  import { platform } from "node:os";
26
25
  import { dirname, join } from "node:path";
27
26
  import { performance } from "node:perf_hooks";
28
-
29
27
  import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
30
-
31
28
  const MIN_LENGTH_TO_GATE = 600;
32
-
33
29
  const REQUIRED_SECTIONS = [
34
- {
35
- heading: "What has been done",
36
- hint: "concrete summary of changes/findings from this turn",
37
- pattern: /^#+ +What has been done(?:\s|$)/m,
38
- },
39
- {
40
- heading: "What is next",
41
- hint: "minimum 5 specific, actionable bullets",
42
- pattern: /^#+ +What is next(?:\s|$)/m,
43
- },
44
- {
45
- heading: "Recommendation",
46
- hint: "ranked descending by impact, or write \"no\" if there is no recommendation",
47
- pattern: /^#+ +Recommendation(?:\s|$)/m,
48
- },
30
+ {
31
+ heading: "What has been done",
32
+ hint: "concrete summary of changes/findings from this turn",
33
+ pattern: /^#+ +What has been done(?:\s|$)/m,
34
+ },
35
+ {
36
+ heading: "What is next",
37
+ hint: "minimum 5 specific, actionable bullets",
38
+ pattern: /^#+ +What is next(?:\s|$)/m,
39
+ },
40
+ {
41
+ heading: "Recommendation",
42
+ hint: "ranked descending by impact, or write \"no\" if there is no recommendation",
43
+ pattern: /^#+ +Recommendation(?:\s|$)/m,
44
+ },
49
45
  ];
50
-
51
46
  function readStdinSync() {
52
- try {
53
- return readFileSync(0, "utf8");
54
- } catch {
55
- return "";
56
- }
47
+ try {
48
+ return readFileSync(0, "utf8");
49
+ }
50
+ catch {
51
+ return "";
52
+ }
57
53
  }
58
-
59
54
  function safeJsonParse(text) {
60
- try {
61
- return JSON.parse(text);
62
- } catch {
63
- return null;
64
- }
55
+ try {
56
+ return JSON.parse(text);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
65
61
  }
66
-
67
62
  function extractLastAssistantText(transcriptPath) {
68
- const raw = readFileSync(transcriptPath, "utf8");
69
- const lines = raw.split(/\r?\n/);
70
- for (let i = lines.length - 1; i >= 0; i--) {
71
- const line = lines[i].trim();
72
- if (!line) continue;
73
- const obj = safeJsonParse(line);
74
- if (!obj || obj.type !== "assistant") continue;
75
- const blocks = obj.message?.content;
76
- if (!Array.isArray(blocks)) continue;
77
- const texts = blocks
78
- .filter((b) => b && b.type === "text" && typeof b.text === "string")
79
- .map((b) => b.text);
80
- if (texts.length === 0) continue;
81
- return texts.join("\n");
82
- }
83
- return null;
63
+ const raw = readFileSync(transcriptPath, "utf8");
64
+ const lines = raw.split(/\r?\n/);
65
+ for (let i = lines.length - 1; i >= 0; i--) {
66
+ const line = lines[i].trim();
67
+ if (!line)
68
+ continue;
69
+ const obj = safeJsonParse(line);
70
+ if (!obj || obj.type !== "assistant")
71
+ continue;
72
+ const blocks = obj.message?.content;
73
+ if (!Array.isArray(blocks))
74
+ continue;
75
+ const texts = blocks
76
+ .filter((b) => b &&
77
+ typeof b === "object" &&
78
+ b.type === "text" &&
79
+ typeof b.text === "string")
80
+ .map((b) => b.text);
81
+ if (texts.length === 0)
82
+ continue;
83
+ return texts.join("\n");
84
+ }
85
+ return null;
84
86
  }
85
-
86
87
  function emitBlock(missing) {
87
- const bullets = missing
88
- .map(
89
- (s) =>
90
- `- "## ${s.heading}" ${s.hint}`,
91
- )
92
- .join("\n");
93
- const reason =
94
- `Your reply is missing the required 3-section close. Add these sections at the end of the reply with these exact headings:\n${bullets}`;
95
- process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
88
+ const bullets = missing
89
+ .map((s) => `- "## ${s.heading}" — ${s.hint}`)
90
+ .join("\n");
91
+ const reason = `Your reply is missing the required 3-section close. Add these sections at the end of the reply with these exact headings:\n${bullets}`;
92
+ process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
96
93
  }
97
-
98
94
  function projectHashFor(transcriptPath) {
99
- const dir = dirname(transcriptPath);
100
- let normalized = dir.split("\\").join("/");
101
- if (platform() === "win32") normalized = normalized.toLowerCase();
102
- return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
95
+ const dir = dirname(transcriptPath);
96
+ let normalized = dir.split("\\").join("/");
97
+ if (platform() === "win32")
98
+ normalized = normalized.toLowerCase();
99
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
103
100
  }
104
-
105
101
  function recordTelemetry(entry, transcriptPath) {
106
- try {
107
- const home = resolveHomeDir();
108
- if (!home) return;
109
- const telemetryDir = join(home, ".claude", "hook-telemetry");
110
102
  try {
111
- mkdirSync(telemetryDir, { recursive: true });
112
- } catch {
113
- return;
103
+ const home = resolveHomeDir();
104
+ if (!home)
105
+ return;
106
+ const telemetryDir = join(home, ".claude", "hook-telemetry");
107
+ try {
108
+ mkdirSync(telemetryDir, { recursive: true });
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ const file = join(telemetryDir, `${projectHashFor(transcriptPath)}.jsonl`);
114
+ try {
115
+ appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
116
+ }
117
+ catch {
118
+ return;
119
+ }
114
120
  }
115
- const file = join(telemetryDir, `${projectHashFor(transcriptPath)}.jsonl`);
116
- try {
117
- appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
118
- } catch {
119
- return;
121
+ catch {
122
+ // belt-and-suspenders: telemetry must never throw
120
123
  }
121
- } catch {
122
- // belt-and-suspenders: telemetry must never throw
123
- }
124
124
  }
125
-
126
125
  function main() {
127
- // Per-operator opt-out. When CLAUDE_THREE_SECTION_CLOSE_DISABLED=1, the hook
128
- // short-circuits entirely: no enforcement, no telemetry. Public default is
129
- // unchanged (rule still fires for everyone else). See feedback memory
130
- // 2026-05-04 for rationale: an audit found 22,876 observations produced
131
- // only 2 graduated instincts, so visible end-of-turn reflection earns its
132
- // keep poorly enough that operators may want to run it as internal thinking.
133
- if (process.env.CLAUDE_THREE_SECTION_CLOSE_DISABLED === "1") return;
134
- const startedAt = performance.now();
135
- const stdin = readStdinSync();
136
- if (!stdin) return;
137
-
138
- const payload = safeJsonParse(stdin);
139
- if (!payload || typeof payload.transcript_path !== "string") return;
140
- if (!existsSync(payload.transcript_path)) return;
141
-
142
- let lastText;
143
- try {
144
- lastText = extractLastAssistantText(payload.transcript_path);
145
- } catch {
146
- return; // fail open on read error
147
- }
148
- if (!lastText) return;
149
-
150
- const textLength = lastText.length;
151
- let action;
152
- let missing = [];
153
-
154
- if (textLength < MIN_LENGTH_TO_GATE) {
155
- action = "skip-short";
156
- } else {
157
- missing = REQUIRED_SECTIONS.filter((s) => !s.pattern.test(lastText));
158
- if (missing.length === 0) {
159
- action = "pass";
160
- } else {
161
- action = "block";
162
- emitBlock(missing);
126
+ // Per-operator opt-out. When CLAUDE_THREE_SECTION_CLOSE_DISABLED=1, the hook
127
+ // short-circuits entirely: no enforcement, no telemetry. Public default is
128
+ // unchanged (rule still fires for everyone else). See feedback memory
129
+ // 2026-05-04 for rationale: an audit found 22,876 observations produced
130
+ // only 2 graduated instincts, so visible end-of-turn reflection earns its
131
+ // keep poorly enough that operators may want to run it as internal thinking.
132
+ if (process.env.CLAUDE_THREE_SECTION_CLOSE_DISABLED === "1")
133
+ return;
134
+ const startedAt = performance.now();
135
+ const stdin = readStdinSync();
136
+ if (!stdin)
137
+ return;
138
+ const payload = safeJsonParse(stdin);
139
+ if (!payload || typeof payload.transcript_path !== "string")
140
+ return;
141
+ if (!existsSync(payload.transcript_path))
142
+ return;
143
+ let lastText;
144
+ try {
145
+ lastText = extractLastAssistantText(payload.transcript_path);
146
+ }
147
+ catch {
148
+ return; // fail open on read error
163
149
  }
164
- }
165
-
166
- const entry = {
167
- ts: new Date().toISOString(),
168
- hook: "three-section-close",
169
- action,
170
- textLength,
171
- missing: missing.map((s) => s.heading),
172
- durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
173
- };
174
- recordTelemetry(entry, payload.transcript_path);
150
+ if (!lastText)
151
+ return;
152
+ const textLength = lastText.length;
153
+ let action;
154
+ let missing = [];
155
+ if (textLength < MIN_LENGTH_TO_GATE) {
156
+ action = "skip-short";
157
+ }
158
+ else {
159
+ missing = REQUIRED_SECTIONS.filter((s) => !s.pattern.test(lastText));
160
+ if (missing.length === 0) {
161
+ action = "pass";
162
+ }
163
+ else {
164
+ action = "block";
165
+ emitBlock(missing);
166
+ }
167
+ }
168
+ const entry = {
169
+ ts: new Date().toISOString(),
170
+ hook: "three-section-close",
171
+ action,
172
+ textLength,
173
+ missing: missing.map((s) => s.heading),
174
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
175
+ };
176
+ recordTelemetry(entry, payload.transcript_path);
175
177
  }
176
-
177
178
  try {
178
- main();
179
- } catch {
180
- // fail open — never block the session due to a hook bug
179
+ main();
180
+ }
181
+ catch {
182
+ // fail open — never block the session due to a hook bug
181
183
  }
File without changes
File without changes
@@ -29,7 +29,7 @@ export function resolveSessionDir() {
29
29
  return fromEnv;
30
30
  const home = process.env.HOME || process.env.USERPROFILE || homedir();
31
31
  const projectRoot = resolveProjectRoot();
32
- const projectHash = createHash("sha256").update(projectRoot).digest("hex").slice(0, 12);
32
+ const projectHash = createHash("sha256").update(canonicalizeProjectRoot(projectRoot)).digest("hex").slice(0, 12);
33
33
  return join(home, ".claude", "instincts", projectHash);
34
34
  }
35
35
  function resolveProjectRoot() {
@@ -79,7 +79,59 @@ export function markFileCleared(state, filePath) {
79
79
  ...state,
80
80
  cleared_files: {
81
81
  ...state.cleared_files,
82
- [filePath]: { cleared_at: new Date().toISOString() },
82
+ [canonicalizeFileKey(filePath)]: { cleared_at: new Date().toISOString() },
83
83
  },
84
84
  };
85
85
  }
86
+ // Canonicalize a path so the hook and any clearance helper agree regardless of
87
+ // which process resolved it. Two processes can supply the same directory in
88
+ // different forms — CLAUDE_PROJECT_DIR gives `d:/...` (lowercase drive) while
89
+ // `git rev-parse --show-toplevel` gives `D:/...` (uppercase) — and tool inputs
90
+ // vary by separator. Normalizing the drive-letter case and separators (only;
91
+ // directory/file-name case is preserved) makes the hash and the per-file key
92
+ // stable. Relative and POSIX paths have no drive letter or backslash, so they
93
+ // pass through unchanged and existing relative-key state keeps matching.
94
+ function canonicalizePath(p) {
95
+ let out = p.replace(/\\/g, "/");
96
+ if (/^[A-Za-z]:/.test(out)) {
97
+ out = out.charAt(0).toLowerCase() + out.slice(1);
98
+ }
99
+ while (out.length > 1 && out.endsWith("/")) {
100
+ out = out.slice(0, -1);
101
+ }
102
+ return out;
103
+ }
104
+ export function canonicalizeProjectRoot(p) {
105
+ return canonicalizePath(p);
106
+ }
107
+ export function canonicalizeFileKey(p) {
108
+ return canonicalizePath(p);
109
+ }
110
+ export function isFileCleared(state, filePath) {
111
+ return canonicalizeFileKey(filePath) in state.cleared_files;
112
+ }
113
+ // Shared clearance writer used by the MCP tool and the CLI. Loads, marks each
114
+ // not-yet-cleared canonical key (respecting MAX_CLEARED_FILES), and saves once.
115
+ // Idempotent across path forms; returns what it cleared and what it skipped for
116
+ // the cap so callers can report partial results on a MultiEdit-sized batch.
117
+ export function clearFiles(sessionDir, filePaths) {
118
+ const cleared = [];
119
+ const skippedForCap = [];
120
+ let state = loadState(sessionDir);
121
+ for (const filePath of filePaths) {
122
+ const key = canonicalizeFileKey(filePath);
123
+ if (key in state.cleared_files) {
124
+ continue; // already cleared — idempotent, no cap charge
125
+ }
126
+ if (Object.keys(state.cleared_files).length >= MAX_CLEARED_FILES) {
127
+ skippedForCap.push(filePath);
128
+ continue;
129
+ }
130
+ state = markFileCleared(state, filePath);
131
+ cleared.push(key);
132
+ }
133
+ if (cleared.length > 0) {
134
+ saveState(sessionDir, state);
135
+ }
136
+ return { cleared, skippedForCap };
137
+ }
@@ -0,0 +1,50 @@
1
+ // goal-drift-gate.mts — Pure decision core for the goal-drift Stop hook.
2
+ //
3
+ // No I/O. Given a goal-plan body, recent observations, the length of the last
4
+ // assistant reply, and the operator's mode, decide whether the Stop hook should
5
+ // allow / warn / block. The hook (src/hooks/goal-drift-stop.mts) wires the file
6
+ // reads around this function; keeping it pure lets the unit tests cover every
7
+ // branch without touching the filesystem.
8
+ //
9
+ // Fail-open by construction: any state that is not a confident DRIFT on a
10
+ // substantive wrap-up returns "allow".
11
+ import { parseGoalFromPlan, scoreObservations, } from "./goal-state.mjs";
12
+ // Matches the three-section-close wrap-up threshold: a substantive Stop reply is
13
+ // a completion report. This is the non-lexical "claims done" proxy — no keyword
14
+ // matching, so it cannot false-fire on the word "done" in ordinary prose.
15
+ const MIN_LENGTH_TO_GATE = 600;
16
+ export function evaluateGoalDrift(input) {
17
+ const { goalMarkdown, observations, textLength, mode } = input;
18
+ if (mode === "off") {
19
+ return { action: "allow", status: "no-goal", score: 0, reason: "Goal-drift gate disabled (mode=off)." };
20
+ }
21
+ const goal = parseGoalFromPlan(goalMarkdown);
22
+ if (!goal) {
23
+ return {
24
+ action: "allow",
25
+ status: "no-goal",
26
+ score: 0,
27
+ reason: "No '## Goal' section found; nothing to gate against.",
28
+ };
29
+ }
30
+ const report = scoreObservations(observations, goal);
31
+ if (report.status === "no-data") {
32
+ return { action: "allow", status: "no-data", score: 0, reason: "Not enough recent observations to judge drift." };
33
+ }
34
+ if (report.status === "on-goal") {
35
+ return { action: "allow", status: "on-goal", score: report.score, reason: report.reason };
36
+ }
37
+ // report.status === "drift"
38
+ if (textLength < MIN_LENGTH_TO_GATE) {
39
+ return {
40
+ action: "allow",
41
+ status: "drift",
42
+ score: report.score,
43
+ reason: "Drift detected, but the reply is too short to be a wrap-up; not gating.",
44
+ };
45
+ }
46
+ const pct = Math.round(report.score * 100);
47
+ const reason = `Session has DRIFTed from the stated goal (${report.matching}/${report.total} recent actions on-goal, ${pct}%). ` +
48
+ "Re-check the work against the '## Goal' in task_plan.md — or update the goal if it has legitimately changed — before closing.";
49
+ return { action: mode === "block" ? "block" : "warn", status: "drift", score: report.score, reason };
50
+ }