continuous-improvement 3.11.0 → 3.15.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/CHANGELOG.md +65 -1
  3. package/QUICKSTART.md +1 -1
  4. package/README.md +39 -18
  5. package/SKILL.md +1 -1
  6. package/bin/check-skill-count.mjs +32 -1
  7. package/bin/check-test-imports-only.mjs +1 -1
  8. package/bin/check-tool-count.mjs +129 -0
  9. package/bin/generate-plugin-manifests.mjs +3 -1
  10. package/bin/harvest-friction.mjs +1 -1
  11. package/bin/install.mjs +77 -3
  12. package/bin/mcp-server.mjs +66 -9
  13. package/bin/plan-pack.mjs +77 -0
  14. package/bin/unified-cli.mjs +55 -410
  15. package/commands/harvest.md +1 -1
  16. package/commands/model-forward.md +13 -0
  17. package/commands/production-readiness-review.md +53 -0
  18. package/commands/ship.md +57 -0
  19. package/commands/superpowers.md +1 -1
  20. package/hooks/companion-preference.mjs +31 -19
  21. package/hooks/gateguard.mjs +59 -25
  22. package/hooks/hook-pack.mjs +110 -0
  23. package/hooks/recall-briefing.mjs +167 -0
  24. package/lib/gateguard-state.mjs +62 -13
  25. package/lib/goal-state.mjs +13 -9
  26. package/lib/hook-pack-gate.mjs +65 -0
  27. package/lib/install-targets.mjs +121 -0
  28. package/lib/plan-review-packet.mjs +96 -0
  29. package/lib/plugin-metadata.mjs +33 -7
  30. package/lib/recall-briefing.mjs +57 -0
  31. package/lib/recall-index.mjs +2 -2
  32. package/lib/skill-distill.mjs +141 -0
  33. package/llms.txt +2 -2
  34. package/package.json +6 -4
  35. package/plugins/beginner.json +3 -3
  36. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
  37. package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
  38. package/plugins/continuous-improvement/README.md +1 -1
  39. package/plugins/continuous-improvement/agents/README.md +3 -3
  40. package/plugins/continuous-improvement/bin/mcp-server.mjs +66 -9
  41. package/plugins/continuous-improvement/commands/harvest.md +1 -1
  42. package/plugins/continuous-improvement/commands/model-forward.md +13 -0
  43. package/plugins/continuous-improvement/commands/production-readiness-review.md +53 -0
  44. package/plugins/continuous-improvement/commands/ship.md +57 -0
  45. package/plugins/continuous-improvement/commands/superpowers.md +1 -1
  46. package/plugins/continuous-improvement/hooks/companion-preference.mjs +31 -19
  47. package/plugins/continuous-improvement/hooks/gateguard.mjs +59 -25
  48. package/plugins/continuous-improvement/hooks/hook-pack.mjs +110 -0
  49. package/plugins/continuous-improvement/hooks/hooks.json +16 -1
  50. package/plugins/continuous-improvement/hooks/recall-briefing.mjs +167 -0
  51. package/plugins/continuous-improvement/lib/gateguard-state.mjs +62 -13
  52. package/plugins/continuous-improvement/lib/goal-state.mjs +13 -9
  53. package/plugins/continuous-improvement/lib/hook-pack-gate.mjs +65 -0
  54. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +33 -7
  55. package/plugins/continuous-improvement/lib/recall-briefing.mjs +57 -0
  56. package/plugins/continuous-improvement/lib/recall-index.mjs +2 -2
  57. package/plugins/continuous-improvement/lib/skill-distill.mjs +141 -0
  58. package/plugins/continuous-improvement/skills/README.md +1 -1
  59. package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +1 -1
  60. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +4 -4
  61. package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +1 -1
  62. package/plugins/continuous-improvement/skills/handoff/SKILL.md +0 -1
  63. package/plugins/continuous-improvement/skills/model-forward/SKILL.md +44 -0
  64. package/plugins/continuous-improvement/skills/superpowers/SKILL.md +1 -1
  65. package/plugins/expert.json +6 -2
  66. package/skills/README.md +4 -2
  67. package/skills/gateguard.md +4 -4
  68. package/skills/goal-monitor.md +1 -1
  69. package/skills/handoff.md +0 -1
  70. package/skills/model-forward.md +44 -0
  71. package/skills/superpowers.md +1 -1
  72. package/lib/compound-engineering.mjs +0 -831
  73. package/lib/pm-skills.mjs +0 -1274
  74. package/lib/unified-plugin.mjs +0 -924
  75. package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +0 -108
  76. package/skills/para-memory-files.md +0 -108
