continuous-improvement 3.19.0 → 3.20.4

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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/QUICKSTART.md +1 -1
  3. package/README.md +3 -2
  4. package/bin/check-landing-version.mjs +63 -0
  5. package/bin/check-scripts-citation-drift.mjs +61 -13
  6. package/bin/generate-plugin-manifests.mjs +3 -0
  7. package/bin/install.mjs +19 -11
  8. package/commands/verify-install.md +1 -1
  9. package/hooks/gateguard.mjs +22 -3
  10. package/hooks/query-cost-nudge.mjs +114 -0
  11. package/hooks/typecheck-stop.mjs +2 -1
  12. package/lib/plugin-metadata.mjs +7 -2
  13. package/lib/query-cost-gate.mjs +53 -0
  14. package/package.json +5 -3
  15. package/plugins/beginner.json +1 -1
  16. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +1 -1
  17. package/plugins/continuous-improvement/.claude-plugin/plugin.json +1 -1
  18. package/plugins/continuous-improvement/README.md +1 -0
  19. package/plugins/continuous-improvement/commands/verify-install.md +1 -1
  20. package/plugins/continuous-improvement/hooks/gateguard.mjs +22 -3
  21. package/plugins/continuous-improvement/hooks/hooks.json +6 -1
  22. package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +114 -0
  23. package/plugins/continuous-improvement/hooks/typecheck-stop.mjs +2 -1
  24. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +7 -2
  25. package/plugins/continuous-improvement/lib/query-cost-gate.mjs +53 -0
  26. package/plugins/continuous-improvement/scripts/README.md +33 -0
  27. package/plugins/continuous-improvement/scripts/detect-deploy-target.sh +66 -0
  28. package/plugins/continuous-improvement/scripts/get-deployed-sha.sh +113 -0
  29. package/plugins/continuous-improvement/scripts/git-state-snapshot.sh +48 -0
  30. package/plugins/continuous-improvement/scripts/resolve-verify-ladder.mjs +241 -0
  31. package/plugins/continuous-improvement/scripts/route-recommendation.mjs +178 -0
  32. package/plugins/continuous-improvement/scripts/route-recommendation.routes.json +213 -0
  33. package/plugins/continuous-improvement/scripts/run-synthetic.mjs +298 -0
  34. package/plugins/continuous-improvement/scripts/scan-past-mistakes.mjs +285 -0
  35. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +2 -2
  36. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +2 -2
  37. package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +2 -2
  38. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +1 -1
  39. package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +5 -5
  40. package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
  41. package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +1 -1
  42. package/plugins/expert.json +1 -1
  43. package/scripts/README.md +33 -0
  44. package/scripts/detect-deploy-target.sh +66 -0
  45. package/scripts/get-deployed-sha.sh +113 -0
  46. package/scripts/git-state-snapshot.sh +48 -0
  47. package/scripts/resolve-verify-ladder.mjs +241 -0
  48. package/scripts/route-recommendation.mjs +178 -0
  49. package/scripts/route-recommendation.routes.json +213 -0
  50. package/scripts/run-synthetic.mjs +298 -0
  51. package/scripts/scan-past-mistakes.mjs +285 -0
  52. package/skills/deploy-receipt.md +2 -2
  53. package/skills/gateguard.md +2 -2
  54. package/skills/proceed-with-the-recommendation.md +2 -2
  55. package/skills/reconcile.md +1 -1
  56. package/skills/verification-loop.md +5 -5
  57. package/skills/workspace-surface-audit.md +1 -1
  58. package/skills/worktree-safety.md +1 -1
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "continuous-improvement",
10
10
  "description": "The persistent-memory and runtime-discipline layer for Claude Code. It remembers the corrections you already gave, grounds every edit in real facts before it lands, and — through the Mulahazah engine — turns each fix into a reusable instinct, so a lesson learned once is applied automatically next time with no re-teaching. Built on the 7 Laws of AI Agent Discipline (research, plan, verify, reflect, learn) and shipped as 27 bundled skills, instinct-aware hooks, an MCP toolset for recall and reflection, and a GitHub Action transcript linter that feeds real work history back into sharper instincts.",
11
- "version": "3.19.0",
11
+ "version": "3.20.4",
12
12
  "source": "./plugins/continuous-improvement",
