continuous-improvement 3.23.0 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +17 -0
  3. package/QUICKSTART.md +19 -20
  4. package/README.md +71 -18
  5. package/SKILL.md +4 -0
  6. package/bin/companion-preference-status.mjs +2 -5
  7. package/bin/generate-plugin-manifests.mjs +21 -2
  8. package/bin/harvest-friction.mjs +10 -8
  9. package/bin/install.mjs +4 -14
  10. package/bin/mcp-server.mjs +4 -9
  11. package/bin/observe.mjs +3 -3
  12. package/bin/reconcile-instinct-hashes.mjs +226 -0
  13. package/commands/discipline.md +5 -2
  14. package/commands/reconcile.md +1 -1
  15. package/commands/superpowers.md +1 -1
  16. package/commands/verify-install.md +8 -3
  17. package/hooks/companion-preference.mjs +2 -5
  18. package/hooks/config-guard.mjs +94 -0
  19. package/hooks/gateguard.mjs +39 -39
  20. package/hooks/goal-drift-stop.mjs +2 -2
  21. package/hooks/query-cost-nudge.mjs +2 -2
  22. package/hooks/recall-briefing.mjs +2 -2
  23. package/hooks/route-prompt.mjs +2 -5
  24. package/hooks/session.mjs +2 -2
  25. package/hooks/workflow-distill.mjs +2 -2
  26. package/lib/config-guard-gate.mjs +243 -0
  27. package/lib/destructive-bash.mjs +216 -0
  28. package/lib/gateguard-state.mjs +5 -1
  29. package/lib/plugin-metadata.mjs +12 -1
  30. package/lib/skill-catalog.mjs +169 -0
  31. package/llms.txt +12 -1
  32. package/package.json +2 -2
  33. package/plugins/beginner.json +1 -1
  34. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
  35. package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
  36. package/plugins/continuous-improvement/README.md +1 -2
  37. package/plugins/continuous-improvement/bin/mcp-server.mjs +4 -9
  38. package/plugins/continuous-improvement/bin/observe.mjs +3 -3
  39. package/plugins/continuous-improvement/commands/discipline.md +5 -2
  40. package/plugins/continuous-improvement/commands/reconcile.md +1 -1
  41. package/plugins/continuous-improvement/commands/superpowers.md +1 -1
  42. package/plugins/continuous-improvement/commands/verify-install.md +8 -3
  43. package/plugins/continuous-improvement/hooks/companion-preference.mjs +2 -5
  44. package/plugins/continuous-improvement/hooks/config-guard.mjs +94 -0
  45. package/plugins/continuous-improvement/hooks/gateguard.mjs +39 -39
  46. package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +2 -2
  47. package/plugins/continuous-improvement/hooks/hooks.json +10 -0
  48. package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +2 -2
  49. package/plugins/continuous-improvement/hooks/recall-briefing.mjs +2 -2
  50. package/plugins/continuous-improvement/hooks/route-prompt.mjs +2 -5
  51. package/plugins/continuous-improvement/hooks/session.mjs +2 -2
  52. package/plugins/continuous-improvement/hooks/workflow-distill.mjs +2 -2
  53. package/plugins/continuous-improvement/lib/config-guard-gate.mjs +243 -0
  54. package/plugins/continuous-improvement/lib/destructive-bash.mjs +216 -0
  55. package/plugins/continuous-improvement/lib/gateguard-state.mjs +5 -1
  56. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +12 -1
  57. package/plugins/continuous-improvement/skills/README.md +0 -1
  58. package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +4 -0
  59. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +1 -1
  60. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +17 -2
  61. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +0 -1
  62. package/plugins/expert.json +1 -1
  63. package/skills/README.md +1 -2
  64. package/skills/deploy-receipt.md +1 -1
  65. package/skills/gateguard.md +17 -2
  66. package/skills/reconcile.md +0 -1
  67. package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +0 -77
  68. package/skills/safety-guard.md +0 -77
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Merge instinct buckets that are the same project under different path
4
+ * spellings (C:/ vs c:/ vs C:\). observe/gateguard now hash the canonical
5
+ * root; this CLI copies history into that hash and leaves an alias marker
6
+ * on the old dir. Idempotent. Never deletes a directory.
7
+ *
8
+ * Usage:
9
+ * node bin/reconcile-instinct-hashes.mjs --dry-run
10
+ * node bin/reconcile-instinct-hashes.mjs --apply
11
+ * node bin/reconcile-instinct-hashes.mjs --apply --only 3ef4426c6e15 --only 137f2f54ec70
12
+ */
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { argv, exit } from "node:process";
16
+ import { canonicalizeProjectRoot, hashProjectRoot, resolveInstinctsRoot } from "../lib/gateguard-state.mjs";
17
+ function countJsonl(path) {
18
+ if (!existsSync(path))
19
+ return 0;
20
+ return readFileSync(path, "utf8").split(/\n/).filter((line) => line.trim() !== "").length;
21
+ }
22
+ function isoNow() {
23
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
24
+ }
25
+ export function discoverGroups(instinctsRoot) {
26
+ const groups = new Map();
27
+ let entries;
28
+ try {
29
+ entries = readdirSync(instinctsRoot);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ for (const name of entries) {
35
+ if (name === "global")
36
+ continue;
37
+ const dir = join(instinctsRoot, name);
38
+ try {
39
+ if (!statSync(dir).isDirectory())
40
+ continue;
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ if (existsSync(join(dir, "alias.json")) && !existsSync(join(dir, "observations.jsonl"))) {
46
+ continue;
47
+ }
48
+ const projectPath = join(dir, "project.json");
49
+ if (!existsSync(projectPath))
50
+ continue;
51
+ let project;
52
+ try {
53
+ project = JSON.parse(readFileSync(projectPath, "utf8"));
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (!project.root)
59
+ continue;
60
+ const canonicalRoot = canonicalizeProjectRoot(project.root);
61
+ const canonicalHash = hashProjectRoot(canonicalRoot);
62
+ const bucket = {
63
+ hash: name,
64
+ root: project.root,
65
+ name: project.name ?? name,
66
+ rows: countJsonl(join(dir, "observations.jsonl")),
67
+ };
68
+ const existing = groups.get(canonicalHash);
69
+ if (existing) {
70
+ existing.members.push(bucket);
71
+ }
72
+ else {
73
+ groups.set(canonicalHash, {
74
+ canonicalHash,
75
+ canonicalRoot,
76
+ name: project.name ?? name,
77
+ members: [bucket],
78
+ });
79
+ }
80
+ }
81
+ return [...groups.values()].filter((group) => group.members.some((member) => member.hash !== group.canonicalHash && member.rows > 0));
82
+ }
83
+ function parseTs(line) {
84
+ try {
85
+ const parsed = JSON.parse(line);
86
+ const value = Date.parse(parsed.ts ?? "");
87
+ return Number.isFinite(value) ? value : 0;
88
+ }
89
+ catch {
90
+ return 0;
91
+ }
92
+ }
93
+ export function mergeJsonl(sources) {
94
+ const rows = [];
95
+ const seen = new Set();
96
+ for (const file of sources) {
97
+ if (!existsSync(file))
98
+ continue;
99
+ for (const line of readFileSync(file, "utf8").split(/\n/)) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed || seen.has(trimmed))
102
+ continue;
103
+ seen.add(trimmed);
104
+ rows.push(trimmed);
105
+ }
106
+ }
107
+ rows.sort((a, b) => parseTs(a) - parseTs(b) || a.localeCompare(b));
108
+ return rows.length === 0 ? "" : `${rows.join("\n")}\n`;
109
+ }
110
+ export function selectGroups(groups, only) {
111
+ if (only.length === 0)
112
+ return groups;
113
+ const wanted = new Set(only);
114
+ return groups.filter((group) => wanted.has(group.canonicalHash) || group.members.some((member) => wanted.has(member.hash)));
115
+ }
116
+ export function applyGroup(instinctsRoot, group) {
117
+ const destDir = join(instinctsRoot, group.canonicalHash);
118
+ mkdirSync(destDir, { recursive: true });
119
+ const destObs = join(destDir, "observations.jsonl");
120
+ const sources = group.members
121
+ .map((member) => join(instinctsRoot, member.hash, "observations.jsonl"))
122
+ .filter((path) => existsSync(path));
123
+ const merged = mergeJsonl(sources);
124
+ writeFileSync(destObs, merged, "utf8");
125
+ let createdAt = isoNow();
126
+ const destProject = join(destDir, "project.json");
127
+ if (existsSync(destProject)) {
128
+ try {
129
+ const existing = JSON.parse(readFileSync(destProject, "utf8"));
130
+ if (existing.created_at)
131
+ createdAt = existing.created_at;
132
+ }
133
+ catch {
134
+ // rewrite below
135
+ }
136
+ }
137
+ writeFileSync(destProject, `${JSON.stringify({
138
+ id: group.canonicalHash,
139
+ name: group.name,
140
+ root: group.canonicalRoot,
141
+ created_at: createdAt,
142
+ })}\n`, "utf8");
143
+ const aliases = [];
144
+ for (const member of group.members) {
145
+ if (member.hash === group.canonicalHash)
146
+ continue;
147
+ const srcDir = join(instinctsRoot, member.hash);
148
+ try {
149
+ for (const file of readdirSync(srcDir)) {
150
+ if (!file.endsWith(".yaml"))
151
+ continue;
152
+ const dest = join(destDir, file);
153
+ if (!existsSync(dest))
154
+ copyFileSync(join(srcDir, file), dest);
155
+ }
156
+ }
157
+ catch {
158
+ // missing dir is non-fatal
159
+ }
160
+ const srcObs = join(srcDir, "observations.jsonl");
161
+ if (existsSync(srcObs)) {
162
+ renameSync(srcObs, join(srcDir, `observations.migrated-to-${group.canonicalHash}.jsonl`));
163
+ }
164
+ writeFileSync(join(srcDir, "alias.json"), `${JSON.stringify({
165
+ canonical: group.canonicalHash,
166
+ canonical_root: group.canonicalRoot,
167
+ migrated_at: isoNow(),
168
+ rows: member.rows,
169
+ })}\n`, "utf8");
170
+ aliases.push(member.hash);
171
+ }
172
+ const copiedRows = merged === "" ? 0 : merged.trim().split("\n").length;
173
+ return { copiedRows, aliases };
174
+ }
175
+ function printPlan(groups) {
176
+ if (groups.length === 0) {
177
+ console.log("No alias observation buckets to merge.");
178
+ return;
179
+ }
180
+ console.log(`Alias groups: ${groups.length}`);
181
+ for (const group of groups) {
182
+ console.log(` ${group.name} → ${group.canonicalHash} (${group.canonicalRoot})`);
183
+ for (const member of group.members) {
184
+ const mark = member.hash === group.canonicalHash ? "canonical" : "alias";
185
+ console.log(` ${member.hash} ${member.rows} rows ${mark} root=${member.root}`);
186
+ }
187
+ }
188
+ }
189
+ function parseOnly(args) {
190
+ const only = [];
191
+ for (let i = 0; i < args.length; i += 1) {
192
+ if (args[i] === "--only" && args[i + 1]) {
193
+ only.push(args[i + 1]);
194
+ i += 1;
195
+ }
196
+ }
197
+ return only;
198
+ }
199
+ function main() {
200
+ const args = argv.slice(2);
201
+ const apply = args.includes("--apply");
202
+ const dryRun = args.includes("--dry-run") || !apply;
203
+ const instinctsRoot = resolveInstinctsRoot();
204
+ const groups = selectGroups(discoverGroups(instinctsRoot), parseOnly(args));
205
+ printPlan(groups);
206
+ if (dryRun) {
207
+ if (groups.length > 0)
208
+ console.log("\nRe-run with --apply to merge.");
209
+ return;
210
+ }
211
+ for (const group of groups) {
212
+ const result = applyGroup(instinctsRoot, group);
213
+ console.log(`merged ${result.copiedRows} rows into ${group.canonicalHash}; aliases ${result.aliases.join(",") || "(none)"}`);
214
+ }
215
+ }
216
+ const invokedDirectly = argv[1]?.endsWith("reconcile-instinct-hashes.mjs");
217
+ if (invokedDirectly) {
218
+ try {
219
+ main();
220
+ }
221
+ catch (error) {
222
+ const message = error instanceof Error ? error.message : String(error);
223
+ console.error(message);
224
+ exit(1);
225
+ }
226
+ }
@@ -19,6 +19,8 @@ Print this card and check yourself against each law.
19
19
  | 6 | **Iterate One Change** | Am I changing one thing at a time? | "And also..." |
20
20
  | 7 | **Learn From Every Session** | Did I capture this as an instinct? | "Next time I'll..." |
21
21
 
22
+ Read the seven as three moments around every act. **Before** (Laws 1, 2): set the terms. **During** (Laws 3, 6): watch yourself. **After** (Laws 4, 5, 7): settle the account and carry it forward. Every check is an audit you run on yourself; every red flag is you hoping instead. The sentence this comes from, and its sources: `docs/philosophy.md`.
23
+
22
24
  ## Operator Stakes
23
25
 
24
26
  The Laws above are the *how*. These five principles are the *why*: code ships from your account, the incident lands on your pager, the bill hits your budget. Each one pairs with the Law that prevents it from going wrong.
@@ -31,7 +33,7 @@ The Laws above are the *how*. These five principles are the *why*: code ships fr
31
33
  | 4 | **Problem framing** | Builds the websocket chat the ticket asked for | Finds out users wanted faster support replies, not chat | 1 |
32
34
  | 5 | **Constraints management** | Calls the $0.02/image model on every upload | Does the math, adds client-side validation + caching + cheaper triage model | 2 |
33
35
 
34
- Code is a liability, not an asset. Speed without these five turns into someone else's incident at 3am — except the someone is you.
36
+ Code is a liability, not an asset. Speed without these five turns into someone else's incident at 3am — except the someone is you. The other half of the why is not fear: the session ends and the context is gone, so the only work that survives is what you verified and wrote down for the one who comes after, whether that is tomorrow's session or the engineer who inherits the repo.
35
37
 
36
38
  ## Goal-Driven Execution maps onto the Laws
37
39
 
@@ -61,5 +63,6 @@ Before saying "Done", verify ALL:
61
63
  - [ ] I checked the **actual** result (not assumed)
62
64
  - [ ] Build passes
63
65
  - [ ] I can explain the change in one sentence
66
+ - [ ] For each item above I checked, not hoped
64
67
 
65
- If you're skipping a step, that's the step you need most.
68
+ If you're skipping a step, that's the step you need most. The step you skip is the one you are hoping through.
@@ -95,7 +95,7 @@ git branch -d <type>/<slug> # delete the merged feature branch (safe
95
95
  ## Pairs with
96
96
 
97
97
  - **`reconcile`** skill — the discipline this command runs.
98
- - **`gateguard`** / **`safety-guard`** runtime + destructive-op guardrails.
98
+ - **`gateguard`** the runtime gate for mutating tool calls and destructive shell.
99
99
  - **`recall`** — recall whether the same git op failed here before.
100
100
  - **`audit`** — the loop that often produces the fix `/reconcile` then ships.
101
101
  - **`/ship`** — the TDD-gated single-defect variant; `commit-commands:commit-push-pr` is the external-plugin equivalent of the commit → PR tail.
@@ -13,7 +13,7 @@ The 7 Laws define *what* discipline must be applied. `/superpowers` decides *whi
13
13
 
14
14
  | Source | Where it lives | Examples of what it routes to |
15
15
  |---|---|---|
16
- | `continuous-improvement` (this plugin) | bundled — always present | `gateguard` (Law 1), `tdd-workflow` (Law 3+4), `verification-loop` (Law 4), `wild-risa-balance` (Law 2), `safety-guard` (Law 3), `proceed-with-the-recommendation` (all 7), `ralph` (Law 6), `workspace-surface-audit` (Law 1) |
16
+ | `continuous-improvement` (this plugin) | bundled — always present | `gateguard` (Law 1), `tdd-workflow` (Law 3+4), `verification-loop` (Law 4), `wild-risa-balance` (Law 2), `proceed-with-the-recommendation` (all 7), `ralph` (Law 6), `workspace-surface-audit` (Law 1) |
17
17
  | `obra/superpowers` (Jesse Vincent) | vendored at `third-party/superpowers/`, pinned SHA `f2cbfbe` (v5.1.0) | `superpowers:brainstorming`, `:writing-plans`, `:executing-plans`, `:test-driven-development`, `:systematic-debugging`, `:requesting-code-review`, `:receiving-code-review`, `:verification-before-completion`, `:dispatching-parallel-agents`, `:using-git-worktrees`, `:finishing-a-development-branch`, `:subagent-driven-development`, `:writing-skills`, `:using-superpowers` |
18
18
  | `addyosmani/agent-skills` | vendored at `third-party/addy-agent-skills/`, pinned SHA `742dca5` (v1.0.0) | `spec-driven-development`, `source-driven-development`, `context-engineering`, `idea-refine`, `incremental-implementation`, `code-review-and-quality`, `code-simplification`, `security-and-hardening`, `debugging-and-error-recovery`, `performance-optimization`, `api-and-interface-design`, `frontend-ui-engineering`, `browser-testing-with-devtools`, `ci-cd-and-automation`, `deprecation-and-migration`, `documentation-and-adrs`, `git-workflow-and-versioning`, `planning-and-task-breakdown`, `shipping-and-launch` |
19
19
  | `ruflo-swarm` (ruvnet) | vendored at `third-party/ruflo-swarm/`, pinned SHA `addb5cd` (v0.2.0) | `swarm-init`, `monitor-stream`; `swarm_*` and `agent_*` MCP tools; `/swarm`, `/watch` |
@@ -29,9 +29,14 @@ with the text `probe`) **without presenting any research first**.
29
29
 
