continuous-improvement 3.9.2 → 3.11.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 (150) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +61 -0
  3. package/QUICKSTART.md +13 -8
  4. package/README.md +84 -153
  5. package/bin/backfill.mjs +0 -0
  6. package/bin/check-doc-runtime-claims.mjs +0 -0
  7. package/bin/check-docs-substrings.mjs +73 -0
  8. package/bin/check-everything-mirror.mjs +0 -0
  9. package/bin/check-routing-targets.mjs +0 -0
  10. package/bin/check-scripts-citation-drift.mjs +210 -0
  11. package/bin/check-skill-count.mjs +110 -0
  12. package/bin/check-skill-law-tag.mjs +0 -0
  13. package/bin/check-skill-mirror.mjs +0 -0
  14. package/bin/check-skill-tiers.mjs +0 -0
  15. package/bin/check-test-imports-only.mjs +126 -0
  16. package/bin/check-third-party-shape.mjs +0 -0
  17. package/bin/companion-preference-status.mjs +231 -0
  18. package/bin/gateguard-clear.mjs +53 -0
  19. package/bin/generate-plugin-manifests.mjs +7 -1
  20. package/bin/harvest-friction.mjs +0 -0
  21. package/bin/hook-stats.mjs +0 -0
  22. package/bin/install.mjs +182 -26
  23. package/bin/lint-transcript.mjs +0 -0
  24. package/bin/mcp-server.mjs +215 -2
  25. package/bin/observe.mjs +0 -0
  26. package/bin/pre-commit-block-strays.sh +0 -0
  27. package/bin/refresh-third-party.mjs +315 -313
  28. package/bin/unified-cli.mjs +0 -0
  29. package/commands/audit.md +34 -0
  30. package/commands/companion-preference.md +58 -0
  31. package/commands/discipline.md +14 -0
  32. package/commands/distill.md +39 -0
  33. package/commands/goal-check.md +53 -0
  34. package/commands/grill-me.md +40 -0
  35. package/commands/grill-with-docs.md +38 -0
  36. package/commands/handoff.md +42 -0
  37. package/commands/proceed-with-the-recommendation.md +20 -0
  38. package/commands/recall.md +49 -0
  39. package/commands/reconcile.md +47 -0
  40. package/commands/superpowers.md +2 -2
  41. package/commands/verify-install.md +55 -0
  42. package/hooks/companion-preference.mjs +190 -0
  43. package/hooks/gateguard.mjs +60 -25
  44. package/hooks/goal-drift-stop.mjs +183 -0
  45. package/hooks/observe.sh +15 -1
  46. package/hooks/route-prompt.mjs +180 -0
  47. package/hooks/route-table.json +35 -0
  48. package/hooks/three-section-close.mjs +134 -132
  49. package/lib/cli-anything.mjs +0 -0
  50. package/lib/compound-engineering.mjs +0 -0
  51. package/lib/gateguard-state.mjs +54 -2
  52. package/lib/goal-drift-gate.mjs +50 -0
  53. package/lib/goal-state.mjs +285 -0
  54. package/lib/observe-event.mjs +0 -0
  55. package/lib/plugin-metadata.mjs +126 -12
  56. package/lib/pm-marketplace.mjs +0 -0
  57. package/lib/pm-skills.mjs +0 -0
  58. package/lib/recall-index.mjs +175 -0
  59. package/lib/resolve-home-dir.mjs +0 -0
  60. package/lib/skill-distill.mjs +222 -0
  61. package/lib/skill-tiers.mjs +0 -0
  62. package/lib/unified-plugin.mjs +0 -0
  63. package/llms.txt +1 -1
  64. package/package.json +11 -7
  65. package/plugins/beginner.json +8 -3
  66. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
  67. package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
  68. package/plugins/continuous-improvement/agents/README.md +2 -2
  69. package/plugins/continuous-improvement/bin/backfill.mjs +0 -0
  70. package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
  71. package/plugins/continuous-improvement/bin/mcp-server.mjs +215 -2
  72. package/plugins/continuous-improvement/bin/observe.mjs +0 -0
  73. package/plugins/continuous-improvement/commands/audit.md +34 -0
  74. package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
  75. package/plugins/continuous-improvement/commands/discipline.md +14 -0
  76. package/plugins/continuous-improvement/commands/distill.md +39 -0
  77. package/plugins/continuous-improvement/commands/goal-check.md +53 -0
  78. package/plugins/continuous-improvement/commands/grill-me.md +40 -0
  79. package/plugins/continuous-improvement/commands/grill-with-docs.md +38 -0
  80. package/plugins/continuous-improvement/commands/handoff.md +42 -0
  81. package/plugins/continuous-improvement/commands/proceed-with-the-recommendation.md +20 -0
  82. package/plugins/continuous-improvement/commands/recall.md +49 -0
  83. package/plugins/continuous-improvement/commands/reconcile.md +47 -0
  84. package/plugins/continuous-improvement/commands/superpowers.md +2 -2
  85. package/plugins/continuous-improvement/commands/verify-install.md +55 -0
  86. package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
  87. package/plugins/continuous-improvement/hooks/gateguard.mjs +60 -25
  88. package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
  89. package/plugins/continuous-improvement/hooks/hooks.json +18 -2
  90. package/plugins/continuous-improvement/hooks/observe.sh +15 -1
  91. package/plugins/continuous-improvement/hooks/route-prompt.mjs +180 -0
  92. package/plugins/continuous-improvement/hooks/route-table.json +35 -0
  93. package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
  94. package/plugins/continuous-improvement/lib/gateguard-state.mjs +137 -0
  95. package/plugins/continuous-improvement/lib/goal-drift-gate.mjs +50 -0
  96. package/plugins/continuous-improvement/lib/goal-state.mjs +285 -0
  97. package/plugins/continuous-improvement/lib/observe-event.mjs +0 -0
  98. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +126 -12
  99. package/plugins/continuous-improvement/lib/recall-index.mjs +175 -0
  100. package/plugins/continuous-improvement/lib/resolve-home-dir.mjs +0 -0
  101. package/plugins/continuous-improvement/lib/skill-distill.mjs +222 -0
  102. package/plugins/continuous-improvement/skills/README.md +8 -0
  103. package/plugins/continuous-improvement/skills/audit/SKILL.md +73 -0
  104. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +13 -17
  105. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +36 -17
  106. package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +81 -0
  107. package/plugins/continuous-improvement/skills/grill-me/SKILL.md +66 -0
  108. package/plugins/continuous-improvement/skills/grill-with-docs/SKILL.md +252 -0
  109. package/plugins/continuous-improvement/skills/handoff/SKILL.md +59 -0
  110. package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +1 -1
  111. package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +82 -6
  112. package/plugins/continuous-improvement/skills/ralph/SKILL.md +1 -0
  113. package/plugins/continuous-improvement/skills/recall/SKILL.md +50 -0
  114. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +80 -0
  115. package/plugins/continuous-improvement/skills/recovery-classification/SKILL.md +1 -0
  116. package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +1 -0
  117. package/plugins/continuous-improvement/skills/skill-distillation/SKILL.md +57 -0
  118. package/plugins/continuous-improvement/skills/state-reconciliation/SKILL.md +1 -0
  119. package/plugins/continuous-improvement/skills/strategic-compact/SKILL.md +1 -0
  120. package/plugins/continuous-improvement/skills/superpowers/SKILL.md +76 -2
  121. package/plugins/continuous-improvement/skills/token-budget-advisor/SKILL.md +1 -1
  122. package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +28 -20
  123. package/plugins/continuous-improvement/skills/wild-risa-balance/SKILL.md +1 -0
  124. package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
  125. package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +3 -2
  126. package/plugins/expert.json +27 -2
  127. package/skills/README.md +3 -0
  128. package/skills/audit.md +73 -0
  129. package/skills/deploy-receipt.md +13 -17
  130. package/skills/gateguard.md +36 -17
  131. package/skills/goal-monitor.md +81 -0
  132. package/skills/grill-me.md +66 -0
  133. package/skills/grill-with-docs.md +252 -0
  134. package/skills/handoff.md +59 -0
  135. package/skills/para-memory-files.md +1 -1
  136. package/skills/proceed-with-the-recommendation.md +82 -6
  137. package/skills/ralph.md +1 -0
  138. package/skills/recall.md +50 -0
  139. package/skills/reconcile.md +80 -0
  140. package/skills/recovery-classification.md +1 -0
  141. package/skills/safety-guard.md +1 -0
  142. package/skills/skill-distillation.md +57 -0
  143. package/skills/state-reconciliation.md +1 -0
  144. package/skills/strategic-compact.md +1 -0
  145. package/skills/superpowers.md +76 -2
  146. package/skills/token-budget-advisor.md +1 -1
  147. package/skills/verification-loop.md +28 -20
  148. package/skills/wild-risa-balance.md +1 -0
  149. package/skills/workspace-surface-audit.md +1 -1
  150. package/skills/worktree-safety.md +3 -2
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // three-section-close.mjs — Stop hook that requires every substantive
2
+ // three-section-close.mts — Stop hook that requires every substantive
3
3
  // assistant reply to close with three named sections:
4
4
  // "What has been done", "What is next", "Recommendation".
5
5
  //
@@ -19,163 +19,165 @@
19
19
  // Telemetry: best-effort JSONL line per gated invocation written to
20
20
  // ~/.claude/hook-telemetry/<project-hash>.jsonl. Never throws, never
21
21
  // blocks the response. No network. No transcript content recorded.
22
-
23
22
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
24
23
  import { createHash } from "node:crypto";
25
24
  import { platform } from "node:os";
26
25
  import { dirname, join } from "node:path";
27
26
  import { performance } from "node:perf_hooks";
28
-
29
27
  import { resolveHomeDir } from "../lib/resolve-home-dir.mjs";
30
-
31
28
  const MIN_LENGTH_TO_GATE = 600;
32
-
33
29
  const REQUIRED_SECTIONS = [
34
- {
35
- heading: "What has been done",
36
- hint: "concrete summary of changes/findings from this turn",
37
- pattern: /^#+ +What has been done(?:\s|$)/m,
38
- },
39
- {
40
- heading: "What is next",
41
- hint: "minimum 5 specific, actionable bullets",
42
- pattern: /^#+ +What is next(?:\s|$)/m,
43
- },
44
- {
45
- heading: "Recommendation",
46
- hint: "ranked descending by impact, or write \"no\" if there is no recommendation",
47
- pattern: /^#+ +Recommendation(?:\s|$)/m,
48
- },
30
+ {
31
+ heading: "What has been done",
32
+ hint: "concrete summary of changes/findings from this turn",
33
+ pattern: /^#+ +What has been done(?:\s|$)/m,
34
+ },
35
+ {
36
+ heading: "What is next",
37
+ hint: "minimum 5 specific, actionable bullets",
38
+ pattern: /^#+ +What is next(?:\s|$)/m,
39
+ },
40
+ {
41
+ heading: "Recommendation",
42
+ hint: "ranked descending by impact, or write \"no\" if there is no recommendation",
43
+ pattern: /^#+ +Recommendation(?:\s|$)/m,
44
+ },
49
45
  ];