13
13
  "author": {
14
14
  "name": "naimkatiman"
package/QUICKSTART.md CHANGED
@@ -139,7 +139,7 @@ npx continuous-improvement install --mode expert
139
139
  npx continuous-improvement install --pack react # optional: react | python | go | meta
140
140
  ```
141
141
 
142
- Preconditions: Node 18 / 20 / 22, plus bash on Windows (Git Bash or WSL — `hooks/observe.sh` is a bash script). See [README.md § Expert](README.md#expert--adds-mcp-server-observation-hooks-and-instinct-packs) for the full preconditions and troubleshooting matrix.
142
+ Preconditions: Node 18 / 20 / 22, plus Git Bash on Windows (`hooks/observe.sh` is a Bash script). WSL Bash launched from native Windows cannot resolve the `C:/...` hook paths written by the installer. See [README.md § Expert](README.md#expert--adds-mcp-server-observation-hooks-and-instinct-packs) for the full preconditions and troubleshooting matrix.
143
143
 
144
144
  Verify with `/dashboard` — you should see instinct health and observation count.
145
145
 
package/README.md CHANGED
@@ -137,7 +137,7 @@ V1 honest limitations: the runtime gate is honor-system once the agent flips `_g
137
137
 
138
138
  Pick this if you want the MCP tools (19 of them, including `ci_plan_init` / `ci_plan_status` for `task_plan.md`-style planning), the session hooks that feed Mulahazah, and starter packs.
139
139
 
140
- Preconditions: Node 18 / 20 / 22, plus bash on Windows (Git Bash or WSL — `hooks/observe.sh` is a bash script and silently no-ops without it). **`jq` is no longer required**: as of v3.6.0, `observe.sh` prefers the Node observer (`bin/observe.mjs`) which writes the rich event schema natively without external dependencies. The bash thin-schema path is kept as a two-phase shim, so legacy installs that have not re-run `npx continuous-improvement install` since v3.5.x will still degrade silently without `jq` (`winget install jqlang.jq` on Windows, `brew install jq` on macOS, `apt install jq` on Debian/Ubuntu) — re-running the installer is the cleaner fix and removes the dependency entirely. See [CHANGELOG.md](CHANGELOG.md) `[3.6.0]` for the migration details.
140
+ Preconditions: Node 18 / 20 / 22, plus Git Bash on Windows (`hooks/observe.sh` is a Bash script). WSL Bash launched from native Windows cannot resolve the `C:/...` hook paths written by the installer. **`jq` is no longer required**: as of v3.6.0, `observe.sh` prefers the Node observer (`bin/observe.mjs`) which writes the rich event schema natively without external dependencies. The bash thin-schema path is kept as a two-phase shim, so legacy installs that have not re-run `npx continuous-improvement install` since v3.5.x will still degrade silently without `jq` (`winget install jqlang.jq` on Windows, `brew install jq` on macOS, `apt install jq` on Debian/Ubuntu) — re-running the installer is the cleaner fix and removes the dependency entirely. See [CHANGELOG.md](CHANGELOG.md) `[3.6.0]` for the migration details.
141
141
 
142
142
  ```bash
143
143
  npx continuous-improvement install --mode expert
@@ -158,7 +158,7 @@ Three failures account for nearly every install support thread. Try them in orde
158
158
  | Symptom | Real cause | Fix |
159
159
  |---|---|---|
160
160
  | `/discipline` says "command not recognized" right after `/plugin install` | Slash commands load on session start; the marketplace did pick the plugin up | Quit and reopen Claude Code, then run `/discipline` again |
161
- | Expert mode hooks never fire on Windows | `observe.sh` is bash; PowerShell silently no-ops on it | Install Git Bash (or WSL) and re-run `npx continuous-improvement install --mode expert` |
161
+ | Expert mode hooks never fire on Windows | WSL Bash cannot resolve the native `C:/...` hook paths | Install Git Bash, ensure its `bin` directory precedes `C:\Windows\System32` on PATH, then re-run `npx continuous-improvement install --mode expert` |
162
162
  | `/plugin marketplace add ...` returned nothing visible | Marketplace add was silent; the plugin is not yet selected | Run `/plugin install continuous-improvement@continuous-improvement` to select and activate it |
163
163
 
164
164
  If none of those apply, paste the output of `npx continuous-improvement install` into a GitHub issue — that surface logs every step.
@@ -177,6 +177,7 @@ The framework has documented operator-level modes that change hook behavior with
177
177
  | `CLAUDE_TYPECHECK_GATE` | `hooks/typecheck-stop.mjs` (a `Stop` hook) runs the project typecheck (the `typecheck` npm script, else a local `tsc --noEmit`) on changed TS files at turn end and feeds a failure back to the model. `off` (default) is a no-op — the global advisory `typecheck-changed.sh` stays the default layer; `warn` prints a one-line stderr notice; `block` re-prompts with the tsc output so a headless/autonomous `-p` loop fixes its own type errors before ending the turn. Skips non-TS repos and turns where no TS file changed; fails open on any error or timeout. | bash/zsh: `export CLAUDE_TYPECHECK_GATE=block` in `~/.bashrc` / `~/.zshrc`. PowerShell: `$env:CLAUDE_TYPECHECK_GATE='block'` (session) or `[Environment]::SetEnvironmentVariable('CLAUDE_TYPECHECK_GATE','block','User')` (persistent). |
178
178
  | `CLAUDE_RECALL_BRIEFING=1` | `hooks/recall-briefing.mjs` (a UserPromptSubmit hook) makes episodic memory proactive: on the first substantive prompt of a session it searches this project's past observations (BM25) and injects a one-time `<system-reminder>` with the most relevant prior activity, so the agent reuses a past fix instead of re-deriving it. Opt-in and default off; it is an amplifier, never a gate — it cannot block a prompt and fails open. The `ci_recall` MCP tool stays available for explicit, deeper searches. | bash/zsh: `export CLAUDE_RECALL_BRIEFING=1` in `~/.bashrc` / `~/.zshrc`. PowerShell: `$env:CLAUDE_RECALL_BRIEFING=1` (session) or `[Environment]::SetEnvironmentVariable('CLAUDE_RECALL_BRIEFING','1','User')` (persistent). |
179
179
  | `CLAUDE_WORKFLOW_DISTILL_NUDGE=on` | `hooks/workflow-distill.mjs` (a `Stop` hook) closes the orchestration-to-memory loop: when a native Workflow run's output then passed a verify in the same session, it prints a one-line stderr nudge to run the `ci_distill_from_workflow` MCP tool, so an expensive multi-agent run leaves a durable Mulahazah draft instinct instead of evaporating. `on` enables it; default (unset or any other value) is off. Opt-in amplifier, never a gate — it cannot block the Stop, dedupes per run, and fails open. | bash/zsh: `export CLAUDE_WORKFLOW_DISTILL_NUDGE=on` in `~/.bashrc` / `~/.zshrc`. PowerShell: `$env:CLAUDE_WORKFLOW_DISTILL_NUDGE='on'` (session) or `[Environment]::SetEnvironmentVariable('CLAUDE_WORKFLOW_DISTILL_NUDGE','on','User')` (persistent). |
180
+ | `CLAUDE_QUERY_COST_NUDGE=on` | `hooks/query-cost-nudge.mjs` (a `Stop` hook) guards against surprise DB bills: when the working tree has changed DB/query files (`.sql`, `.prisma`, `migrations/`, `/db/`, `schema.*`, `drizzle`) at turn end, it injects a once-per-session `additionalContext` reminder to run a D1-aware cost audit — dispatch the `database-reviewer` agent or check EXPLAIN QUERY PLAN, index coverage, N+1, and D1 `rows_read` billing before finishing. `on` enables it; default (unset) is off. Opt-in amplifier, never a gate; dedupes per session and fails open. | bash/zsh: `export CLAUDE_QUERY_COST_NUDGE=on` in `~/.bashrc` / `~/.zshrc`. PowerShell: `$env:CLAUDE_QUERY_COST_NUDGE='on'` (session) or `[Environment]::SetEnvironmentVariable('CLAUDE_QUERY_COST_NUDGE','on','User')` (persistent). |
180
181
 
181
182
  </details>
182
183
 
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { argv, cwd } from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+ const VERSION_RE = "([0-9]+\\.[0-9]+\\.[0-9]+)";
7
+ function firstMatch(content, pattern) {
8
+ return pattern.exec(content)?.[1] ?? null;
9
+ }
10
+ export function checkLandingVersion(repoRoot) {
11
+ const packagePath = resolve(repoRoot, "package.json");
12
+ const landingPath = resolve(repoRoot, "docs", "landing", "index.html");
13
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
14
+ const packageVersion = typeof packageJson.version === "string" ? packageJson.version : "";
15
+ const landing = readFileSync(landingPath, "utf8");
16
+ const markers = {
17
+ badge: firstMatch(landing, new RegExp(`<span\\b[^>]*class=["'][^"']*\\bver\\b[^"']*["'][^>]*>\\s*v${VERSION_RE}\\s*</span>`, "i")),
18
+ hero: firstMatch(landing, new RegExp(`<span\\b[^>]*class=["'][^"']*\\bkicker\\b[^"']*["'][^>]*>[\\s\\S]*?\\bREV\\s+${VERSION_RE}[\\s\\S]*?</span>`, "i")),
19
+ current: firstMatch(landing, new RegExp(`<span\\b[^>]*class=["'][^"']*\\bv\\b[^"']*["'][^>]*>\\s*v${VERSION_RE}\\s*</span>\\s*<span\\b[^>]*class=["'][^"']*\\bk\\b[^"']*["'][^>]*>\\s*Current rev\\s*</span>`, "i")),
20
+ footer: firstMatch(landing, new RegExp(`<span\\b[^>]*class=["'][^"']*\\bfoot-doc\\b[^"']*["'][^>]*>[\\s\\S]*?\\bREV\\s+${VERSION_RE}[\\s\\S]*?</span>`, "i")),
21
+ };
22
+ const errors = [];
23
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(packageVersion)) {
24
+ errors.push(`package.json has an invalid version: ${packageVersion || "<missing>"}`);
25
+ }
26
+ const missing = Object.entries(markers)
27
+ .filter(([, version]) => version === null)
28
+ .map(([name]) => name);
29
+ if (missing.length > 0) {
30
+ errors.push(`landing release marker(s) missing: ${missing.join(", ")}`);
31
+ }
32
+ const landingVersions = [...new Set(Object.values(markers).filter((value) => value !== null))];
33
+ if (landingVersions.length > 1) {
34
+ errors.push(`landing release markers are mixed: ${landingVersions.join(", ")}`);
35
+ }
36
+ for (const [name, version] of Object.entries(markers)) {
37
+ if (version !== null && version !== packageVersion) {
38
+ errors.push(`${name} marker is ${version}; package.json is ${packageVersion}`);
39
+ }
40
+ }
41
+ return { ok: errors.length === 0, packageVersion, markers, errors };
42
+ }
43
+ function main() {
44
+ const repoRoot = argv[2] ?? cwd();
45
+ try {
46
+ const result = checkLandingVersion(repoRoot);
47
+ if (result.ok) {
48
+ console.log(`OK landing-version: all 4 markers match package.json ${result.packageVersion}.`);
49
+ return;
50
+ }
51
+ console.error(`FAIL landing-version: ${result.errors.join("; ")}`);
52
+ process.exitCode = 1;
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ console.error(`FAIL landing-version: ${message}`);
57
+ process.exitCode = 1;
58
+ }
59
+ }
60
+ const entryPath = argv[1] ? resolve(argv[1]) : "";
61
+ if (entryPath === fileURLToPath(import.meta.url)) {
62
+ main();
63
+ }
@@ -11,7 +11,7 @@
11
11
  * Three sides of the contract are enforced as one invariant:
12
12
  *
13
13
  * Side A — every file in scripts/ (excluding README.md) must appear in the
14
- * Inventory table's Script column.
14
+ * Inventory table, and every inventoried helper must exist on disk.
15
15
  * Side B — for every Inventory row, every `skills/<name>.md` token in the
16
16
  * Cited by cell must reference a file whose body contains the
17
17
  * literal substring `scripts/<script-filename>` for at least one
@@ -85,30 +85,67 @@ function listSkillFiles(repoRoot) {
85
85
  const dir = join(repoRoot, SKILLS_DIR);
86
86
  if (!existsSync(dir) || !statSync(dir).isDirectory())
87
87
  return [];
88
- return readdirSync(dir)
89
- .filter((entry) => entry.endsWith(".md"))
90
- .map((entry) => `${SKILLS_DIR}/${entry}`)
91
- .sort();
88
+ const skills = [];
89
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
90
+ if (entry.isFile() && entry.name.endsWith(".md")) {
91
+ skills.push(`${SKILLS_DIR}/${entry.name}`);
92
+ continue;
93
+ }
94
+ if (entry.isDirectory() &&
95
+ existsSync(join(dir, entry.name, "SKILL.md"))) {
96
+ skills.push(`${SKILLS_DIR}/${entry.name}.md`);
97
+ }
98
+ }
99
+ return skills.sort();
100
+ }
101
+ function resolveSkillFile(repoRoot, skillPath) {
102
+ const flatPath = join(repoRoot, skillPath);
103
+ if (existsSync(flatPath))
104
+ return flatPath;
105
+ const bundledSkill = /^skills\/([^/]+)\.md$/.exec(skillPath);
106
+ if (bundledSkill?.[1]) {
107
+ return join(repoRoot, SKILLS_DIR, bundledSkill[1], "SKILL.md");
108
+ }
109
+ return flatPath;
92
110
  }
