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.
- package/.claude-plugin/marketplace.json +3 -3
- package/CHANGELOG.md +65 -1
- package/QUICKSTART.md +1 -1
- package/README.md +39 -18
- package/SKILL.md +1 -1
- package/bin/check-skill-count.mjs +32 -1
- package/bin/check-test-imports-only.mjs +1 -1
- package/bin/check-tool-count.mjs +129 -0
- package/bin/generate-plugin-manifests.mjs +3 -1
- package/bin/harvest-friction.mjs +1 -1
- package/bin/install.mjs +77 -3
- package/bin/mcp-server.mjs +66 -9
- package/bin/plan-pack.mjs +77 -0
- package/bin/unified-cli.mjs +55 -410
- package/commands/harvest.md +1 -1
- package/commands/model-forward.md +13 -0
- package/commands/production-readiness-review.md +53 -0
- package/commands/ship.md +57 -0
- package/commands/superpowers.md +1 -1
- package/hooks/companion-preference.mjs +31 -19
- package/hooks/gateguard.mjs +59 -25
- package/hooks/hook-pack.mjs +110 -0
- package/hooks/recall-briefing.mjs +167 -0
- package/lib/gateguard-state.mjs +62 -13
- package/lib/goal-state.mjs +13 -9
- package/lib/hook-pack-gate.mjs +65 -0
- package/lib/install-targets.mjs +121 -0
- package/lib/plan-review-packet.mjs +96 -0
- package/lib/plugin-metadata.mjs +33 -7
- package/lib/recall-briefing.mjs +57 -0
- package/lib/recall-index.mjs +2 -2
- package/lib/skill-distill.mjs +141 -0
- package/llms.txt +2 -2
- package/package.json +6 -4
- package/plugins/beginner.json +3 -3
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
- package/plugins/continuous-improvement/README.md +1 -1
- package/plugins/continuous-improvement/agents/README.md +3 -3
- package/plugins/continuous-improvement/bin/mcp-server.mjs +66 -9
- package/plugins/continuous-improvement/commands/harvest.md +1 -1
- package/plugins/continuous-improvement/commands/model-forward.md +13 -0
- package/plugins/continuous-improvement/commands/production-readiness-review.md +53 -0
- package/plugins/continuous-improvement/commands/ship.md +57 -0
- package/plugins/continuous-improvement/commands/superpowers.md +1 -1
- package/plugins/continuous-improvement/hooks/companion-preference.mjs +31 -19
- package/plugins/continuous-improvement/hooks/gateguard.mjs +59 -25
- package/plugins/continuous-improvement/hooks/hook-pack.mjs +110 -0
- package/plugins/continuous-improvement/hooks/hooks.json +16 -1
- package/plugins/continuous-improvement/hooks/recall-briefing.mjs +167 -0
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +62 -13
- package/plugins/continuous-improvement/lib/goal-state.mjs +13 -9
- package/plugins/continuous-improvement/lib/hook-pack-gate.mjs +65 -0
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +33 -7
- package/plugins/continuous-improvement/lib/recall-briefing.mjs +57 -0
- package/plugins/continuous-improvement/lib/recall-index.mjs +2 -2
- package/plugins/continuous-improvement/lib/skill-distill.mjs +141 -0
- package/plugins/continuous-improvement/skills/README.md +1 -1
- package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +4 -4
- package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/handoff/SKILL.md +0 -1
- package/plugins/continuous-improvement/skills/model-forward/SKILL.md +44 -0
- package/plugins/continuous-improvement/skills/superpowers/SKILL.md +1 -1
- package/plugins/expert.json +6 -2
- package/skills/README.md +4 -2
- package/skills/gateguard.md +4 -4
- package/skills/goal-monitor.md +1 -1
- package/skills/handoff.md +0 -1
- package/skills/model-forward.md +44 -0
- package/skills/superpowers.md +1 -1
- package/lib/compound-engineering.mjs +0 -831
- package/lib/pm-skills.mjs +0 -1274
- package/lib/unified-plugin.mjs +0 -924
- package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +0 -108
- 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 →
|
|
16
|
-
*
|
|
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 :
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
158
|
+
emitAllowAndExit();
|
|
141
159
|
}
|
|
142
160
|
if (payload.tool_name !== "Skill") {
|
|
143
|
-
|
|
161
|
+
emitAllowAndExit();
|
|
144
162
|
}
|
|
145
163
|
const skill = payload.tool_input?.skill;
|
|
146
164
|
if (typeof skill !== "string" || skill.length === 0) {
|
|
147
|
-
|
|
165
|
+
emitAllowAndExit();
|
|
148
166
|
}
|
|
149
167
|
const normalized = normalizeSkill(skill);
|
|
150
168
|
const override = OVERRIDES[normalized];
|
|
151
169
|
if (!override) {
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
|
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.
|
|
121
|
-
` A.
|
|
122
|
-
` B.
|
|
123
|
-
|
|
124
|
-
"
|
|
125
|
-
"
|
|
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
|
|
143
|
-
"
|
|
144
|
-
"stuck-loop or rogue-agent clearance
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
emitAllow();
|
|
173
204
|
return;
|
|
174
205
|
}
|
|
175
206
|
if (gate === "destructive-bash") {
|
|
176
207
|
const cmd = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
177
|
-
|
|
208
|
+
emitDeny(buildDestructiveBashReason(cmd));
|
|
178
209
|
return;
|
|
179
210
|
}
|
|
180
|
-
// mutating-file
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|