@@ -12,11 +12,17 @@
12
12
  * (when installed) or the install hint (when not).
13
13
  *
14
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.
15
+ * the filesystem → exit 0 with empty stdout (allow). The hook never blocks
16
+ * on its own bugs.
17
17
  *
18
18
  * Stdin : JSON { tool_name, tool_input }
19
- * Stdout : JSON { decision: "allow" | "block", reason?: string }
19
+ * Stdout : empty on allow (no output = no opinion, the call proceeds); on
20
+ * deny, the documented PreToolUse shape:
21
+ * { hookSpecificOutput: { hookEventName: "PreToolUse",
22
+ * permissionDecision: "deny", permissionDecisionReason } }
23
+ * A bare { decision: "allow" } is not schema-valid for PreToolUse
24
+ * (the deprecated enum is "approve" | "block") and surfaces as a
25
+ * "Hook JSON output validation failed" error on every tool call.
20
26
  * Exit : 0 always — decision lives in stdout, never in the exit code.
21
27
  *
22
28
  * OVERRIDES map must stay row-aligned with skills/superpowers.md
@@ -50,8 +56,20 @@ const OVERRIDES = {
50
56
  plugin: "oh-my-claudecode",
51
57
  },
52
58
  };
53
- function emitAndExit(d) {
54
- process.stdout.write(`${JSON.stringify(d)}\n`);
59
+ // Allow = empty stdout + exit 0. Emitting JSON here is wrong: PreToolUse has
60
+ // no { decision: "allow" } shape, and the invalid output surfaces as a
61
+ // per-tool-call "Hook JSON output validation failed" error in the client.
62
+ function emitAllowAndExit() {
63
+ process.exit(0);
64
+ }
65
+ function emitDenyAndExit(reason) {
66
+ process.stdout.write(`${JSON.stringify({
67
+ hookSpecificOutput: {
68
+ hookEventName: "PreToolUse",
69
+ permissionDecision: "deny",
70
+ permissionDecisionReason: reason,
71
+ },
72
+ })}\n`);
55
73
  process.exit(0);
56
74
  }
57
75
  function readStdin() {
@@ -137,19 +155,19 @@ function main() {
137
155
  payload = JSON.parse(raw);
138
156
  }
139
157
  catch {
140
- emitAndExit({ decision: "allow" });
158
+ emitAllowAndExit();
141
159
  }
142
160
  if (payload.tool_name !== "Skill") {
143
- emitAndExit({ decision: "allow" });
161
+ emitAllowAndExit();
144
162
  }
145
163
  const skill = payload.tool_input?.skill;
146
164
  if (typeof skill !== "string" || skill.length === 0) {
147
- emitAndExit({ decision: "allow" });
165
+ emitAllowAndExit();
148
166
  }
149
167
  const normalized = normalizeSkill(skill);
150
168
  const override = OVERRIDES[normalized];
151
169
  if (!override) {
152
- emitAndExit({ decision: "allow" });
170
+ emitAllowAndExit();
153
171
  }
154
172
  const home = resolveHome();
155
173
  const mode = readMode(home);
@@ -166,25 +184,19 @@ function main() {
166
184
  // Shadow row: what companions-first would have done. This is the data
167
185
  // set that earns a future default-flip decision.
168
186
  writeTelemetry(home, { ...baseEvent, mode, action: "observation" });
169
- emitAndExit({ decision: "allow" });
187
+ emitAllowAndExit();
170
188
  }
171
189
  if (mode === "companions-first") {
172
190
  process.stderr.write(`[continuous-improvement] companion_preference=companions-first → prefer \`${override.companion}\` over \`ci:${normalized}\`.\n`);
173
191
  writeTelemetry(home, { ...baseEvent, mode, action: "advisory" });
174
- emitAndExit({ decision: "allow" });
192
+ emitAllowAndExit();
175
193
  }
176
194
  // strict-companions: always block; reason depends on install state.
177
195
  if (installed) {
178
196
  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
- });
197
+ emitDenyAndExit(`companion_preference=strict-companions: route to \`${override.companion}\` instead of \`ci:${normalized}\`. The CI fallback is suppressed by your setting.`);
183
198
  }