93
111
  function toPosix(relPath) {
94
112
  return relPath.split(sep).join(posix.sep);
95
113
  }
96
114
  export function checkRepo(repoRoot) {
115
+ const structure = [];
97
116
  const sideA = [];
98
117
  const sideB = [];
99
118
  const sideC = [];
100
- const scriptFiles = listScriptFiles(repoRoot);
119
+ const scriptsPath = join(repoRoot, SCRIPTS_DIR);
101
120
  const readmePath = join(repoRoot, SCRIPTS_README);
102
- const readmeContent = existsSync(readmePath) ? readFileSync(readmePath, "utf8") : "";
121
+ const scriptsDirectoryExists = existsSync(scriptsPath) && statSync(scriptsPath).isDirectory();
122
+ const readmeExists = scriptsDirectoryExists && existsSync(readmePath);
123
+ const readmeContent = readmeExists ? readFileSync(readmePath, "utf8") : "";
103
124
  const rows = parseInventoryTable(readmeContent);
125
+ if (!scriptsDirectoryExists) {
126
+ structure.push("scripts/ directory is missing");
127
+ }
128
+ else if (!readmeExists) {
129
+ structure.push("scripts/README.md inventory is missing");
130
+ }
131
+ else if (rows.length === 0) {
132
+ structure.push("scripts/README.md inventory has no rows");
133
+ }
134
+ const scriptFiles = listScriptFiles(repoRoot);
104
135
  const inventoryScripts = new Set();
105
136
  for (const row of rows) {
106
137
  for (const s of row.scripts)
107
138
  inventoryScripts.add(s);
108
139
  }
140
+ const scriptFileSet = new Set(scriptFiles);
109
141
  for (const file of scriptFiles) {
110
142
  if (!inventoryScripts.has(file)) {
111
- sideA.push(file);
143
+ sideA.push(`${file} — file is missing from the inventory`);
144
+ }
145
+ }
146
+ for (const inventoryScript of inventoryScripts) {
147
+ if (!scriptFileSet.has(inventoryScript)) {
148
+ sideA.push(`${inventoryScript} — inventoried helper file is missing`);
112
149
  }
113
150
  }
114
151
  let citationCount = 0;
@@ -122,7 +159,7 @@ export function checkRepo(repoRoot) {
122
159
  for (const row of rows) {
123
160
  for (const skillPath of row.skills) {
124
161
  citationCount += 1;
125
- const fullSkill = join(repoRoot, skillPath);
162
+ const fullSkill = resolveSkillFile(repoRoot, skillPath);
126
163
  let skillBody = "";
127
164
  try {
128
165
  skillBody = readFileSync(fullSkill, "utf8");
@@ -142,7 +179,7 @@ export function checkRepo(repoRoot) {
142
179
  for (const skillPath of skillFiles) {
143
180
  let body = "";
144
181
  try {
145
- body = readFileSync(join(repoRoot, skillPath), "utf8");
182
+ body = readFileSync(resolveSkillFile(repoRoot, skillPath), "utf8");
146
183
  }
147
184
  catch {
148
185
  continue;
@@ -160,6 +197,7 @@ export function checkRepo(repoRoot) {
160
197
  }
161
198
  }
162
199
  return {
200
+ structure,
163
201
  sideA,
164
202
  sideB,
165
203
  sideC,
@@ -170,15 +208,25 @@ export function checkRepo(repoRoot) {
170
208
  function main() {
171
209
  const repoRoot = argv[2] ?? cwd();
172
210
  const result = checkRepo(repoRoot);
173
- const totalViolations = result.sideA.length + result.sideB.length + result.sideC.length;
211
+ const totalViolations = result.structure.length +
212
+ result.sideA.length +
213
+ result.sideB.length +
214
+ result.sideC.length;
174
215
  if (totalViolations === 0) {
175
216
  console.log(`OK scripts-citation-drift: ${result.scriptCount} script(s), ${result.citationCount} skill citation(s), all three sides reconciled.`);
176
217
  exit(0);
177
218
  }
178
219
  console.error(`FAIL scripts-citation-drift: ${totalViolations} drift(s) across the inventory-citation contract.`);
220
+ if (result.structure.length > 0) {
221
+ console.error("");
222
+ console.error("Contract structure:");
223
+ for (const v of result.structure) {
224
+ console.error(` ${toPosix(v)}`);
225
+ }
226
+ }
179
227
  if (result.sideA.length > 0) {
180
228
  console.error("");
181
- console.error("Side A — script files missing from scripts/README.md inventory table:");
229
+ console.error("Side A — scripts/ files and inventory rows do not match:");
182
230
  for (const v of result.sideA) {
183
231
  console.error(` ${toPosix(v)}`);
184
232
  }
@@ -198,7 +246,7 @@ function main() {
198
246
  }
199
247
  }
200
248
  console.error("");
201
- console.error("Fix: update scripts/README.md inventory (Side A/C) or correct the skill citation (Side B).");
249
+ console.error("Fix: restore scripts/ and its inventory, update Side A/C, or correct the Side B skill citation.");
202
250
  exit(1);
203
251
  }
204
252
  const scriptPathFromArgv = argv[1];
@@ -82,6 +82,7 @@ async function writePluginBundleReadme() {
82
82
  "- `commands/`",
83
83
  "- `agents/` — `code-reviewer`, `security-auditor`, `test-engineer` personas (auto-discovered Claude Code subagents; pattern from addy/agent-skills)",
84
84
  "- `hooks/`",
85
+ "- `scripts/`",
85
86
  "- `bin/mcp-server.mjs`",
86
87
  "- `bin/observe.mjs`",
87
88
  "- `bin/backfill.mjs`",
@@ -140,6 +141,7 @@ async function writePluginBundle() {
140
141
  copyDirectory(join(REPO_ROOT, "commands"), join(PLUGIN_BUNDLE_DIR, "commands")),
141
142
  copyDirectory(join(REPO_ROOT, "agents"), join(PLUGIN_BUNDLE_DIR, "agents")),
142
143
  copyDirectory(join(REPO_ROOT, "hooks"), join(PLUGIN_BUNDLE_DIR, "hooks")),
144
+ copyDirectory(join(REPO_ROOT, "scripts"), join(PLUGIN_BUNDLE_DIR, "scripts")),
143
145
  copyDirectory(join(REPO_ROOT, "instinct-packs"), join(PLUGIN_BUNDLE_DIR, "instinct-packs")),
144
146
  copyDirectory(join(REPO_ROOT, "templates", "planning-with-files"), join(PLUGIN_BUNDLE_DIR, "templates", "planning-with-files")),
145
147
  copyFileTo(join(REPO_ROOT, "bin", "mcp-server.mjs"), join(PLUGIN_BUNDLE_DIR, "bin", "mcp-server.mjs")),
@@ -157,6 +159,7 @@ async function writePluginBundle() {
157
159
  copyFileTo(join(REPO_ROOT, "lib", "recall-briefing.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "recall-briefing.mjs")),
158
160
  copyFileTo(join(REPO_ROOT, "lib", "skill-distill.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "skill-distill.mjs")),
159
161
  copyFileTo(join(REPO_ROOT, "lib", "typecheck-gate.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "typecheck-gate.mjs")),
162
+ copyFileTo(join(REPO_ROOT, "lib", "query-cost-gate.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "query-cost-gate.mjs")),
160
163
  copyFileTo(join(REPO_ROOT, "LICENSE"), join(PLUGIN_BUNDLE_DIR, "LICENSE")),
161
164
  writePluginBundleReadme(),
162
165
  ]);
package/bin/install.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  * npx continuous-improvement install --uninstall # remove everything
11
11
  */
12
12
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
13
- import { execSync } from "node:child_process";
13
+ import { execFileSync as runFileSync, execSync } from "node:child_process";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
@@ -84,20 +84,25 @@ function readJsonFile(filePath) {
84
84
  return null;
85
85
  }
86
86
  }
87
- // The observation hooks (hooks/observe.sh, hooks/session.sh) are bash scripts.
88
- // On Windows without Git Bash / WSL the hook commands written into settings.json
89
- // silently no-op, so the user finishes install thinking observation capture is
90
- // live when it never fires. Refuse the install with one actionable line instead.
91
- function assertBashAvailableOnWindows() {
87
+ // The observation hooks are Bash scripts, and the native Windows settings use
88
+ // forward-slash drive paths such as C:/Users/... . Git Bash resolves that form;
89
+ // WSL's C:\Windows\System32\bash.exe does not. Probe the real packaged hook path
90
+ // instead of accepting any executable that happens to answer `bash --version`.
91
+ function assertBashCanReadHookOnWindows() {
92
92
  if (process.platform !== "win32")
93
93
  return;
94
+ const hookSource = toBashPath(join(REPO_ROOT, "hooks", "observe.sh"));
94
95
  try {
95
- execSync("bash --version", { stdio: "ignore" });
96
+ runFileSync("bash", ["-c", 'test -r "$CONTINUOUS_IMPROVEMENT_HOOK_SOURCE"'], {
97
+ env: { ...process.env, CONTINUOUS_IMPROVEMENT_HOOK_SOURCE: hookSource },
98
+ stdio: "ignore",
99
+ });
96
100
  }
97
101
  catch {
98
- console.error(" ✗ Install refused: the observation hooks (hooks/observe.sh, hooks/session.sh) " +
99
- "are bash scripts, but `bash --version` is not on PATH. Install Git Bash or WSL, " +
100
- "reopen your shell, and re-run see README → Troubleshooting install.");
102
+ console.error(` ✗ Install refused: Bash on PATH cannot read the Windows hook path ${hookSource}. ` +
103
+ "Install Git Bash and ensure its bin directory comes before " +
104
+ "C:\\Windows\\System32 on PATH, then reopen your shell and re-run. " +
105
+ "See README > Troubleshooting install.");
101
106
  process.exit(1);
102
107
  }
103
108
  }
@@ -612,6 +617,10 @@ function installNonClaudeTargets(targetIds) {
612
617
  for (const note of notes)
613
618
  console.log(` ℹ ${note}`);
614
619
  }
620
+ // A mixed target install can write non-Claude rule files below. Validate the
621
+ // Claude hook runtime first so an incompatible Bash cannot leave a partial install.
622
+ if (requestedTargets.includes("claude"))
623
+ assertBashCanReadHookOnWindows();
615
624
  const nonClaudeTargets = requestedTargets.filter((targetId) => targetId !== "claude");
616
625
  if (nonClaudeTargets.length > 0) {
617
626
  console.log("\ncontinuous-improvement multi-platform install\n");
@@ -625,7 +634,6 @@ console.log(`
625
634
  continuous-improvement (mode: ${INSTALL_MODE})
626
635
  Research → Plan → Execute → Verify → Reflect → Learn → Iterate
627
636
  `);
628
- assertBashAvailableOnWindows();
629
637
  warnOnMarketplaceCollision();
630
638
  console.log("Installing to Claude Code...\n");
631
639
  const installed = installSkill() ? 1 : 0;
@@ -41,7 +41,7 @@ The observation hook appends one row per tool call to
41
41
 
42
42
  - If it exists and has at least one row — capture is recording. Record `observe: ✓`.
43
43
  - If it is missing or empty — record `observe: ✗ (observation hook not recording —
44
- on Windows confirm Git Bash / WSL is installed, then re-run the installer)`.
44
+ on Windows confirm Git Bash is installed before WSL Bash on PATH, then re-run the installer)`.
45
45
 
46
46
  ## Report
47
47
 
@@ -210,6 +210,16 @@ function buildMutatingFileReason(toolName, filePaths, stateFilePath) {
210
210
  " `_gateguard_facts_presented: true`; Claude Code's strict schema rejects that, so use A or B.)",
211
211
  ].join("\n");
212
212
  }
213
+ function isGitBraceSelector(value) {
214
+ const selector = value.trim();
215
+ if (/^(?:u|upstream|push|-?\d+)$/i.test(selector))
216
+ return true;
217
+ if (/^(?:now|today|yesterday|tomorrow|noon|midnight|tea)$/i.test(selector))
218
+ return true;
219
+ if (/^\d{4}-\d{1,2}-\d{1,2}(?:[ T].*)?$/.test(selector))
220
+ return true;
221
+ return !selector.startsWith("#") && /(?:^|[.\s])ago$/i.test(selector);
222
+ }
213
223
  function findUnquotedBraceRef(command) {
214
224
  let quote = null;
215
225
  for (let i = 0; i < command.length; i++) {
@@ -224,17 +234,26 @@ function findUnquotedBraceRef(command) {
224
234
  continue;
225
235
  }
226
236
  if (ch === "@" && command[i + 1] === "{") {
237
+ const braceEnd = command.indexOf("}", i + 2);
238
+ if (braceEnd === -1)
239
+ continue;
240
+ const selector = command.slice(i + 2, braceEnd);
241
+ // Deny only recognized Git selector grammar. PowerShell hashtables can
242
+ // contain comments, quoted braces, or arbitrary key expressions.
243
+ if (!isGitBraceSelector(selector)) {
244
+ i = braceEnd;
245
+ continue;
246
+ }
227
247
  // Expand to the whitespace-delimited word that carries this @{ ref, then
228
248
  // single-quote that whole word in the suggested fix.
229
249
  let wordStart = i;
230
250
  while (wordStart > 0 && !/\s/.test(command[wordStart - 1]))
231
251
  wordStart--;
232
- let wordEnd = i;
252
+ let wordEnd = braceEnd + 1;
233
253
  while (wordEnd < command.length && !/\s/.test(command[wordEnd]))
234
254
  wordEnd++;
235
255
  const word = command.slice(wordStart, wordEnd);
236
- const braceEnd = command.indexOf("}", i);
237
- const ref = braceEnd === -1 ? command.slice(i, wordEnd) : command.slice(i, braceEnd + 1);
256
+ const ref = command.slice(i, braceEnd + 1);
238
257
  const fixed = `${command.slice(0, wordStart)}'${word}'${command.slice(wordEnd)}`;
239
258
  return { ref, fixed };
240
259
  }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * query-cost-nudge.mts — Stop hook that nudges a D1-aware query-cost audit when
4
+ * the working tree has changed DB/query files at turn end (RISA 5 / G5).
5
+ *
6
+ * The `database-reviewer` agent already carries the cost checklist, but nothing
7
+ * dispatched it on DB edits, so cost regressions (the surprise-D1-bill class)
8
+ * shipped unaudited. This injects a once-per-session reminder via Stop
9
+ * `additionalContext` — a Stop hook (not PostToolUse) because only PreToolUse /
10
+ * UserPromptSubmit / Stop / SubagentStop support additionalContext; PostToolUse
11
+ * could only emit a user-facing systemMessage that never reaches the model.
12
+ *
13
+ * Opt-in via CLAUDE_QUERY_COST_NUDGE=on (default off). Once-per-session dedup
14
+ * keyed on the Stop stdin session_id (falls back to a per-day key) — without it,
15
+ * additionalContext "keeps the turn going" and would re-fire every turn while
16
+ * the DB files stay dirty. Never blocks; fail-open on any error.
17
+ */
18
+ import { execFileSync } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import { buildQueryCostReminder, changedQueryPaths, parseChangedFiles, resolveQueryCostNudge, } from "../lib/query-cost-gate.mjs";
24
+ function readStdin() {
25
+ try {
26
+ return readFileSync(0, "utf8");
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ }
32
+ function resolveHome() {
33
+ return process.env.HOME || process.env.USERPROFILE || homedir();
34
+ }
35
+ function resolveProjectRoot() {
36
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR;
37
+ if (fromEnv && fromEnv.trim())
38
+ return fromEnv.trim();
39
+ try {
40
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
41
+ encoding: "utf8",
42
+ stdio: ["ignore", "pipe", "ignore"],
43
+ }).trim();
44
+ if (root)
45
+ return root;
46
+ }
47
+ catch {
48
+ // not in a git repo
49
+ }
50
+ return "global";
51
+ }
52
+ function collectChangedFiles(root) {
53
+ const run = (args) => {
54
+ try {
55
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
56
+ }
57
+ catch {
58
+ return "";
59
+ }
60
+ };
61
+ return [
62
+ ...parseChangedFiles(run(["diff", "--name-only", "--diff-filter=ACMR"])),
63
+ ...parseChangedFiles(run(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])),
64
+ ...parseChangedFiles(run(["ls-files", "--others", "--exclude-standard"])),
65
+ ];
66
+ }
67
+ // Marker key: the sanitized session_id when present (the normal case), else a
68
+ // per-day key so a missing id self-heals daily instead of blocking forever.
69
+ function markerKey(sessionId) {
70
+ const sanitized = (sessionId ?? "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
71
+ return sanitized || `day-${new Date().toISOString().slice(0, 10)}`;
72
+ }
73
+ function markerPath(home, projectRoot, sessionId) {
74
+ const hash = createHash("sha256").update(projectRoot).digest("hex").slice(0, 12);
75
+ return join(home, ".claude", "instincts", hash, "query-cost-nudge", `${markerKey(sessionId)}.nudged`);
76
+ }
77
+ function main() {
78
+ if (resolveQueryCostNudge(process.env.CLAUDE_QUERY_COST_NUDGE) === "off")
79
+ return;
80
+ let sessionId;
81
+ try {
82
+ const payload = JSON.parse(readStdin());
83
+ if (typeof payload.session_id === "string")
84
+ sessionId = payload.session_id;
85
+ }
86
+ catch {
87
+ // stdin optional — proceed without a session id (per-day dedup)
88
+ }
89
+ const root = resolveProjectRoot();
90
+ if (root === "global")
91
+ return; // no repo → nothing to diff
92
+ const changed = changedQueryPaths(collectChangedFiles(root));
93
+ if (changed.length === 0)
94
+ return;
95
+ const marker = markerPath(resolveHome(), root, sessionId);
96
+ try {
97
+ if (existsSync(marker))
98
+ return; // already nudged this session
99
+ mkdirSync(dirname(marker), { recursive: true });
100
+ writeFileSync(marker, `${new Date().toISOString()}\n`);
101
+ }
102
+ catch {
103
+ // if the marker can't be written, nudge anyway (no dedup) rather than stay silent
104
+ }
105
+ process.stdout.write(`${JSON.stringify({
106
+ hookSpecificOutput: { hookEventName: "Stop", additionalContext: buildQueryCostReminder(changed) },
107
+ })}\n`);
108
+ }
109
+ try {
110
+ main();
111
+ }
112
+ catch {
113
+ // fail open — never trap a turn on a hook bug
114
+ }
@@ -51,7 +51,8 @@ function collectChangedFiles(root) {
51
51
  };
52
52
  const unstaged = run(["diff", "--name-only", "--diff-filter=ACMR"]);
53
53
  const staged = run(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
54
- return [...parseChangedFiles(unstaged), ...parseChangedFiles(staged)];
54
+ const untracked = run(["ls-files", "--others", "--exclude-standard"]);
55
+ return [...parseChangedFiles(unstaged), ...parseChangedFiles(staged), ...parseChangedFiles(untracked)];
55
56
  }
56
57
  function hasNpmTypecheckScript(root) {
57
58
  try {
@@ -505,6 +505,11 @@ export function getPluginHooksConfig() {
505
505
  // internal timeout it fails open (allow) rather than blocking.
506
506
  timeout: 30,
507
507
  };
508
+ const queryCostNudgeCommand = {
509
+ type: "command",
510
+ command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/query-cost-nudge.mjs\"",
511
+ timeout: 5,
512
+ };
508
513
  const routePromptCommand = {
509
514
  type: "command",
510
515
  command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/route-prompt.mjs\"",
@@ -516,7 +521,7 @@ export function getPluginHooksConfig() {
516
521
  timeout: 5,
517
522
  };
518
523
  return {
519
- description: "Gateguard fact-forcing PreToolUse, companion-preference enforcement, observation, session lifecycle, 3-section-close discipline, goal-drift Stop gate, opt-in workflow-distill Stop nudge, opt-in typecheck Stop gate, and UserPromptSubmit lazy-routing plus opt-in proactive recall-briefing hooks for continuous-improvement.",
524
+ description: "Gateguard fact-forcing PreToolUse, companion-preference enforcement, observation, session lifecycle, 3-section-close discipline, goal-drift Stop gate, opt-in workflow-distill Stop nudge, opt-in typecheck Stop gate, opt-in query-cost Stop nudge, and UserPromptSubmit lazy-routing plus opt-in proactive recall-briefing hooks for continuous-improvement.",
520
525
  hooks: {
521
526
  // gateguard runs FIRST on PreToolUse so its block decision short-circuits
522
527
  // before companion-preference sees the call. companion-preference runs
@@ -540,7 +545,7 @@ export function getPluginHooksConfig() {
540
545
  UserPromptSubmit: [{ hooks: [routePromptCommand, recallBriefingCommand] }],
541
546
  SessionStart: [{ hooks: [sessionCommand] }],
542
547
  SessionEnd: [{ hooks: [sessionCommand] }],
543
- Stop: [{ hooks: [threeSectionCloseCommand, goalDriftStopCommand, workflowDistillCommand, typecheckStopCommand] }],
548
+ Stop: [{ hooks: [threeSectionCloseCommand, goalDriftStopCommand, workflowDistillCommand, typecheckStopCommand, queryCostNudgeCommand] }],
544
549
  },
545
550
  };
546
551
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure helpers for the query-cost nudge Stop hook (RISA 5 / G5).
3
+ *
4
+ * The hook (src/hooks/query-cost-nudge.mts) does the I/O — git, the per-session
5
+ * dedup marker, emitting the reminder. Everything here is pure and unit-tested.
6
+ *
7
+ * Opt-in via CLAUDE_QUERY_COST_NUDGE=on (default off). When on and the working
8
+ * tree has changed DB/query files at turn end, the hook injects a once-per-session
9
+ * reminder (via Stop `additionalContext`) to run a D1-aware cost audit before
10
+ * finishing — the surprise-D1-bill class the insights flagged. Never blocks.
11
+ */
12
+ export function resolveQueryCostNudge(raw) {
13
+ return (raw ?? "").trim().toLowerCase() === "on" ? "on" : "off";
14
+ }
15
+ // DB / query source paths. Case-insensitive; the caller passes forward-slash
16
+ // paths straight from `git diff --name-only` (git always uses forward slashes).
17
+ const QUERY_PATH_RES = [
18
+ /\.sql$/i,
19
+ /\.prisma$/i,
20
+ /(^|\/)migrations?\//i,
21
+ /(^|\/)db\//i,
22
+ /(^|\/)schema\.(ts|js|mjs|cjs|cts|mts|prisma|sql)$/i,
23
+ /drizzle/i,
24
+ ];
25
+ export function isQueryPath(filePath) {
26
+ const path = (filePath ?? "").replace(/\\/g, "/").trim();
27
+ if (path === "")
28
+ return false;
29
+ return QUERY_PATH_RES.some((re) => re.test(path));
30
+ }
31
+ export function parseChangedFiles(gitOutput) {
32
+ return (gitOutput ?? "")
33
+ .split(/\r?\n/)
34
+ .map((line) => line.trim())
35
+ .filter((line) => line.length > 0);
36
+ }
37
+ export function changedQueryPaths(files) {
38
+ return files.filter((file) => isQueryPath(file));
39
+ }
40
+ export function buildQueryCostReminder(paths) {
41
+ const list = paths.slice(0, 8).map((path) => path.replace(/\\/g, "/")).join(", ");
42
+ return [
43
+ "<system-reminder>",
44
+ `Query-cost check: you changed DB/query file(s) this session (${list}).`,
45
+ "Before finishing, audit cost — dispatch the database-reviewer agent, or check directly:",
46
+ " - EXPLAIN QUERY PLAN each new/changed query; no full table SCAN on a hot path.",
47
+ " - Every WHERE / JOIN / ORDER BY column is index-covered; no N+1 loops.",
48
+ " - D1: rows_read is billed per row SCANNED (not returned) — add covering indexes and cache hot reads (KV) instead of re-querying.",
49
+ " - No unbounded query — add LIMIT / pagination.",
50
+ "One-time per-session reminder (CLAUDE_QUERY_COST_NUDGE).",
51
+ "</system-reminder>",
52
+ ].join("\n");
53
+ }