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,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime PreToolUse companion-preference hook.
4
+ *
5
+ * Reads ~/.claude/settings.json → continuous_improvement.companion_preference
6
+ * and enforces the Companion-Preference Override contract documented in
7
+ * skills/superpowers.md for `Skill` tool calls.
8
+ *
9
+ * ci-first (default): no-op, allow.
10
+ * companions-first : advisory stderr on mapped CI→companion pair; allow.
11
+ * strict-companions : block mapped CI route. Reason names the companion
12
+ * (when installed) or the install hint (when not).
13
+ *
14
+ * Fail-open: any unexpected error reading stdin / parsing settings / probing
15
+ * the filesystem → emit { decision: "allow" } and exit 0. The hook never
16
+ * blocks on its own bugs.
17
+ *
18
+ * Stdin : JSON { tool_name, tool_input }
19
+ * Stdout : JSON { decision: "allow" | "block", reason?: string }
20
+ * Exit : 0 always — decision lives in stdout, never in the exit code.
21
+ *
22
+ * OVERRIDES map must stay row-aligned with skills/superpowers.md
23
+ * § "Which rows the override affects" — drift will surface when the test
24
+ * suite or a follow-up audit walks the table against this map.
25
+ */
26
+ import { execFileSync } from "node:child_process";
27
+ import { createHash } from "node:crypto";
28
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
31
+ const OVERRIDES = {
32
+ "tdd-workflow": {
33
+ companion: "superpowers:test-driven-development",
34
+ plugin: "superpowers",
35
+ },
36
+ "verification-loop": {
37
+ companion: "superpowers:verification-before-completion",
38
+ plugin: "superpowers",
39
+ },
40
+ "context-budget": {
41
+ companion: "agent-skills:context-engineering",
42
+ plugin: "agent-skills",
43
+ },
44
+ ralph: {
45
+ companion: "oh-my-claudecode:ultrawork",
46
+ plugin: "oh-my-claudecode",
47
+ },
48
+ "learn-eval": {
49
+ companion: "oh-my-claudecode:retrospective",
50
+ plugin: "oh-my-claudecode",
51
+ },
52
+ };
53
+ function emitAndExit(d) {
54
+ process.stdout.write(`${JSON.stringify(d)}\n`);
55
+ process.exit(0);
56
+ }
57
+ function readStdin() {
58
+ try {
59
+ return readFileSync(0, "utf8");
60
+ }
61
+ catch {
62
+ return "";
63
+ }
64
+ }
65
+ function readMode(home) {
66
+ try {
67
+ const raw = readFileSync(join(home, ".claude", "settings.json"), "utf8");
68
+ const settings = JSON.parse(raw);
69
+ const mode = settings.continuous_improvement?.companion_preference;
70
+ if (mode === "companions-first" || mode === "strict-companions")
71
+ return mode;
72
+ return "ci-first";
73
+ }
74
+ catch {
75
+ return "ci-first";
76
+ }
77
+ }
78
+ function isCompanionInstalled(home, plugin) {
79
+ try {
80
+ return existsSync(join(home, ".claude", "plugins", plugin));
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ function normalizeSkill(skill) {
87
+ return skill.startsWith("ci:") ? skill.slice(3) : skill;
88
+ }
89
+ function resolveHome() {
90
+ return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
91
+ }
92
+ function resolveProjectRoot() {
93
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR;
94
+ if (fromEnv)
95
+ return fromEnv;
96
+ try {
97
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
98
+ encoding: "utf8",
99
+ stdio: ["ignore", "pipe", "ignore"],
100
+ }).trim();
101
+ if (root)
102
+ return root;
103
+ }
104
+ catch {
105
+ // not in a git repo
106
+ }
107
+ return "global";
108
+ }
109
+ function telemetryPath(home) {
110
+ const hash = createHash("sha256")
111
+ .update(resolveProjectRoot())
112
+ .digest("hex")
113
+ .slice(0, 12);
114
+ return join(home, ".claude", "instincts", hash, "companion-preference.jsonl");
115
+ }
116
+ /**
117
+ * Append one JSONL line per hook decision. Wrapped to fail open: any write
118
+ * error (read-only dir, disk full, race on mkdir) is swallowed and the hook
119
+ * decision proceeds. Telemetry must never block tool calls.
120
+ */
121
+ function writeTelemetry(home, event) {
122
+ try {
123
+ const path = telemetryPath(home);
124
+ const dir = join(path, "..");
125
+ if (!existsSync(dir))
126
+ mkdirSync(dir, { recursive: true });
127
+ appendFileSync(path, `${JSON.stringify(event)}\n`);
128
+ }
129
+ catch {
130
+ // fail-open — telemetry failure never changes the decision
131
+ }
132
+ }
133
+ function main() {
134
+ const raw = readStdin();
135
+ let payload;
136
+ try {
137
+ payload = JSON.parse(raw);
138
+ }
139
+ catch {
140
+ emitAndExit({ decision: "allow" });
141
+ }
142
+ if (payload.tool_name !== "Skill") {
143
+ emitAndExit({ decision: "allow" });
144
+ }
145
+ const skill = payload.tool_input?.skill;
146
+ if (typeof skill !== "string" || skill.length === 0) {
147
+ emitAndExit({ decision: "allow" });
148
+ }
149
+ const normalized = normalizeSkill(skill);
150
+ const override = OVERRIDES[normalized];
151
+ if (!override) {
152
+ emitAndExit({ decision: "allow" });
153
+ }
154
+ const home = resolveHome();
155
+ const mode = readMode(home);
156
+ const installed = isCompanionInstalled(home, override.plugin);
157
+ const baseEvent = {
158
+ ts: new Date().toISOString(),
159
+ hook: "companion-preference",
160
+ ci_skill: normalized,
161
+ companion: override.companion,
162
+ plugin: override.plugin,
163
+ companion_installed: installed,
164
+ };
165
+ if (mode === "ci-first") {
166
+ // Shadow row: what companions-first would have done. This is the data
167
+ // set that earns a future default-flip decision.
168
+ writeTelemetry(home, { ...baseEvent, mode, action: "observation" });
169
+ emitAndExit({ decision: "allow" });
170
+ }
171
+ if (mode === "companions-first") {
172
+ process.stderr.write(`[continuous-improvement] companion_preference=companions-first → prefer \`${override.companion}\` over \`ci:${normalized}\`.\n`);
173
+ writeTelemetry(home, { ...baseEvent, mode, action: "advisory" });
174
+ emitAndExit({ decision: "allow" });
175
+ }
176
+ // strict-companions: always block; reason depends on install state.
177
+ if (installed) {
178
+ writeTelemetry(home, { ...baseEvent, mode, action: "block" });
179
+ emitAndExit({
180
+ decision: "block",
181
+ reason: `companion_preference=strict-companions: route to \`${override.companion}\` instead of \`ci:${normalized}\`. The CI fallback is suppressed by your setting.`,
182
+ });
183
+ }
184
+ writeTelemetry(home, { ...baseEvent, mode, action: "block-not-installed" });
185
+ emitAndExit({
186
+ decision: "block",
187
+ reason: `companion_preference=strict-companions: companion plugin \`${override.plugin}\` is not installed. Install with \`/plugin install ${override.plugin}@continuous-improvement\` or relax the setting to \`companions-first\` or \`ci-first\` in ~/.claude/settings.json.`,
188
+ });
189
+ }
190
+ main();
@@ -9,8 +9,10 @@
9
9
  * Three-stage gate per skills/gateguard.md:
10
10
  * - DENY : first mutating tool call per file, with fact-list reason
11
11
  * - FORCE : agent presents facts (model-side; out of band)
12
- * - ALLOW : retry once `_gateguard_facts_presented: true` is set or the
13
- * per-file marker is recorded in session state
12
+ * - ALLOW : retry once the per-file marker is recorded in session state
13
+ * (harness-portable, written out of band via Bash), or
14
+ * `_gateguard_facts_presented: true` is set where the harness
15
+ * forwards unknown tool params
14
16
  *
15
17
  * Read-only and exploratory tools (Read, Grep, Glob, routine Bash) bypass
16
18
  * unconditionally. Destructive Bash gates EVERY call, not just first.
@@ -18,12 +20,15 @@
18
20
  * V1 honest limitations (see src/lib/gateguard-state.mts header):
19
21
  * honor-system flag, state-file deletion, parallel-hook race.
20
22
  *
21
- * MultiEdit handling (V1): gates on edits[0].file_path only. Per-file
22
- * batching is not implemented TODO: extend to gate every entry in
23
- * edits[]. Tracked in issue #106 acceptance criteria item 5.
23
+ * MultiEdit handling gates every edited file individually. The hook blocks
24
+ * unless all edited paths are already cleared, or the agent presents facts
25
+ * for the call and the hook records clearance for each edited path.
26
+ * Block reasons now name the whole batch instead of just the first uncleared
27
+ * path.
24
28
  */