184
199
  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
- });
200
+ emitDenyAndExit(`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.`);
189
201
  }
190
202
  main();
@@ -3,7 +3,13 @@
3
3
  * Runtime PreToolUse gateguard hook.
4
4
  *
5
5
  * Stdin : JSON { tool_name, tool_input }
6
- * Stdout : JSON { decision: "allow" | "block", reason?: string }
6
+ * Stdout : empty on allow (no output = no opinion, the call proceeds); on
7
+ * deny, the documented PreToolUse shape:
8
+ * { hookSpecificOutput: { hookEventName: "PreToolUse",
9
+ * permissionDecision: "deny", permissionDecisionReason } }
10
+ * A bare { decision: "allow" } is not schema-valid for PreToolUse
11
+ * (the deprecated enum is "approve" | "block") and surfaces as a
12
+ * "Hook JSON output validation failed" error on every tool call.
7
13
  * Exit : 0 always (decision is in stdout, fail-open on parse error).
8
14
  *
9
15
  * Three-stage gate per skills/gateguard.md:
@@ -27,7 +33,8 @@
27
33
  * path.
28
34
  */
29
35
  import { readFileSync } from "node:fs";
30
- import { join } from "node:path";
36
+ import { dirname, join } from "node:path";
37
+ import { fileURLToPath } from "node:url";
31
38
  import { MAX_CLEARED_FILES, canonicalizeFileKey, isCapReached, isFileCleared, loadState, markFileCleared, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
32
39
  const TOOL_ROUTE = {
33
40
  Read: "allow",
@@ -104,11 +111,23 @@ function formatFileTarget(filePaths) {
104
111
  return nonEmpty[0];
105
112
  return `${nonEmpty.length} files (${nonEmpty.join(", ")})`;
106
113
  }
114
+ // Resolved from the hook's own location at load time. The block reason must
115
+ // print a command the agent can run in its OWN shell, where ${CLAUDE_PLUGIN_ROOT}
116
+ // is empty — that variable is only populated while this hook itself executes.
117
+ // bin/ is a sibling of hooks/ in both the repo layout and the plugin-bundle
118
+ // mirror, so this resolves correctly from either.
119
+ const CLEAR_CLI_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gateguard-clear.mjs");
120
+ // Quote a path for the printed clearance command. Forward slashes (node accepts
121
+ // them on every platform and they survive a Bash double-quote without escaping)
122
+ // inside JSON quotes — JSON.stringify still escapes any embedded quote, so a
123
+ // path like a"b.ts stays safe rather than collapsing into a bare-quoted token.
124
+ function quotePath(p) {
125
+ return JSON.stringify(p.replace(/\\/g, "/"));
126
+ }
107
127
  function buildMutatingFileReason(toolName, filePaths, stateFilePath) {
108
128
  const target = formatFileTarget(filePaths);
109
129
  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(" ");
130
+ const quoted = (pathList.length > 0 ? pathList : [target]).map((p) => quotePath(p));
112
131
  return [
113
132
  `Before ${toolName === "Write" ? "creating" : "editing"} ${target}, present these facts:`,
114
133
  "",
@@ -117,13 +136,12 @@ function buildMutatingFileReason(toolName, filePaths, stateFilePath) {
117
136
  " 3. If this file reads/writes data files, show field names, structure, and date format",
118
137
  " 4. Quote the user's current instruction verbatim",
119
138
  "",
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.)",
139
+ "Then clear the gate and retry the same call. Either route works:",
140
+ ` A. Bash (always works — run verbatim): node ${quotePath(CLEAR_CLI_PATH)} --state ${quotePath(stateFilePath)} ${quoted.join(" ")}`,
141
+ ` B. MCP tool (when the continuous-improvement server is connected): ci_gateguard_clear {file_paths: [${quoted.join(", ")}], state_path: ${quotePath(stateFilePath)}}`,
142
+ " Both canonicalize paths drive-letter case and separators don't matter.",
143
+ " (Harnesses that forward unknown tool params may instead retry the call with",
144
+ " `_gateguard_facts_presented: true`; Claude Code's strict schema rejects that, so use A or B.)",
127
145
  ].join("\n");
128
146
  }
129
147
  function buildDestructiveBashReason(command) {
@@ -139,13 +157,26 @@ function buildDestructiveBashReason(command) {
139
157
  }
140
158
  function buildCapReachedReason() {
141
159
  return [
142
- `Gateguard session clearance cap reached (${MAX_CLEARED_FILES} distinct files).`,
143
- "Start a new Claude Code session to reset the gate. The cap exists to bound",
144
- "stuck-loop or rogue-agent clearance from compounding within a single session.",
160
+ `Gateguard clearance cap reached (${MAX_CLEARED_FILES} distinct files this session).`,
161
+ "The cap is per-session: start a new Claude Code session to reset it, or wait",
162
+ "for the state file to self-heal. It bounds stuck-loop or rogue-agent clearance",
163
+ "from compounding within one session without affecting your other sessions.",
145
164
  ].join("\n");
146
165
  }
147
- function emit(decision) {
148
- process.stdout.write(`${JSON.stringify(decision)}\n`);
166
+ // Allow = empty stdout + exit 0. Emitting JSON here is wrong: PreToolUse has
167
+ // no { decision: "allow" } shape, and the invalid output surfaces as a
168
+ // per-tool-call "Hook JSON output validation failed" error in the client.
169
+ function emitAllow() {
170
+ process.exit(0);
171
+ }
172
+ function emitDeny(reason) {
173
+ process.stdout.write(`${JSON.stringify({
174
+ hookSpecificOutput: {
175
+ hookEventName: "PreToolUse",
176
+ permissionDecision: "deny",
177
+ permissionDecisionReason: reason,
178
+ },
179
+ })}\n`);
149
180
  process.exit(0);
150
181
  }
151
182
  function main() {
@@ -154,7 +185,7 @@ function main() {
154
185
  raw = readFileSync(0, "utf8");
155
186
  }
156
187
  catch {
157
- emit({ decision: "allow" });
188
+ emitAllow();
158
189
  return;
159
190
  }
160
191
  let payload;
@@ -162,23 +193,26 @@ function main() {
162
193
  payload = JSON.parse(raw);
163
194
  }
164
195
  catch {
165
- emit({ decision: "allow" }); // fail-open
196
+ emitAllow(); // fail-open
166
197
  return;
167
198
  }
168
199
  const toolName = typeof payload.tool_name === "string" ? payload.tool_name : "";
169
200
  const toolInput = payload.tool_input ?? {};
170
201
  const gate = classifyTool(toolName, toolInput);
171
202
  if (gate === "allow") {
172
- emit({ decision: "allow" });
203
+ emitAllow();
173
204
  return;
174
205
  }
175
206
  if (gate === "destructive-bash") {
176
207
  const cmd = typeof toolInput.command === "string" ? toolInput.command : "";
177
- emit({ decision: "block", reason: buildDestructiveBashReason(cmd) });
208
+ emitDeny(buildDestructiveBashReason(cmd));
178
209
  return;
179
210
  }
180
- // mutating-file
181
- const sessionDir = resolveSessionDir();
211
+ // mutating-file — scope state to THIS session so the clearance cap never
212
+ // bleeds across concurrent same-day sessions. session_id is the standard hook
213
+ // stdin field (see recall-briefing / observe-event); absent → legacy dir.
214
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : undefined;
215
+ const sessionDir = resolveSessionDir(sessionId);
182
216
  const stateFilePath = join(sessionDir, "gateguard-session.json");
183
217
  const state = loadState(sessionDir);
184
218
  const filePaths = extractFilePaths(toolInput);
@@ -187,12 +221,12 @@ function main() {
187
221
  const alreadyCleared = filePaths.length > 0 && filePaths.every((path) => isFileCleared(state, path));
188
222
  const newFileCount = countDistinctNewFilePaths(filePaths, state);
189
223
  if (!factsFlagged && !alreadyCleared) {
190
- emit({ decision: "block", reason: buildMutatingFileReason(toolName, filePaths.length > 0 ? filePaths : [filePath], stateFilePath) });
224
+ emitDeny(buildMutatingFileReason(toolName, filePaths.length > 0 ? filePaths : [filePath], stateFilePath));
191
225
  return;
192
226
  }
193
227
  if (factsFlagged && !alreadyCleared) {
194
228
  if (isCapReached(state) || Object.keys(state.cleared_files).length + newFileCount > MAX_CLEARED_FILES) {
195
- emit({ decision: "block", reason: buildCapReachedReason() });
229
+ emitDeny(buildCapReachedReason());
196
230
  return;
197
231
  }
198
232
  let nextState = state;
@@ -202,6 +236,6 @@ function main() {
202
236
  if (filePaths.length > 0)
203
237
  saveState(sessionDir, nextState);
204
238
  }
205
- emit({ decision: "allow" });
239
+ emitAllow();
206
240
  }
207
241
  main();
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime PreToolUse enforcing-hook pack (v1).
4
+ *
5
+ * Stdin : JSON { tool_name, tool_input: { command } }
6
+ * Stdout : empty on allow / warn (warn prints to stderr); on block, the
7
+ * documented PreToolUse shape:
8
+ * { hookSpecificOutput: { hookEventName: "PreToolUse",
9
+ * permissionDecision: "deny", permissionDecisionReason } }
10
+ * A bare { decision: "allow" } is not schema-valid for PreToolUse.
11
+ * Exit : 0 always (decision is in stdout; fail-open on any error).
12
+ *
13
+ * Two gates over Bash git commands (pure logic in ../lib/hook-pack-gate.mjs):
14
+ * - push-to-main : a direct `git push` to main/master/release-*
15
+ * - commit-size : a `git commit` staging more than COMMIT_FILE_LIMIT files
16
+ *
17
+ * Mode via CLAUDE_CI_HOOKPACK_GATE: "warn" (default) | "block" | "off".
18
+ * warn : print a one-line notice to stderr; never blocks.
19
+ * block : emit the PreToolUse deny shape.
20
+ * off : no-op.
21
+ *
22
+ * Only `git push` / `git commit` commands spawn a git subprocess; every other
23
+ * Bash command and non-Bash tool returns immediately. No network. Fail-open.
24
+ */
25
+ import { execFileSync } from "node:child_process";
26
+ import { readFileSync } from "node:fs";
27
+ import { COMMIT_FILE_LIMIT, classifyCommand, decide, evaluateCommitSize, evaluatePush, parseMode, } from "../lib/hook-pack-gate.mjs";
28
+ function readStdinSync() {
29
+ try {
30
+ return readFileSync(0, "utf8");
31
+ }
32
+ catch {
33
+ return "";
34
+ }
35
+ }
36
+ function safeJsonParse(text) {
37
+ try {
38
+ return JSON.parse(text);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function gitCurrentBranch() {
45
+ try {
46
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
47
+ encoding: "utf8",
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ }).trim();
50
+ return branch || null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ function gitStagedCount() {
57
+ try {
58
+ const out = execFileSync("git", ["diff", "--cached", "--name-only"], {
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "ignore"],
61
+ });
62
+ return out.split(/\r?\n/).filter(Boolean).length;
63
+ }
64
+ catch {
65
+ return 0;
66
+ }
67
+ }
68
+ const ALLOW = { action: "allow", reason: "" };
69
+ function compute() {
70
+ const mode = parseMode(process.env.CLAUDE_CI_HOOKPACK_GATE);
71
+ if (mode === "off")
72
+ return ALLOW;
73
+ const payload = safeJsonParse(readStdinSync());
74
+ if (!payload || typeof payload !== "object")
75
+ return ALLOW;
76
+ const obj = payload;
77
+ if (obj.tool_name !== "Bash")
78
+ return ALLOW;
79
+ const input = (obj.tool_input && typeof obj.tool_input === "object"
80
+ ? obj.tool_input
81
+ : {});
82
+ const command = typeof input.command === "string" ? input.command : "";
83
+ const kind = classifyCommand(command);
84
+ if (kind === "push") {
85
+ const res = evaluatePush(command, gitCurrentBranch());
86
+ return decide(mode, res.gated, `Direct push to protected branch "${res.branch}" — open a feature branch + PR instead.`);
87
+ }
88
+ if (kind === "commit") {
89
+ const res = evaluateCommitSize(command, gitStagedCount());
90
+ return decide(mode, res.gated, `This commit stages more than ${COMMIT_FILE_LIMIT} files — exceeds the one-concern limit. Split by layer, or set CLAUDE_CI_HOOKPACK_GATE=off for genuinely generated bulk.`);
91
+ }
92
+ return ALLOW;
93
+ }
94
+ function main() {
95
+ const decision = compute();
96
+ if (decision.action === "block") {
97
+ process.stdout.write(JSON.stringify({
98
+ hookSpecificOutput: {
99
+ hookEventName: "PreToolUse",
100
+ permissionDecision: "deny",
101
+ permissionDecisionReason: decision.reason,
102
+ },
103
+ }));
104
+ }
105
+ else if (decision.action === "warn") {
106
+ process.stderr.write(`[hook-pack] ${decision.reason}\n`);
107
+ }
108
+ process.exit(0);
109
+ }
110
+ main();
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Gateguard fact-forcing PreToolUse, companion-preference enforcement, observation, session lifecycle, 3-section-close discipline, goal-drift Stop gate, and UserPromptSubmit lazy-routing hooks for continuous-improvement.",
2
+ "description": "Gateguard fact-forcing PreToolUse, companion-preference enforcement, observation, session lifecycle, 3-section-close discipline, goal-drift Stop gate, and UserPromptSubmit lazy-routing plus opt-in proactive recall-briefing hooks for continuous-improvement.",
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
@@ -15,6 +15,16 @@
15
15
  "timeout": 5
16
16
  }
17
17
  ]
18
+ },
19
+ {
20
+ "matcher": "Bash",
21
+ "hooks": [
22
+ {
23
+ "type": "command",
24
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-pack.mjs\"",
25
+ "timeout": 5
26
+ }
27
+ ]
18
28
  }
19
29
  ],
20
30
  "PostToolUse": [
@@ -35,6 +45,11 @@
35
45
  "type": "command",
36
46
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/route-prompt.mjs\"",
37
47
  "timeout": 5
48
+ },
49
+ {
50
+ "type": "command",
51
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recall-briefing.mjs\"",
52
+ "timeout": 5
38
53
  }
39
54
  ]
40
55
  }
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime UserPromptSubmit recall-briefing hook (opt-in amplifier).
4
+ *
5
+ * On the first substantive prompt of a session, searches this project's past
6
+ * observations (BM25, via recall-index.mts) and injects a one-time
7
+ * `<system-reminder>` listing the most relevant prior activity — so the agent
8
+ * reuses a past fix instead of re-deriving it. This makes episodic memory
9
+ * proactive instead of opt-in-per-query (the ci_recall MCP tool stays available
10
+ * for explicit, deeper searches).
11
+ *
12
+ * Opt-in: runs only when CLAUDE_RECALL_BRIEFING is 1/on/true. Default off, so it
13
+ * never changes the prompt path for users who have not enabled it.
14
+ *
15
+ * Stdin : JSON { prompt, session_id?, cwd? }
16
+ * Stdout : JSON { hookSpecificOutput: { hookEventName: "UserPromptSubmit",
17
+ * additionalContext: string } }
18
+ * OR empty when it should not brief.
19
+ * Exit : 0 always — this is an amplifier, never a gate; it cannot block a prompt.
20
+ *
21
+ * Fail-open: any error (no env flag, bad stdin, missing/short observations log,
22
+ * unreadable state) emits nothing and exits 0. State at
23
+ * ~/.claude/instincts/<project-hash>/recall-briefing-session.json records which
24
+ * session_ids have been briefed so each session is briefed at most once.
25
+ */
26
+ import { createHash } from "node:crypto";
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { execFileSync } from "node:child_process";
31
+ import { buildIndex, query } from "../lib/recall-index.mjs";
32
+ import { DEFAULT_MAX_HITS, decideBriefing } from "../lib/recall-briefing.mjs";
33
+ const ENABLED_VALUES = new Set(["1", "on", "true", "yes"]);
34
+ const MAX_BRIEFED_KEYS = 1000;
35
+ function isEnabled() {
36
+ const flag = (process.env.CLAUDE_RECALL_BRIEFING ?? "").trim().toLowerCase();
37
+ return ENABLED_VALUES.has(flag);
38
+ }
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 instinctsDir(home) {
68
+ const hash = createHash("sha256").update(resolveProjectRoot()).digest("hex").slice(0, 12);
69
+ return join(home, ".claude", "instincts", hash);
70
+ }
71
+ function readObservations(dir) {
72
+ const path = join(dir, "observations.jsonl");
73
+ let raw;
74
+ try {
75
+ raw = readFileSync(path, "utf8");
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ const rows = [];
81
+ for (const line of raw.split(/\r?\n/)) {
82
+ const trimmed = line.trim();
83
+ if (!trimmed)
84
+ continue;
85
+ try {
86
+ rows.push(JSON.parse(trimmed));
87
+ }
88
+ catch {
89
+ // skip a malformed line; never let one bad row sink the briefing
90
+ }
91
+ }
92
+ return rows;
93
+ }
94
+ function readState(dir) {
95
+ try {
96
+ const parsed = JSON.parse(readFileSync(join(dir, "recall-briefing-session.json"), "utf8"));
97
+ if (parsed && typeof parsed.briefed === "object" && parsed.briefed !== null)
98
+ return parsed;
99
+ }
100
+ catch {
101
+ // missing or malformed → fresh state
102
+ }
103
+ return { briefed: {} };
104
+ }
105
+ function writeState(dir, state, sessionKey) {
106
+ try {
107
+ const keys = Object.keys(state.briefed);
108
+ // Bound growth: if the map is huge, keep only the most recent run plus this one.
109
+ const briefed = keys.length >= MAX_BRIEFED_KEYS ? {} : state.briefed;
110
+ briefed[sessionKey] = new Date().toISOString();
111
+ if (!existsSync(dir))
112
+ mkdirSync(dir, { recursive: true });
113
+ writeFileSync(join(dir, "recall-briefing-session.json"), `${JSON.stringify({ briefed }, null, 2)}\n`);
114
+ }
115
+ catch {
116
+ // fail-open: a missed state write at worst briefs twice; it never blocks
117
+ }
118
+ }
119
+ function emit(additionalContext) {
120
+ if (additionalContext !== null) {
121
+ process.stdout.write(`${JSON.stringify({
122
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext },
123
+ })}\n`);
124
+ }
125
+ process.exit(0);
126
+ }
127
+ function main() {
128
+ if (!isEnabled()) {
129
+ emit(null);
130
+ return;
131
+ }
132
+ let payload;
133
+ try {
134
+ payload = JSON.parse(readStdin());
135
+ }
136
+ catch {
137
+ emit(null);
138
+ return;
139
+ }
140
+ const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
141
+ if (prompt.length === 0) {
142
+ emit(null);
143
+ return;
144
+ }
145
+ const sessionKey = typeof payload.session_id === "string" && payload.session_id ? payload.session_id : "no-session";
146
+ const home = resolveHome();
147
+ const dir = instinctsDir(home);
148
+ const state = readState(dir);
149
+ const alreadyBriefed = Object.prototype.hasOwnProperty.call(state.briefed, sessionKey);
150
+ const observations = readObservations(dir);
151
+ const index = buildIndex(observations);
152
+ const hits = query(index, prompt, { k: DEFAULT_MAX_HITS });
153
+ const decision = decideBriefing({ enabled: true, alreadyBriefed, prompt, hits });
154
+ if (!decision.shouldBrief || decision.reminder === null) {
155
+ emit(null);
156
+ return;
157
+ }
158
+ writeState(dir, state, sessionKey);
159
+ emit(decision.reminder);
160
+ }
161
+ try {
162
+ main();
163
+ }
164
+ catch {
165
+ // Absolute fail-open backstop: never let a hook bug block a prompt.
166
+ process.exit(0);
167
+ }