continuous-improvement 3.9.2 → 3.12.3
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 +95 -0
- package/LICENSE +21 -21
- package/QUICKSTART.md +13 -8
- package/README.md +94 -162
- package/SKILL.md +1 -1
- package/bin/analyze.sh +161 -161
- package/bin/backfill.mjs +19 -19
- package/bin/check-docs-substrings.mjs +73 -0
- package/bin/check-scripts-citation-drift.mjs +210 -0
- package/bin/check-skill-count.mjs +110 -0
- package/bin/check-test-imports-only.mjs +126 -0
- package/bin/check-tool-count.mjs +129 -0
- package/bin/companion-preference-status.mjs +231 -0
- package/bin/gateguard-clear.mjs +53 -0
- package/bin/generate-plugin-manifests.mjs +8 -1
- package/bin/harvest-friction.mjs +1 -1
- package/bin/hook-stats.mjs +21 -21
- package/bin/install.mjs +185 -28
- package/bin/mcp-server.mjs +216 -3
- package/bin/refresh-third-party.mjs +315 -313
- package/commands/audit.md +34 -0
- package/commands/companion-preference.md +58 -0
- package/commands/continuous-improvement.md +115 -115
- package/commands/dashboard.md +56 -56
- package/commands/discipline.md +14 -0
- package/commands/distill.md +39 -0
- package/commands/goal-check.md +53 -0
- package/commands/grill-me.md +40 -0
- package/commands/grill-with-docs.md +38 -0
- package/commands/handoff.md +42 -0
- package/commands/harvest.md +1 -1
- package/commands/proceed-with-the-recommendation.md +20 -0
- package/commands/recall.md +49 -0
- package/commands/reconcile.md +47 -0
- package/commands/seven-laws.md +16 -16
- package/commands/superpowers.md +3 -3
- package/commands/verify-install.md +55 -0
- package/commands/workspace-surface-audit.md +77 -77
- package/hooks/companion-preference.mjs +190 -0
- package/hooks/gateguard.mjs +72 -25
- package/hooks/goal-drift-stop.mjs +183 -0
- package/hooks/observe.sh +15 -1
- package/hooks/recall-briefing.mjs +167 -0
- package/hooks/route-prompt.mjs +180 -0
- package/hooks/route-table.json +35 -0
- package/hooks/session.sh +106 -106
- package/hooks/three-section-close.mjs +134 -132
- package/instinct-packs/go.json +58 -58
- package/instinct-packs/meta.json +16 -16
- package/instinct-packs/python.json +58 -58
- package/instinct-packs/react.json +58 -58
- package/lib/gateguard-state.mjs +54 -2
- package/lib/goal-drift-gate.mjs +50 -0
- package/lib/goal-state.mjs +285 -0
- package/lib/plugin-metadata.mjs +134 -15
- package/lib/recall-briefing.mjs +57 -0
- package/lib/recall-index.mjs +175 -0
- package/lib/skill-distill.mjs +222 -0
- package/llms.txt +2 -2
- package/package.json +12 -7
- package/plugins/beginner.json +9 -4
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
- package/plugins/continuous-improvement/LICENSE +21 -21
- package/plugins/continuous-improvement/agents/README.md +3 -3
- package/plugins/continuous-improvement/bin/backfill.mjs +19 -19
- package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
- package/plugins/continuous-improvement/bin/mcp-server.mjs +216 -3
- package/plugins/continuous-improvement/commands/audit.md +34 -0
- package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
- package/plugins/continuous-improvement/commands/continuous-improvement.md +115 -115
- package/plugins/continuous-improvement/commands/dashboard.md +56 -56
- package/plugins/continuous-improvement/commands/discipline.md +14 -0
- package/plugins/continuous-improvement/commands/distill.md +39 -0
- package/plugins/continuous-improvement/commands/goal-check.md +53 -0
- package/plugins/continuous-improvement/commands/grill-me.md +40 -0
- package/plugins/continuous-improvement/commands/grill-with-docs.md +38 -0
- package/plugins/continuous-improvement/commands/handoff.md +42 -0
- package/plugins/continuous-improvement/commands/harvest.md +1 -1
- package/plugins/continuous-improvement/commands/proceed-with-the-recommendation.md +20 -0
- package/plugins/continuous-improvement/commands/recall.md +49 -0
- package/plugins/continuous-improvement/commands/reconcile.md +47 -0
- package/plugins/continuous-improvement/commands/seven-laws.md +16 -16
- package/plugins/continuous-improvement/commands/superpowers.md +3 -3
- package/plugins/continuous-improvement/commands/verify-install.md +55 -0
- package/plugins/continuous-improvement/commands/workspace-surface-audit.md +77 -77
- package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
- package/plugins/continuous-improvement/hooks/gateguard.mjs +72 -25
- package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
- package/plugins/continuous-improvement/hooks/hooks.json +23 -2
- package/plugins/continuous-improvement/hooks/observe.sh +15 -1
- package/plugins/continuous-improvement/hooks/recall-briefing.mjs +167 -0
- package/plugins/continuous-improvement/hooks/route-prompt.mjs +180 -0
- package/plugins/continuous-improvement/hooks/route-table.json +35 -0
- package/plugins/continuous-improvement/hooks/session.sh +106 -106
- package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
- package/plugins/continuous-improvement/instinct-packs/go.json +58 -58
- package/plugins/continuous-improvement/instinct-packs/meta.json +16 -16
- package/plugins/continuous-improvement/instinct-packs/python.json +58 -58
- package/plugins/continuous-improvement/instinct-packs/react.json +58 -58
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +137 -0
- package/plugins/continuous-improvement/lib/goal-drift-gate.mjs +50 -0
- package/plugins/continuous-improvement/lib/goal-state.mjs +285 -0
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +134 -15
- package/plugins/continuous-improvement/lib/recall-briefing.mjs +57 -0
- package/plugins/continuous-improvement/lib/recall-index.mjs +175 -0
- package/plugins/continuous-improvement/lib/skill-distill.mjs +222 -0
- package/plugins/continuous-improvement/skills/README.md +8 -0
- package/plugins/continuous-improvement/skills/audit/SKILL.md +73 -0
- package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +13 -17
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +36 -17
- package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +81 -0
- package/plugins/continuous-improvement/skills/grill-me/SKILL.md +66 -0
- package/plugins/continuous-improvement/skills/grill-with-docs/SKILL.md +252 -0
- package/plugins/continuous-improvement/skills/handoff/SKILL.md +59 -0
- package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +82 -6
- package/plugins/continuous-improvement/skills/ralph/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/recall/SKILL.md +50 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +80 -0
- package/plugins/continuous-improvement/skills/recovery-classification/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/skill-distillation/SKILL.md +57 -0
- package/plugins/continuous-improvement/skills/state-reconciliation/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/strategic-compact/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/superpowers/SKILL.md +77 -3
- package/plugins/continuous-improvement/skills/tdd-workflow/SKILL.md +411 -411
- package/plugins/continuous-improvement/skills/token-budget-advisor/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +28 -20
- package/plugins/continuous-improvement/skills/wild-risa-balance/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +3 -2
- package/plugins/expert.json +28 -3
- package/skills/README.md +3 -0
- package/skills/audit.md +73 -0
- package/skills/deploy-receipt.md +13 -17
- package/skills/gateguard.md +36 -17
- package/skills/goal-monitor.md +81 -0
- package/skills/grill-me.md +66 -0
- package/skills/grill-with-docs.md +252 -0
- package/skills/handoff.md +59 -0
- package/skills/para-memory-files.md +1 -1
- package/skills/proceed-with-the-recommendation.md +82 -6
- package/skills/ralph.md +1 -0
- package/skills/recall.md +50 -0
- package/skills/reconcile.md +80 -0
- package/skills/recovery-classification.md +1 -0
- package/skills/safety-guard.md +1 -0
- package/skills/skill-distillation.md +57 -0
- package/skills/state-reconciliation.md +1 -0
- package/skills/strategic-compact.md +1 -0
- package/skills/superpowers.md +77 -3
- package/skills/tdd-workflow.md +411 -411
- package/skills/token-budget-advisor.md +1 -1
- package/skills/verification-loop.md +28 -20
- package/skills/wild-risa-balance.md +1 -0
- package/skills/workspace-surface-audit.md +1 -1
- package/skills/worktree-safety.md +3 -2
|
@@ -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
|
|
13
|
-
*
|
|
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,16 @@
|
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 {
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { MAX_CLEARED_FILES, canonicalizeFileKey, isCapReached, isFileCleared, loadState, markFileCleared, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
|
|
27
33
|
const TOOL_ROUTE = {
|
|
28
34
|
Read: "allow",
|
|
29
35
|
Grep: "allow",
|
|
@@ -71,30 +77,65 @@ function classifyTool(toolName, toolInput) {
|
|
|
71
77
|
}
|
|
72
78
|
return "allow";
|
|
73
79
|
}
|
|
74
|
-
function
|
|
80
|
+
function extractFilePaths(toolInput) {
|
|
75
81
|
if (typeof toolInput.file_path === "string")
|
|
76
|
-
return toolInput.file_path;
|
|
77
|
-
// MultiEdit V1: first edit's file_path is the canonical key.
|
|
82
|
+
return [toolInput.file_path];
|
|
78
83
|
if (Array.isArray(toolInput.edits) && toolInput.edits.length > 0) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
return toolInput.edits
|
|
85
|
+
.map((edit) => (edit && typeof edit.file_path === "string" ? edit.file_path : ""))
|
|
86
|
+
.filter((filePath) => filePath !== "");
|
|
82
87
|
}
|
|
83
88
|
if (typeof toolInput.command === "string")
|
|
84
|
-
return toolInput.command;
|
|
85
|
-
return
|
|
89
|
+
return [toolInput.command];
|
|
90
|
+
return [];
|
|
86
91
|
}
|
|
87
|
-
|
|
92
|
+
// The call site only reads this inside the block branch, where at least one
|
|
93
|
+
// path is uncleared; an all-cleared batch returns "" and is never consumed.
|
|
94
|
+
function firstUnclearedFilePath(toolInput, state) {
|
|
95
|
+
return extractFilePaths(toolInput).find((path) => !isFileCleared(state, path)) ?? "";
|
|
96
|
+
}
|
|
97
|
+
function countDistinctNewFilePaths(filePaths, state) {
|
|
98
|
+
return new Set(filePaths.filter((path) => !isFileCleared(state, path)).map((path) => canonicalizeFileKey(path))).size;
|
|
99
|
+
}
|
|
100
|
+
function formatFileTarget(filePaths) {
|
|
101
|
+
const nonEmpty = filePaths.filter((path) => path !== "");
|
|
102
|
+
if (nonEmpty.length === 0)
|
|
103
|
+
return "<unknown>";
|
|
104
|
+
if (nonEmpty.length === 1)
|
|
105
|
+
return nonEmpty[0];
|
|
106
|
+
return `${nonEmpty.length} files (${nonEmpty.join(", ")})`;
|
|
107
|
+
}
|
|
108
|
+
// Resolved from the hook's own location at load time. The block reason must
|
|
109
|
+
// print a command the agent can run in its OWN shell, where ${CLAUDE_PLUGIN_ROOT}
|
|
110
|
+
// is empty — that variable is only populated while this hook itself executes.
|
|
111
|
+
// bin/ is a sibling of hooks/ in both the repo layout and the plugin-bundle
|
|
112
|
+
// mirror, so this resolves correctly from either.
|
|
113
|
+
const CLEAR_CLI_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gateguard-clear.mjs");
|
|
114
|
+
// Quote a path for the printed clearance command. Forward slashes (node accepts
|
|
115
|
+
// them on every platform and they survive a Bash double-quote without escaping)
|
|
116
|
+
// inside JSON quotes — JSON.stringify still escapes any embedded quote, so a
|
|
117
|
+
// path like a"b.ts stays safe rather than collapsing into a bare-quoted token.
|
|
118
|
+
function quotePath(p) {
|
|
119
|
+
return JSON.stringify(p.replace(/\\/g, "/"));
|
|
120
|
+
}
|
|
121
|
+
function buildMutatingFileReason(toolName, filePaths, stateFilePath) {
|
|
122
|
+
const target = formatFileTarget(filePaths);
|
|
123
|
+
const pathList = filePaths.filter((p) => p !== "");
|
|
124
|
+
const quoted = (pathList.length > 0 ? pathList : [target]).map((p) => quotePath(p));
|
|
88
125
|
return [
|
|
89
|
-
`Before ${toolName === "Write" ? "creating" : "editing"} ${
|
|
126
|
+
`Before ${toolName === "Write" ? "creating" : "editing"} ${target}, present these facts:`,
|
|
90
127
|
"",
|
|
91
128
|
" 1. List ALL files that import/require this file (use Grep)",
|
|
92
129
|
" 2. List the public functions/classes affected by this change",
|
|
93
130
|
" 3. If this file reads/writes data files, show field names, structure, and date format",
|
|
94
131
|
" 4. Quote the user's current instruction verbatim",
|
|
95
132
|
"",
|
|
96
|
-
"
|
|
97
|
-
|
|
133
|
+
"Then clear the gate and retry the same call. Either route works:",
|
|
134
|
+
` A. Bash (always works — run verbatim): node ${quotePath(CLEAR_CLI_PATH)} --state ${quotePath(stateFilePath)} ${quoted.join(" ")}`,
|
|
135
|
+
` B. MCP tool (when the continuous-improvement server is connected): ci_gateguard_clear {file_paths: [${quoted.join(", ")}]}`,
|
|
136
|
+
" Both canonicalize paths — drive-letter case and separators don't matter.",
|
|
137
|
+
" (Harnesses that forward unknown tool params may instead retry the call with",
|
|
138
|
+
" `_gateguard_facts_presented: true`; Claude Code's strict schema rejects that, so use A or B.)",
|
|
98
139
|
].join("\n");
|
|
99
140
|
}
|
|
100
141
|
function buildDestructiveBashReason(command) {
|
|
@@ -110,7 +151,7 @@ function buildDestructiveBashReason(command) {
|
|
|
110
151
|
}
|
|
111
152
|
function buildCapReachedReason() {
|
|
112
153
|
return [
|
|
113
|
-
|
|
154
|
+
`Gateguard session clearance cap reached (${MAX_CLEARED_FILES} distinct files).`,
|
|
114
155
|
"Start a new Claude Code session to reset the gate. The cap exists to bound",
|
|
115
156
|
"stuck-loop or rogue-agent clearance from compounding within a single session.",
|
|
116
157
|
].join("\n");
|
|
@@ -150,22 +191,28 @@ function main() {
|
|
|
150
191
|
}
|
|
151
192
|
// mutating-file
|
|
152
193
|
const sessionDir = resolveSessionDir();
|
|
194
|
+
const stateFilePath = join(sessionDir, "gateguard-session.json");
|
|
153
195
|
const state = loadState(sessionDir);
|
|
154
|
-
const
|
|
196
|
+
const filePaths = extractFilePaths(toolInput);
|
|
197
|
+
const filePath = firstUnclearedFilePath(toolInput, state);
|
|
155
198
|
const factsFlagged = toolInput._gateguard_facts_presented === true;
|
|
156
|
-
const alreadyCleared =
|
|
199
|
+
const alreadyCleared = filePaths.length > 0 && filePaths.every((path) => isFileCleared(state, path));
|
|
200
|
+
const newFileCount = countDistinctNewFilePaths(filePaths, state);
|
|
157
201
|
if (!factsFlagged && !alreadyCleared) {
|
|
158
|
-
emit({ decision: "block", reason: buildMutatingFileReason(toolName, filePath) });
|
|
202
|
+
emit({ decision: "block", reason: buildMutatingFileReason(toolName, filePaths.length > 0 ? filePaths : [filePath], stateFilePath) });
|
|
159
203
|
return;
|
|
160
204
|
}
|
|
161
205
|
if (factsFlagged && !alreadyCleared) {
|
|
162
|
-
if (isCapReached(state)) {
|
|
206
|
+
if (isCapReached(state) || Object.keys(state.cleared_files).length + newFileCount > MAX_CLEARED_FILES) {
|
|
163
207
|
emit({ decision: "block", reason: buildCapReachedReason() });
|
|
164
208
|
return;
|
|
165
209
|
}
|
|
166
|
-
|
|
167
|
-
|
|
210
|
+
let nextState = state;
|
|
211
|
+
for (const path of filePaths) {
|
|
212
|
+
nextState = markFileCleared(nextState, path);
|
|
168
213
|
}
|
|
214
|
+
if (filePaths.length > 0)
|
|
215
|
+
saveState(sessionDir, nextState);
|
|
169
216
|
}
|
|
170
217
|
emit({ decision: "allow" });
|
|
171
218
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Gateguard fact-forcing PreToolUse, observation, session lifecycle,
|
|
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
|
{
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"type": "command",
|
|
14
|
-
"command": "
|
|
14
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/companion-preference.mjs\"",
|
|
15
15
|
"timeout": 5
|
|
16
16
|
}
|
|
17
17
|
]
|
|
@@ -28,6 +28,22 @@
|
|
|
28
28
|
]
|
|
29
29
|
}
|
|
30
30
|
],
|
|
31
|
+
"UserPromptSubmit": [
|
|
32
|
+
{
|
|
33
|
+
"hooks": [
|
|
34
|
+
{
|
|
35
|
+
"type": "command",
|
|
36
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/route-prompt.mjs\"",
|
|
37
|
+
"timeout": 5
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"type": "command",
|
|
41
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recall-briefing.mjs\"",
|
|
42
|
+
"timeout": 5
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
],
|
|
31
47
|
"SessionStart": [
|
|
32
48
|
{
|
|
33
49
|
"hooks": [
|
|
@@ -57,6 +73,11 @@
|
|
|
57
73
|
"type": "command",
|
|
58
74
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/three-section-close.mjs\"",
|
|
59
75
|
"timeout": 5
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"type": "command",
|
|
79
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/goal-drift-stop.mjs\"",
|
|
80
|
+
"timeout": 5
|
|
60
81
|
}
|
|
61
82
|
]
|
|
62
83
|
}
|
|
@@ -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
|
|
@@ -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
|
+
}
|