25
29
  import { readFileSync } from "node:fs";
26
- import { isCapReached, loadState, markFileCleared, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
30
+ import { join } from "node:path";
31
+ import { MAX_CLEARED_FILES, canonicalizeFileKey, isCapReached, isFileCleared, loadState, markFileCleared, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
27
32
  const TOOL_ROUTE = {
28
33
  Read: "allow",
29
34
  Grep: "allow",
@@ -71,30 +76,54 @@ function classifyTool(toolName, toolInput) {
71
76
  }
72
77
  return "allow";
73
78
  }
74
- function extractFilePath(toolInput) {
79
+ function extractFilePaths(toolInput) {
75
80
  if (typeof toolInput.file_path === "string")
76
- return toolInput.file_path;
77
- // MultiEdit V1: first edit's file_path is the canonical key.
81
+ return [toolInput.file_path];
78
82
  if (Array.isArray(toolInput.edits) && toolInput.edits.length > 0) {
79
- const first = toolInput.edits[0];
80
- if (first && typeof first.file_path === "string")
81
- return first.file_path;
83
+ return toolInput.edits
84
+ .map((edit) => (edit && typeof edit.file_path === "string" ? edit.file_path : ""))
85
+ .filter((filePath) => filePath !== "");
82
86
  }
83
87
  if (typeof toolInput.command === "string")
84
- return toolInput.command;
85
- return "";
88
+ return [toolInput.command];
89
+ return [];
86
90
  }
87
- function buildMutatingFileReason(toolName, filePath) {
91
+ // The call site only reads this inside the block branch, where at least one
92
+ // path is uncleared; an all-cleared batch returns "" and is never consumed.
93
+ function firstUnclearedFilePath(toolInput, state) {
94
+ return extractFilePaths(toolInput).find((path) => !isFileCleared(state, path)) ?? "";
95
+ }
96
+ function countDistinctNewFilePaths(filePaths, state) {
97
+ return new Set(filePaths.filter((path) => !isFileCleared(state, path)).map((path) => canonicalizeFileKey(path))).size;
98
+ }
99
+ function formatFileTarget(filePaths) {
100
+ const nonEmpty = filePaths.filter((path) => path !== "");
101
+ if (nonEmpty.length === 0)
102
+ return "<unknown>";
103
+ if (nonEmpty.length === 1)
104
+ return nonEmpty[0];
105
+ return `${nonEmpty.length} files (${nonEmpty.join(", ")})`;
106
+ }
107
+ function buildMutatingFileReason(toolName, filePaths, stateFilePath) {
108
+ const target = formatFileTarget(filePaths);
109
+ const pathList = filePaths.filter((p) => p !== "");
110
+ const jsonArray = (pathList.length > 0 ? pathList : [target]).map((p) => JSON.stringify(p)).join(", ");
111
+ const cliArgs = (pathList.length > 0 ? pathList : [target]).map((p) => JSON.stringify(p)).join(" ");
88
112
  return [
89
- `Before ${toolName === "Write" ? "creating" : "editing"} ${filePath || "<unknown>"}, present these facts:`,
113
+ `Before ${toolName === "Write" ? "creating" : "editing"} ${target}, present these facts:`,
90
114
  "",
91
115
  " 1. List ALL files that import/require this file (use Grep)",
92
116
  " 2. List the public functions/classes affected by this change",
93
117
  " 3. If this file reads/writes data files, show field names, structure, and date format",
94
118
  " 4. Quote the user's current instruction verbatim",
95
119
  "",
96
- "After presenting the facts, retry with `_gateguard_facts_presented: true` in tool_input,",
97
- "or with the same file_path after a previous clearance has been recorded for this session.",
120
+ "Then clear the gate and retry the same call. Any one of:",
121
+ ` A. MCP tool: ci_gateguard_clear {file_paths: [${jsonArray}]}`,
122
+ ` B. CLI (Bash, never gated): node "\${CLAUDE_PLUGIN_ROOT}/bin/gateguard-clear.mjs" ${cliArgs}`,
123
+ ` C. Manual: append each path to "cleared_files" in ${stateFilePath} via a non-destructive Bash write.`,
124
+ " Clearance matches regardless of drive-letter case or path separator.",
125
+ " (On harnesses that forward unknown tool params you may instead retry with",
126
+ " `_gateguard_facts_presented: true`; Claude Code's strict schema rejects that, so use A/B/C.)",
98
127
  ].join("\n");
99
128
  }
100
129
  function buildDestructiveBashReason(command) {
@@ -110,7 +139,7 @@ function buildDestructiveBashReason(command) {
110
139
  }
111
140
  function buildCapReachedReason() {
112
141
  return [
113
- "Gateguard session clearance cap reached (50 distinct files).",
142
+ `Gateguard session clearance cap reached (${MAX_CLEARED_FILES} distinct files).`,
114
143
  "Start a new Claude Code session to reset the gate. The cap exists to bound",
115
144
  "stuck-loop or rogue-agent clearance from compounding within a single session.",
116
145
  ].join("\n");
@@ -150,22 +179,28 @@ function main() {
150
179
  }
151
180
  // mutating-file
152
181
  const sessionDir = resolveSessionDir();
182
+ const stateFilePath = join(sessionDir, "gateguard-session.json");
153
183
  const state = loadState(sessionDir);
154
- const filePath = extractFilePath(toolInput);
184
+ const filePaths = extractFilePaths(toolInput);
185
+ const filePath = firstUnclearedFilePath(toolInput, state);
155
186
  const factsFlagged = toolInput._gateguard_facts_presented === true;
156
- const alreadyCleared = filePath !== "" && filePath in state.cleared_files;
187
+ const alreadyCleared = filePaths.length > 0 && filePaths.every((path) => isFileCleared(state, path));
188
+ const newFileCount = countDistinctNewFilePaths(filePaths, state);
157
189
  if (!factsFlagged && !alreadyCleared) {
158
- emit({ decision: "block", reason: buildMutatingFileReason(toolName, filePath) });
190
+ emit({ decision: "block", reason: buildMutatingFileReason(toolName, filePaths.length > 0 ? filePaths : [filePath], stateFilePath) });
159
191
  return;
160
192
  }
161
193
  if (factsFlagged && !alreadyCleared) {
162
- if (isCapReached(state)) {
194
+ if (isCapReached(state) || Object.keys(state.cleared_files).length + newFileCount > MAX_CLEARED_FILES) {
163
195
  emit({ decision: "block", reason: buildCapReachedReason() });
164
196
  return;
165
197
  }
166
- if (filePath !== "") {
167
- saveState(sessionDir, markFileCleared(state, filePath));
198
+ let nextState = state;
199
+ for (const path of filePaths) {
200
+ nextState = markFileCleared(nextState, path);
168
201
  }
202
+ if (filePaths.length > 0)
203
+ saveState(sessionDir, nextState);
169
204
  }
170
205
  emit({ decision: "allow" });
171
206
  }
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ // goal-drift-stop.mts — Stop hook that scores the turn's activity against the
3
+ // stated goal (task_plan.md ## Goal) and, when the session has DRIFTed on a
4
+ // substantive wrap-up, warns or (opt-in) blocks the close. This is the runtime
5
+ // arm of goal-monitor: it auto-fires the drift check at the Stop phase boundary
6
+ // instead of waiting for someone to run /goal-check.
7
+ //
8
+ // Mode via CLAUDE_GOAL_DRIFT_GATE: "warn" (default) | "block" | "off".
9
+ // - warn : print a one-line drift notice to stderr; never blocks.
10
+ // - block : emit {"decision":"block","reason":...} to re-prompt.
11
+ // - off : no-op.
12
+ // The drift status is persisted to
13
+ // ~/.claude/instincts/<project-hash>/goal-drift-state.json every turn.
14
+ //
15
+ // Project-hash resolution is byte-identical to bin/observe.mts so this hook
16
+ // reads the SAME observations.jsonl the observer writes. Fail-open by
17
+ // construction: any error / missing goal / unreadable observations exits 0 and
18
+ // never blocks. No network. 5s hook budget.
19
+ import { execFileSync } from "node:child_process";
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { evaluateGoalDrift } from "../lib/goal-drift-gate.mjs";
24
+ import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
25
+ function readStdinSync() {
26
+ try {
27
+ return readFileSync(0, "utf8");
28
+ }
29
+ catch {
30
+ return "";
31
+ }
32
+ }
33
+ function safeJsonParse(text) {
34
+ try {
35
+ return JSON.parse(text);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function resolveMode() {
42
+ const raw = (process.env.CLAUDE_GOAL_DRIFT_GATE ?? "warn").trim().toLowerCase();
43
+ return raw === "block" || raw === "off" ? raw : "warn";
44
+ }
45
+ // Mirrors bin/observe.mts:resolveProjectRoot — the same basis the observer uses
46
+ // to bucket observations.jsonl, so the hash here resolves to the same file.
47
+ function resolveProjectRoot() {
48
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR;
49
+ if (fromEnv)
50
+ return fromEnv;
51
+ try {
52
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "ignore"],
55
+ }).trim();
56
+ if (root)
57
+ return root;
58
+ }
59
+ catch {
60
+ // not a git repo
61
+ }
62
+ return "global";
63
+ }
64
+ function projectHash(root) {
65
+ return createHash("sha256").update(root).digest("hex").slice(0, 12);
66
+ }
67
+ function readGoalMarkdown(projectRoot, instinctsProjectDir) {
68
+ const candidates = [join(projectRoot, "task_plan.md"), join(instinctsProjectDir, "goal.md")];
69
+ for (const file of candidates) {
70
+ try {
71
+ if (existsSync(file))
72
+ return readFileSync(file, "utf8");
73
+ }
74
+ catch {
75
+ // try the next candidate
76
+ }
77
+ }
78
+ return "";
79
+ }
80
+ function readObservations(instinctsProjectDir) {
81
+ const file = join(instinctsProjectDir, "observations.jsonl");
82
+ try {
83
+ if (!existsSync(file))
84
+ return [];
85
+ const lines = readFileSync(file, "utf8")
86
+ .split("\n")
87
+ .filter((line) => line.trim().length > 0);
88
+ const out = [];
89
+ for (const line of lines.slice(-100)) {
90
+ const row = safeJsonParse(line);
91
+ if (!row)
92
+ continue;
93
+ out.push({
94
+ ts: typeof row.ts === "string" ? row.ts : "",
95
+ tool: typeof row.tool === "string" ? row.tool : "",
96
+ input_summary: typeof row.input_summary === "string" ? row.input_summary : "",
97
+ output_summary: typeof row.output_summary === "string" ? row.output_summary : "",
98
+ });
99
+ }
100
+ return out;
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ }
106
+ function lastAssistantTextLength(transcriptPath) {
107
+ try {
108
+ const lines = readFileSync(transcriptPath, "utf8").split(/\r?\n/);
109
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
110
+ const line = lines[i].trim();
111
+ if (!line)
112
+ continue;
113
+ const obj = safeJsonParse(line);
114
+ if (!obj || obj.type !== "assistant")
115
+ continue;
116
+ const blocks = obj.message?.content;
117
+ if (!Array.isArray(blocks))
118
+ continue;
119
+ const text = blocks
120
+ .filter((b) => b &&
121
+ typeof b === "object" &&
122
+ b.type === "text" &&
123
+ typeof b.text === "string")
124
+ .map((b) => b.text)
125
+ .join("\n");
126
+ if (text.length === 0)
127
+ continue;
128
+ return text.length;
129
+ }
130
+ }
131
+ catch {
132
+ // fall through to 0
133
+ }
134
+ return 0;
135
+ }
136
+ function persistState(instinctsProjectDir, state) {
137
+ try {
138
+ mkdirSync(instinctsProjectDir, { recursive: true });
139
+ writeFileSync(join(instinctsProjectDir, "goal-drift-state.json"), JSON.stringify({ ts: new Date().toISOString(), ...state }) + "\n", "utf8");
140
+ }
141
+ catch {
142
+ // state persistence is best-effort; never throw
143
+ }
144
+ }
145
+ function main() {
146
+ const mode = resolveMode();
147
+ if (mode === "off")
148
+ return;
149
+ const stdin = readStdinSync();
150
+ if (!stdin)
151
+ return;
152
+ const payload = safeJsonParse(stdin);
153
+ if (!payload)
154
+ return;
155
+ const home = resolveHomeDir();
156
+ if (!home)
157
+ return;
158
+ const projectRoot = resolveProjectRoot();
159
+ const instinctsProjectDir = join(home, ".claude", "instincts", projectHash(projectRoot));
160
+ const goalMarkdown = readGoalMarkdown(projectRoot, instinctsProjectDir);
161
+ const observations = readObservations(instinctsProjectDir);
162
+ const textLength = payload.transcript_path && existsSync(payload.transcript_path)
163
+ ? lastAssistantTextLength(payload.transcript_path)
164
+ : 0;
165
+ const decision = evaluateGoalDrift({ goalMarkdown, observations, textLength, mode });
166
+ persistState(instinctsProjectDir, {
167
+ status: decision.status,
168
+ score: decision.score,
169
+ action: decision.action,
170
+ });
171
+ if (decision.action === "block") {
172
+ process.stdout.write(JSON.stringify({ decision: "block", reason: decision.reason }) + "\n");
173
+ }
174
+ else if (decision.action === "warn") {
175
+ process.stderr.write(`[continuous-improvement] goal-drift: ${decision.reason}\n`);
176
+ }
177
+ }
178
+ try {
179
+ main();
180
+ }
181
+ catch {
182
+ // fail open — never block the session due to a hook bug
183
+ }
package/hooks/observe.sh CHANGED
@@ -101,6 +101,20 @@ fi
101
101
  # ---------------------------------------------------------------------------
102
102
  # Compute project hash and name
103
103
  # ---------------------------------------------------------------------------
104
+ # Canonicalize MSYS-mounted Windows paths (/d/Path) to native form (D:/Path)
105
+ # so the bash fallback hashes to the same project bucket as the Node observer.
106
+ # Without this, Git Bash on Windows produces /d/Ai/repo from `git rev-parse`
107
+ # while Node's child_process produces D:/Ai/repo for the same physical path,
108
+ # splitting telemetry between two SHA-256 buckets per project.
109
+ # Pattern matches /<single-letter>/... only — Linux/macOS paths like /usr,
110
+ # /home, /Users are unaffected.
111
+ case "$PROJECT_ROOT" in
112
+ /[a-zA-Z]/*)
113
+ drive_letter="$(printf '%s' "$PROJECT_ROOT" | cut -c2 | tr '[:lower:]' '[:upper:]')"
114
+ PROJECT_ROOT="${drive_letter}:$(printf '%s' "$PROJECT_ROOT" | cut -c3-)"
115
+ ;;
116
+ esac
117
+
104
118
  PROJECT_HASH="$(printf '%s' "$PROJECT_ROOT" | sha256sum | cut -c1-12)"
105
119
  PROJECT_NAME="$(basename "${PROJECT_ROOT%.git}")"
106
120
 
@@ -119,7 +133,7 @@ if [[ -f "$OBS_FILE" ]]; then
119
133
  LINE_COUNT="$(wc -l < "$OBS_FILE")"
120
134
  if (( LINE_COUNT >= 10000 )); then
121
135
  mv "$OBS_FILE" "${PROJECT_DIR}/observations.$(date -u +"%Y-%m-%d-%H%M%S").jsonl"
122
-
136
+
123
137
  # Clean up old archives - keep only 10 most recent
124
138
  ls -t "${PROJECT_DIR}"/observations.*.jsonl 2>/dev/null | tail -n +11 | xargs -r rm -f
125
139
  fi