50
-
51
46
  function readStdinSync() {
52
- try {
53
- return readFileSync(0, "utf8");
54
- } catch {
55
- return "";
56
- }
47
+ try {
48
+ return readFileSync(0, "utf8");
49
+ }
50
+ catch {
51
+ return "";
52
+ }
57
53
  }
58
-
59
54
  function safeJsonParse(text) {
60
- try {
61
- return JSON.parse(text);
62
- } catch {
63
- return null;
64
- }
55
+ try {
56
+ return JSON.parse(text);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
65
61
  }
66
-
67
62
  function extractLastAssistantText(transcriptPath) {
68
- const raw = readFileSync(transcriptPath, "utf8");
69
- const lines = raw.split(/\r?\n/);
70
- for (let i = lines.length - 1; i >= 0; i--) {
71
- const line = lines[i].trim();
72
- if (!line) continue;
73
- const obj = safeJsonParse(line);
74
- if (!obj || obj.type !== "assistant") continue;
75
- const blocks = obj.message?.content;
76
- if (!Array.isArray(blocks)) continue;
77
- const texts = blocks
78
- .filter((b) => b && b.type === "text" && typeof b.text === "string")
79
- .map((b) => b.text);
80
- if (texts.length === 0) continue;
81
- return texts.join("\n");
82
- }
83
- return null;
63
+ const raw = readFileSync(transcriptPath, "utf8");
64
+ const lines = raw.split(/\r?\n/);
65
+ for (let i = lines.length - 1; i >= 0; i--) {
66
+ const line = lines[i].trim();
67
+ if (!line)
68
+ continue;
69
+ const obj = safeJsonParse(line);
70
+ if (!obj || obj.type !== "assistant")
71
+ continue;
72
+ const blocks = obj.message?.content;
73
+ if (!Array.isArray(blocks))
74
+ continue;
75
+ const texts = blocks
76
+ .filter((b) => b &&
77
+ typeof b === "object" &&
78
+ b.type === "text" &&
79
+ typeof b.text === "string")
80
+ .map((b) => b.text);
81
+ if (texts.length === 0)
82
+ continue;
83
+ return texts.join("\n");
84
+ }
85
+ return null;
84
86
  }
85
-
86
87
  function emitBlock(missing) {
87
- const bullets = missing
88
- .map(
89
- (s) =>
90
- `- "## ${s.heading}" ${s.hint}`,
91
- )
92
- .join("\n");
93
- const reason =
94
- `Your reply is missing the required 3-section close. Add these sections at the end of the reply with these exact headings:\n${bullets}`;
95
- process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
88
+ const bullets = missing
89
+ .map((s) => `- "## ${s.heading}" — ${s.hint}`)
90
+ .join("\n");
91
+ const reason = `Your reply is missing the required 3-section close. Add these sections at the end of the reply with these exact headings:\n${bullets}`;
92
+ process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
96
93
  }
97
-
98
94
  function projectHashFor(transcriptPath) {
99
- const dir = dirname(transcriptPath);
100
- let normalized = dir.split("\\").join("/");
101
- if (platform() === "win32") normalized = normalized.toLowerCase();
102
- return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
95
+ const dir = dirname(transcriptPath);
96
+ let normalized = dir.split("\\").join("/");
97
+ if (platform() === "win32")
98
+ normalized = normalized.toLowerCase();
99
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
103
100
  }
104
-
105
101
  function recordTelemetry(entry, transcriptPath) {
106
- try {
107
- const home = resolveHomeDir();
108
- if (!home) return;
109
- const telemetryDir = join(home, ".claude", "hook-telemetry");
110
102
  try {
111
- mkdirSync(telemetryDir, { recursive: true });
112
- } catch {
113
- return;
103
+ const home = resolveHomeDir();
104
+ if (!home)
105
+ return;
106
+ const telemetryDir = join(home, ".claude", "hook-telemetry");
107
+ try {
108
+ mkdirSync(telemetryDir, { recursive: true });
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ const file = join(telemetryDir, `${projectHashFor(transcriptPath)}.jsonl`);
114
+ try {
115
+ appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
116
+ }
117
+ catch {
118
+ return;
119
+ }
114
120
  }
115
- const file = join(telemetryDir, `${projectHashFor(transcriptPath)}.jsonl`);
116
- try {
117
- appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
118
- } catch {
119
- return;
121
+ catch {
122
+ // belt-and-suspenders: telemetry must never throw
120
123
  }
121
- } catch {
122
- // belt-and-suspenders: telemetry must never throw
123
- }
124
124
  }
125
-
126
125
  function main() {
127
- // Per-operator opt-out. When CLAUDE_THREE_SECTION_CLOSE_DISABLED=1, the hook
128
- // short-circuits entirely: no enforcement, no telemetry. Public default is
129
- // unchanged (rule still fires for everyone else). See feedback memory
130
- // 2026-05-04 for rationale: an audit found 22,876 observations produced
131
- // only 2 graduated instincts, so visible end-of-turn reflection earns its
132
- // keep poorly enough that operators may want to run it as internal thinking.
133
- if (process.env.CLAUDE_THREE_SECTION_CLOSE_DISABLED === "1") return;
134
- const startedAt = performance.now();
135
- const stdin = readStdinSync();
136
- if (!stdin) return;
137
-
138
- const payload = safeJsonParse(stdin);
139
- if (!payload || typeof payload.transcript_path !== "string") return;
140
- if (!existsSync(payload.transcript_path)) return;
141
-
142
- let lastText;
143
- try {
144
- lastText = extractLastAssistantText(payload.transcript_path);
145
- } catch {
146
- return; // fail open on read error
147
- }
148
- if (!lastText) return;
149
-
150
- const textLength = lastText.length;
151
- let action;
152
- let missing = [];
153
-
154
- if (textLength < MIN_LENGTH_TO_GATE) {
155
- action = "skip-short";
156
- } else {
157
- missing = REQUIRED_SECTIONS.filter((s) => !s.pattern.test(lastText));
158
- if (missing.length === 0) {
159
- action = "pass";
160
- } else {
161
- action = "block";
162
- emitBlock(missing);
126
+ // Per-operator opt-out. When CLAUDE_THREE_SECTION_CLOSE_DISABLED=1, the hook
127
+ // short-circuits entirely: no enforcement, no telemetry. Public default is
128
+ // unchanged (rule still fires for everyone else). See feedback memory
129
+ // 2026-05-04 for rationale: an audit found 22,876 observations produced
130
+ // only 2 graduated instincts, so visible end-of-turn reflection earns its
131
+ // keep poorly enough that operators may want to run it as internal thinking.
132
+ if (process.env.CLAUDE_THREE_SECTION_CLOSE_DISABLED === "1")
133
+ return;
134
+ const startedAt = performance.now();
135
+ const stdin = readStdinSync();
136
+ if (!stdin)
137
+ return;
138
+ const payload = safeJsonParse(stdin);
139
+ if (!payload || typeof payload.transcript_path !== "string")
140
+ return;
141
+ if (!existsSync(payload.transcript_path))
142
+ return;
143
+ let lastText;
144
+ try {
145
+ lastText = extractLastAssistantText(payload.transcript_path);
146
+ }
147
+ catch {
148
+ return; // fail open on read error
163
149
  }
164
- }
165
-
166
- const entry = {
167
- ts: new Date().toISOString(),
168
- hook: "three-section-close",
169
- action,
170
- textLength,
171
- missing: missing.map((s) => s.heading),
172
- durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
173
- };
174
- recordTelemetry(entry, payload.transcript_path);
150
+ if (!lastText)
151
+ return;
152
+ const textLength = lastText.length;
153
+ let action;
154
+ let missing = [];
155
+ if (textLength < MIN_LENGTH_TO_GATE) {
156
+ action = "skip-short";
157
+ }
158
+ else {
159
+ missing = REQUIRED_SECTIONS.filter((s) => !s.pattern.test(lastText));
160
+ if (missing.length === 0) {
161
+ action = "pass";
162
+ }
163
+ else {
164
+ action = "block";
165
+ emitBlock(missing);
166
+ }
167
+ }
168
+ const entry = {
169
+ ts: new Date().toISOString(),
170
+ hook: "three-section-close",
171
+ action,
172
+ textLength,
173
+ missing: missing.map((s) => s.heading),
174
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
175
+ };
176
+ recordTelemetry(entry, payload.transcript_path);
175
177
  }
176
-
177
178
  try {
178
- main();
179
- } catch {
180
- // fail open — never block the session due to a hook bug
179
+ main();
180
+ }
181
+ catch {
182
+ // fail open — never block the session due to a hook bug
181
183
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Gateguard per-session state.
3
+ *
4
+ * State file: <sessionDir>/gateguard-session.json. sessionDir resolves to
5
+ * GATEGUARD_SESSION_DIR (env override, used by tests) or
6
+ * ~/.claude/instincts/<projectHash>/ in production.
7
+ *
8
+ * V1 limitations (documented for honesty, not mitigated in code):
9
+ * - Honor system: clearance is granted whenever the agent sets
10
+ * `_gateguard_facts_presented: true` in tool_input or has a prior per-file
11
+ * marker. The hook cannot verify that real investigation occurred.
12
+ * - State-file deletion: rm'ing the state file resets every gate in the
13
+ * session. Defensible because the session itself is the trust boundary;
14
+ * the cap below limits cumulative damage.
15
+ * - Concurrency: two parallel hook invocations can race the read+write.
16
+ * Acceptable trade-off vs OS-specific atomic-rename complexity on Windows.
17
+ * - Cap: MAX_CLEARED_FILES caps the number of distinct files a single
18
+ * session can clear, bounding stuck-loop / rogue-agent damage.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { execFileSync } from "node:child_process";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+ export const MAX_CLEARED_FILES = 50;
26
+ export function resolveSessionDir() {
27
+ const fromEnv = process.env.GATEGUARD_SESSION_DIR;
28
+ if (fromEnv)
29
+ return fromEnv;
30
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
31
+ const projectRoot = resolveProjectRoot();
32
+ const projectHash = createHash("sha256").update(canonicalizeProjectRoot(projectRoot)).digest("hex").slice(0, 12);
33
+ return join(home, ".claude", "instincts", projectHash);
34
+ }
35
+ function resolveProjectRoot() {
36
+ const fromEnv = process.env.CLAUDE_PROJECT_DIR;
37
+ if (fromEnv)
38
+ return fromEnv;
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
+ export function loadState(sessionDir) {
53
+ const path = join(sessionDir, "gateguard-session.json");
54
+ if (!existsSync(path)) {
55
+ return { created_at: new Date().toISOString(), cleared_files: {} };
56
+ }
57
+ try {
58
+ const raw = readFileSync(path, "utf8");
59
+ const parsed = JSON.parse(raw);
60
+ return {
61
+ created_at: parsed.created_at ?? new Date().toISOString(),
62
+ cleared_files: parsed.cleared_files ?? {},
63
+ };
64
+ }
65
+ catch {
66
+ return { created_at: new Date().toISOString(), cleared_files: {} };
67
+ }
68
+ }
69
+ export function saveState(sessionDir, state) {
70
+ if (!existsSync(sessionDir))
71
+ mkdirSync(sessionDir, { recursive: true });
72
+ writeFileSync(join(sessionDir, "gateguard-session.json"), `${JSON.stringify(state, null, 2)}\n`);
73
+ }
74
+ export function isCapReached(state) {
75
+ return Object.keys(state.cleared_files).length >= MAX_CLEARED_FILES;
76
+ }
77
+ export function markFileCleared(state, filePath) {
78
+ return {
79
+ ...state,
80
+ cleared_files: {
81
+ ...state.cleared_files,
82
+ [canonicalizeFileKey(filePath)]: { cleared_at: new Date().toISOString() },
83
+ },
84
+ };
85
+ }
86
+ // Canonicalize a path so the hook and any clearance helper agree regardless of
87
+ // which process resolved it. Two processes can supply the same directory in
88
+ // different forms — CLAUDE_PROJECT_DIR gives `d:/...` (lowercase drive) while
89
+ // `git rev-parse --show-toplevel` gives `D:/...` (uppercase) — and tool inputs
90
+ // vary by separator. Normalizing the drive-letter case and separators (only;
91
+ // directory/file-name case is preserved) makes the hash and the per-file key
92
+ // stable. Relative and POSIX paths have no drive letter or backslash, so they
93
+ // pass through unchanged and existing relative-key state keeps matching.
94
+ function canonicalizePath(p) {
95
+ let out = p.replace(/\\/g, "/");
96
+ if (/^[A-Za-z]:/.test(out)) {
97
+ out = out.charAt(0).toLowerCase() + out.slice(1);
98
+ }
99
+ while (out.length > 1 && out.endsWith("/")) {
100
+ out = out.slice(0, -1);
101
+ }
102
+ return out;
103
+ }
104
+ export function canonicalizeProjectRoot(p) {
105
+ return canonicalizePath(p);
106
+ }
107
+ export function canonicalizeFileKey(p) {
108
+ return canonicalizePath(p);
109
+ }
110
+ export function isFileCleared(state, filePath) {
111
+ return canonicalizeFileKey(filePath) in state.cleared_files;
112
+ }
113
+ // Shared clearance writer used by the MCP tool and the CLI. Loads, marks each
114
+ // not-yet-cleared canonical key (respecting MAX_CLEARED_FILES), and saves once.
115
+ // Idempotent across path forms; returns what it cleared and what it skipped for
116
+ // the cap so callers can report partial results on a MultiEdit-sized batch.
117
+ export function clearFiles(sessionDir, filePaths) {
118
+ const cleared = [];
119
+ const skippedForCap = [];
120
+ let state = loadState(sessionDir);
121
+ for (const filePath of filePaths) {
122
+ const key = canonicalizeFileKey(filePath);
123
+ if (key in state.cleared_files) {
124
+ continue; // already cleared — idempotent, no cap charge
125
+ }
126
+ if (Object.keys(state.cleared_files).length >= MAX_CLEARED_FILES) {
127
+ skippedForCap.push(filePath);
128
+ continue;
129
+ }
130
+ state = markFileCleared(state, filePath);
131
+ cleared.push(key);
132
+ }
133
+ if (cleared.length > 0) {
134
+ saveState(sessionDir, state);
135
+ }
136
+ return { cleared, skippedForCap };
137
+ }
@@ -0,0 +1,50 @@
1
+ // goal-drift-gate.mts — Pure decision core for the goal-drift Stop hook.
2
+ //
3
+ // No I/O. Given a goal-plan body, recent observations, the length of the last
4
+ // assistant reply, and the operator's mode, decide whether the Stop hook should
5
+ // allow / warn / block. The hook (src/hooks/goal-drift-stop.mts) wires the file
6
+ // reads around this function; keeping it pure lets the unit tests cover every
7
+ // branch without touching the filesystem.
8
+ //
9
+ // Fail-open by construction: any state that is not a confident DRIFT on a
10
+ // substantive wrap-up returns "allow".
11
+ import { parseGoalFromPlan, scoreObservations, } from "./goal-state.mjs";
12
+ // Matches the three-section-close wrap-up threshold: a substantive Stop reply is
13
+ // a completion report. This is the non-lexical "claims done" proxy — no keyword
14
+ // matching, so it cannot false-fire on the word "done" in ordinary prose.
15
+ const MIN_LENGTH_TO_GATE = 600;
16
+ export function evaluateGoalDrift(input) {
17
+ const { goalMarkdown, observations, textLength, mode } = input;
18
+ if (mode === "off") {
19
+ return { action: "allow", status: "no-goal", score: 0, reason: "Goal-drift gate disabled (mode=off)." };
20
+ }
21
+ const goal = parseGoalFromPlan(goalMarkdown);
22
+ if (!goal) {
23
+ return {
24
+ action: "allow",
25
+ status: "no-goal",
26
+ score: 0,
27
+ reason: "No '## Goal' section found; nothing to gate against.",
28
+ };
29
+ }
30
+ const report = scoreObservations(observations, goal);
31
+ if (report.status === "no-data") {
32
+ return { action: "allow", status: "no-data", score: 0, reason: "Not enough recent observations to judge drift." };
33
+ }
34
+ if (report.status === "on-goal") {
35
+ return { action: "allow", status: "on-goal", score: report.score, reason: report.reason };
36
+ }
37
+ // report.status === "drift"
38
+ if (textLength < MIN_LENGTH_TO_GATE) {
39
+ return {
40
+ action: "allow",
41
+ status: "drift",
42
+ score: report.score,
43
+ reason: "Drift detected, but the reply is too short to be a wrap-up; not gating.",
44
+ };
45
+ }
46
+ const pct = Math.round(report.score * 100);
47
+ const reason = `Session has DRIFTed from the stated goal (${report.matching}/${report.total} recent actions on-goal, ${pct}%). ` +
48
+ "Re-check the work against the '## Goal' in task_plan.md — or update the goal if it has legitimately changed — before closing.";
49
+ return { action: mode === "block" ? "block" : "warn", status: "drift", score: report.score, reason };
50
+ }