30
30
  - If the hook **blocks** the write with a fact-list reason — the runtime layer is
31
31
  wired. Record `gateguard: ✓`. Do not retry the write; the block is the pass.
32
- - If the write **goes through** with no pause — the hook did not load. Record
33
- `gateguard: (hooks/gateguard.mjs not wired see README Troubleshooting install)`.
34
- Delete the probe file if it was created.
32
+ - If the write **goes through** with no pause — either the hook did not load, or
33
+ `CI_GATEGUARD_EXCLUDE` is set to a fragment that matches the probe path (a
34
+ catch-all such as `/` or `.` matches every path and switches the file gate off;
35
+ the hook prints a one-line stderr notice when an exclusion fires). Run
36
+ `echo "$CI_GATEGUARD_EXCLUDE"` first. If it is empty, record
37
+ `gateguard: ✗ (hooks/gateguard.mjs not wired — see README → Troubleshooting install)`;
38
+ if it is set, record `gateguard: ✗ (excluded by CI_GATEGUARD_EXCLUDE=<value>; unset it or
39
+ narrow the fragment)`. Delete the probe file if it was created.
35
40
 
36
41
  ## Check 3 — observation capture recording
37
42
 
@@ -30,10 +30,10 @@
30
30
  * suite or a follow-up audit walks the table against this map.
31
31
  */
32
32
  import { execFileSync } from "node:child_process";
33
- import { createHash } from "node:crypto";
34
33
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
35
34
  import { homedir } from "node:os";
36
35
  import { join } from "node:path";
36
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
37
37
  const OVERRIDES = {
38
38
  "tdd-workflow": {
39
39
  companion: "superpowers:test-driven-development",
@@ -125,10 +125,7 @@ function resolveProjectRoot() {
125
125
  return "global";
126
126
  }
127
127
  function telemetryPath(home) {
128
- const hash = createHash("sha256")
129
- .update(resolveProjectRoot())
130
- .digest("hex")
131
- .slice(0, 12);
128
+ const hash = hashProjectRoot(resolveProjectRoot());
132
129
  return join(home, ".claude", "instincts", hash, "companion-preference.jsonl");
133
130
  }
134
131
  /**
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime PreToolUse config-guard hook.
4
+ *
5
+ * Stdin : JSON { tool_name, tool_input }
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
+ * Exit : 0 always (decision is in stdout; fail-open on any error).
11
+ *
12
+ * Guards the files that wire the guardrails: `.claude/settings*.json`,
13
+ * `.mcp.json`, `hooks.json`, `.claude/hooks/`, `.claude/plugins/`,
14
+ * `.claude-plugin/`, and the `claude plugin|mcp|config` CLI forms that edit
15
+ * them. Pure logic in ../lib/config-guard-gate.mjs.
16
+ *
17
+ * Mode via CI_CONFIG_GUARD: "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
+ * One-call bypass: CI_CONFIG_GUARD_ALLOW=true allows the call and says so.
22
+ *
23
+ * Registered with a tool matcher (Bash|Edit|MultiEdit|Write|NotebookEdit) so
24
+ * read-only tools never spawn it. No network. No git. Fail-open.
25
+ */
26
+ import { readFileSync } from "node:fs";
27
+ import { classifyMutation, decide, parseMode } from "../lib/config-guard-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 buildReason(toolName, hit, mode) {
45
+ const what = hit.via === "claude-cli"
46
+ ? `\`${hit.target}\` edits the plugin / MCP / settings configuration`
47
+ : `${toolName} would modify ${hit.target} (matches "${hit.pattern}")`;
48
+ return [
49
+ `config-guard: ${what}, one of the files that wires the guardrails (settings, MCP config, hooks, installed plugins).`,
50
+ "If this is intended, rerun this one call with CI_CONFIG_GUARD_ALLOW=true, or set CI_CONFIG_GUARD=off for the session.",
51
+ mode === "block"
52
+ ? "You are seeing a deny because CI_CONFIG_GUARD=block; the default is warn."
53
+ : "This is a warning (CI_CONFIG_GUARD=warn, the default); set CI_CONFIG_GUARD=block to deny instead.",
54
+ ].join("\n");
55
+ }
56
+ function main() {
57
+ const mode = parseMode(process.env.CI_CONFIG_GUARD);
58
+ if (mode === "off")
59
+ return;
60
+ const payload = safeJsonParse(readStdinSync());
61
+ if (!payload || typeof payload !== "object")
62
+ return;
63
+ const obj = payload;
64
+ const toolName = typeof obj.tool_name === "string" ? obj.tool_name : "";
65
+ const toolInput = obj.tool_input && typeof obj.tool_input === "object" ? obj.tool_input : {};
66
+ const hit = classifyMutation(toolName, toolInput);
67
+ if (!hit)
68
+ return;
69
+ if (String(process.env.CI_CONFIG_GUARD_ALLOW ?? "").trim().toLowerCase() === "true") {
70
+ process.stderr.write(`[continuous-improvement] config-guard: CI_CONFIG_GUARD_ALLOW=true let ${toolName} touch ${hit.target} on this call.\n`);
71
+ return;
72
+ }
73
+ const decision = decide(mode, true, buildReason(toolName, hit, mode));
74
+ if (decision.action === "block") {
75
+ process.stdout.write(`${JSON.stringify({
76
+ hookSpecificOutput: {
77
+ hookEventName: "PreToolUse",
78
+ permissionDecision: "deny",
79
+ permissionDecisionReason: decision.reason,
80
+ },
81
+ })}\n`);
82
+ return;
83
+ }
84
+ if (decision.action === "warn") {
85
+ process.stderr.write(`[continuous-improvement] ${decision.reason.split("\n")[0]}\n`);
86
+ }
87
+ }
88
+ try {
89
+ main();
90
+ }
91
+ catch {
92
+ // fail-open: never block a session on a hook error
93
+ }
94
+ process.exit(0);
@@ -35,6 +35,7 @@
35
35
  import { readFileSync } from "node:fs";
36
36
  import { dirname, join } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
+ import { classifyDestructiveBash } from "../lib/destructive-bash.mjs";
38
39
  import { MAX_CLEARED_FILES, canonicalizeFileKey, canonicalizeProjectRoot, isCapReached, isFileCleared, loadState, markFileCleared, resolveProjectRoot, resolveSessionDir, saveState, } from "../lib/gateguard-state.mjs";
39
40
  const TOOL_ROUTE = {
40
41
  Read: "allow",
@@ -48,42 +49,14 @@ const TOOL_ROUTE = {
48
49
  NotebookEdit: "mutating-file",
49
50
  Bash: "allow",
50
51
  };
51
- const DESTRUCTIVE_PATTERNS = [
52
- "rm -rf",
53
- "rm -fr",
54
- "git reset --hard",
55
- "git push --force",
56
- "git push -f",
57
- "--force-with-lease",
58
- "git branch -D",
59
- "drop table",
60
- "drop database",
61
- "drop schema",
62
- "truncate ",
63
- "mkfs",
64
- "dd if=",
65
- "format ",
66
- "rmdir /s",
67
- "del /f /q",
68
- "del /q /f",
69
- "Remove-Item -Recurse",
70
- "Remove-Item -Force",
71
- ];
72
- // Flags whose VALUE is human prose (a commit message, a PR body) or a filename —
73
- // never a command to execute. Their contents must not trip the destructive scan:
74
- // `git commit -m "drop the stale format helper"` and `gh pr create --body "…"`
75
- // were stranding finished work on their own wording. `-c` is deliberately
76
- // EXCLUDED — `bash -c "rm -rf /"` carries a real command and must still gate.
77
- const MESSAGE_FLAG_RE = /(^|\s)(-m|--message|-F|--file|--body|--body-file|--title|--notes|-C|--reuse-message)(=|\s+)('[^']*'|"[^"]*"|\S+)/g;
78
- // Blank the value of every message/body flag so only executable command syntax
79
- // remains for the destructive-pattern scan. The flag itself is preserved so a
80
- // flag like `-F` never accidentally merges with its neighbours.
81
- function stripMessageArgs(command) {
82
- return command.replace(MESSAGE_FLAG_RE, (_match, lead, flag) => `${lead}${flag} `);
83
- }
52
+ // The destructive-Bash classifier lives in lib/destructive-bash.mjs: structured
53
+ // rules (flag order and spelling do not matter: `rm -r -f`, `git clean -fdx`,
54
+ // `git checkout -- .`, `git restore .`, `find -delete`, `git push +ref`,
55
+ // `git stash drop`) plus the original substring list as the fallback. The
56
+ // message-flag carve-out (`git commit -m "…"`) lives there too. Each rule has
57
+ // a stable id the deny reason prints, so a block is explainable.
84
58
  function isDestructiveBash(command) {
85
- const lower = stripMessageArgs(command).toLowerCase();
86
- return DESTRUCTIVE_PATTERNS.some((p) => lower.includes(p.toLowerCase()));
59
+ return classifyDestructiveBash(command).destructive;
87
60
  }
88
61
  function classifyTool(toolName, toolInput) {
89
62
  const route = TOOL_ROUTE[toolName] ?? "allow";
@@ -119,12 +92,34 @@ const EXCLUDE_FRAGMENTS = String(process.env.CI_GATEGUARD_EXCLUDE ?? "")
119
92
  .split(",")
120
93
  .map((fragment) => fragment.trim().replace(/\\/g, "/").toLowerCase())
121
94
  .filter((fragment) => fragment !== "");
122
- function isExcludedPath(filePath) {
95
+ function matchedExcludeFragment(filePath) {
123
96
  if (EXCLUDE_FRAGMENTS.length === 0 || typeof filePath !== "string" || filePath === "") {
124
- return false;
97
+ return null;
125
98
  }
126
99
  const normalized = filePath.replace(/\\/g, "/").toLowerCase();
127
- return EXCLUDE_FRAGMENTS.some((fragment) => normalized.includes(fragment));
100
+ return EXCLUDE_FRAGMENTS.find((fragment) => normalized.includes(fragment)) ?? null;
101
+ }
102
+ function isExcludedPath(filePath) {
103
+ return matchedExcludeFragment(filePath) !== null;
104
+ }
105
+ // A fragment every path contains ("/", ".", any single character) is not an
106
+ // exclusion, it is an off switch for the whole file gate. It is still honoured —
107
+ // the operator set it — but silently honouring it is how a host ends up with the
108
+ // headline feature off and nobody noticing (this repo's own author's shell had
109
+ // CI_GATEGUARD_EXCLUDE="/,." for weeks). So every exclusion prints one stderr
110
+ // line, and a catch-all says plainly that the gate is off. stderr never changes
111
+ // the decision: allow stays empty stdout + exit 0.
112
+ function isCatchAllFragment(fragment) {
113
+ return fragment.length <= 1 || fragment === "./" || fragment === "..";
114
+ }
115
+ function buildExcludeNotice(paths, fragment) {
116
+ const shown = paths.map((p) => p.replace(/\\/g, "/")).join(", ");
117
+ if (isCatchAllFragment(fragment)) {
118
+ return (`[continuous-improvement] gateguard: CI_GATEGUARD_EXCLUDE fragment "${fragment}" matches every path, ` +
119
+ `so the file gate is off for this session (skipped ${shown}). ` +
120
+ "Narrow it to a directory, e.g. CI_GATEGUARD_EXCLUDE=docs/wiki, to get the gate back.");
121
+ }
122
+ return `[continuous-improvement] gateguard: skipped by CI_GATEGUARD_EXCLUDE (fragment "${fragment}" matched ${shown}).`;
128
123
  }
129
124
  // --- Target lock (opt-in) --------------------------------------------------
130
125
  // A fact-list can't catch a wrong-repo / wrong-worktree write — you can present
@@ -271,6 +266,7 @@ function buildBraceRefReason(hit) {
271
266
  ].join("\n");
272
267
  }
273
268
  function buildDestructiveBashReason(command) {
269
+ const rule = classifyDestructiveBash(command).rule ?? "unknown";
274
270
  return [
275
271
  `Destructive command requested: ${command}`,
276
272
  "",
@@ -278,6 +274,7 @@ function buildDestructiveBashReason(command) {
278
274
  " 2. Write a one-line rollback procedure",
279
275
  " 3. Quote the user's current instruction verbatim",
280
276
  "",
277
+ `Matched rule: ${rule}`,
281
278
  "Destructive Bash gates EVERY call — clearance is not cached.",
282
279
  ].join("\n");
283
280
  }
@@ -354,7 +351,10 @@ function main() {
354
351
  const allTargetPaths = extractFilePaths(toolInput);
355
352
  const filePaths = allTargetPaths.filter((path) => !isExcludedPath(path));
356
353
  if (allTargetPaths.length > 0 && filePaths.length === 0) {
357
- emitAllow(); // every target is under a CI_GATEGUARD_EXCLUDE path; skip the gate
354
+ // Every target is under a CI_GATEGUARD_EXCLUDE path; skip the gate, but say so.
355
+ const fragment = matchedExcludeFragment(allTargetPaths[0]) ?? EXCLUDE_FRAGMENTS[0];
356
+ process.stderr.write(`${buildExcludeNotice(allTargetPaths, fragment)}\n`);
357
+ emitAllow();
358
358
  return;
359
359
  }
360
360
  // Target lock runs before the fact gate and independent of clearance: a
@@ -17,10 +17,10 @@
17
17
  // construction: any error / missing goal / unreadable observations exits 0 and
18
18
  // never blocks. No network. 5s hook budget.
19
19
  import { execFileSync } from "node:child_process";
20
- import { createHash } from "node:crypto";
21
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
21
  import { join } from "node:path";
23
22
  import { evaluateGoalDrift } from "../lib/goal-drift-gate.mjs";
23
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
24
24
  import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
25
25
  function readStdinSync() {
26
26
  try {
@@ -62,7 +62,7 @@ function resolveProjectRoot() {
62
62
  return "global";
63
63
  }
64
64
  function projectHash(root) {
65
- return createHash("sha256").update(root).digest("hex").slice(0, 12);
65
+ return hashProjectRoot(root);
66
66
  }
67
67
  function readGoalMarkdown(projectRoot, instinctsProjectDir) {
68
68
  const candidates = [join(projectRoot, "task_plan.md"), join(instinctsProjectDir, "goal.md")];
@@ -16,7 +16,7 @@
16
16
  * the DB files stay dirty. Never blocks; fail-open on any error.
17
17
  */
18
18
  import { execFileSync } from "node:child_process";
19
- import { createHash } from "node:crypto";
19
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
20
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { dirname, join } from "node:path";
@@ -71,7 +71,7 @@ function markerKey(sessionId) {
71
71
  return sanitized || `day-${new Date().toISOString().slice(0, 10)}`;
72
72
  }
73
73
  function markerPath(home, projectRoot, sessionId) {
74
- const hash = createHash("sha256").update(projectRoot).digest("hex").slice(0, 12);
74
+ const hash = hashProjectRoot(projectRoot);
75
75
  return join(home, ".claude", "instincts", hash, "query-cost-nudge", `${markerKey(sessionId)}.nudged`);
76
76
  }
77
77
  function main() {
@@ -23,13 +23,13 @@
23
23
  * ~/.claude/instincts/<project-hash>/recall-briefing-session.json records which
24
24
  * session_ids have been briefed so each session is briefed at most once.
25
25
  */
26
- import { createHash } from "node:crypto";
27
26
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
27
  import { homedir } from "node:os";
29
28
  import { join } from "node:path";
30
29
  import { execFileSync } from "node:child_process";
31
30
  import { buildIndex, query } from "../lib/recall-index.mjs";
32
31
  import { DEFAULT_MAX_HITS, decideBriefing } from "../lib/recall-briefing.mjs";
32
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
33
33
  const ENABLED_VALUES = new Set(["1", "on", "true", "yes"]);
34
34
  const MAX_BRIEFED_KEYS = 1000;
35
35
  function isEnabled() {
@@ -65,7 +65,7 @@ function resolveProjectRoot() {
65
65
  return "global";
66
66
  }
67
67
  function instinctsDir(home) {
68
- const hash = createHash("sha256").update(resolveProjectRoot()).digest("hex").slice(0, 12);
68
+ const hash = hashProjectRoot(resolveProjectRoot());
69
69
  return join(home, ".claude", "instincts", hash);
70
70
  }
71
71
  function readObservations(dir) {
@@ -30,12 +30,12 @@
30
30
  * }
31
31
  * Rows are evaluated in order; first match wins.
32
32
  */
33
- import { createHash } from "node:crypto";
34
33
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
35
34
  import { homedir } from "node:os";
36
35
  import { dirname, join } from "node:path";
37
36
  import { execFileSync } from "node:child_process";
38
37
  import { fileURLToPath } from "node:url";
38
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
39
39
  function readStdin() {
40
40
  try {
41
41
  return readFileSync(0, "utf8");
@@ -65,10 +65,7 @@ function resolveProjectRoot() {
65
65
  return "global";
66
66
  }
67
67
  function telemetryPath(home) {
68
- const hash = createHash("sha256")
69
- .update(resolveProjectRoot())
70
- .digest("hex")
71
- .slice(0, 12);
68
+ const hash = hashProjectRoot(resolveProjectRoot());
72
69
  return join(home, ".claude", "instincts", hash, "route-prompt.jsonl");
73
70
  }
74
71
  function writeTelemetry(home, event) {
package/hooks/session.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
3
  import { readFileSync, readdirSync } from "node:fs";
5
4
  import { join } from "node:path";
5
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
6
6
  import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
7
7
  function read(path) {
8
8
  try {
@@ -62,7 +62,7 @@ function main() {
62
62
  if (!home)
63
63
  return;
64
64
  const instinctsRoot = join(home, ".claude", "instincts");
65
- const hash = createHash("sha256").update(projectRoot()).digest("hex").slice(0, 12);
65
+ const hash = hashProjectRoot(projectRoot());
66
66
  const projectDir = join(instinctsRoot, hash);
67
67
  const files = [...yamlFiles(projectDir), ...yamlFiles(join(instinctsRoot, "global"))];
68
68
  const observations = read(join(projectDir, "observations.jsonl")).split(/\r?\n/).filter(Boolean).length;
@@ -24,9 +24,9 @@
24
24
  // name + verify command, so a run is nudged at most once. Fail-open by
25
25
  // construction: any error exits 0 and never blocks. No network. 5s hook budget.
26
26
  import { execFileSync } from "node:child_process";
27
- import { createHash } from "node:crypto";
28
27
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
28
  import { join } from "node:path";
29
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
30
30
  import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
31
31
  import { workflowRunFromObservations } from "../lib/skill-distill.mjs";
32
32
  function safeJsonParse(text) {
@@ -60,7 +60,7 @@ function resolveProjectRoot() {
60
60
  return "global";
61
61
  }
62
62
  function projectHash(root) {
63
- return createHash("sha256").update(root).digest("hex").slice(0, 12);
63
+ return hashProjectRoot(root);
64
64
  }
65
65
  function readObservations(instinctsProjectDir) {
66
66
  const file = join(instinctsProjectDir, "observations.jsonl");