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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +61 -0
- package/QUICKSTART.md +13 -8
- package/README.md +84 -153
- package/bin/backfill.mjs +0 -0
- package/bin/check-doc-runtime-claims.mjs +0 -0
- package/bin/check-docs-substrings.mjs +73 -0
- package/bin/check-everything-mirror.mjs +0 -0
- package/bin/check-routing-targets.mjs +0 -0
- package/bin/check-scripts-citation-drift.mjs +210 -0
- package/bin/check-skill-count.mjs +110 -0
- package/bin/check-skill-law-tag.mjs +0 -0
- package/bin/check-skill-mirror.mjs +0 -0
- package/bin/check-skill-tiers.mjs +0 -0
- package/bin/check-test-imports-only.mjs +126 -0
- package/bin/check-third-party-shape.mjs +0 -0
- package/bin/companion-preference-status.mjs +231 -0
- package/bin/gateguard-clear.mjs +53 -0
- package/bin/generate-plugin-manifests.mjs +7 -1
- package/bin/harvest-friction.mjs +0 -0
- package/bin/hook-stats.mjs +0 -0
- package/bin/install.mjs +182 -26
- package/bin/lint-transcript.mjs +0 -0
- package/bin/mcp-server.mjs +215 -2
- package/bin/observe.mjs +0 -0
- package/bin/pre-commit-block-strays.sh +0 -0
- package/bin/refresh-third-party.mjs +315 -313
- package/bin/unified-cli.mjs +0 -0
- package/commands/audit.md +34 -0
- package/commands/companion-preference.md +58 -0
- package/commands/discipline.md +14 -0
- package/commands/distill.md +39 -0
- package/commands/goal-check.md +53 -0
- package/commands/grill-me.md +40 -0
- package/commands/grill-with-docs.md +38 -0
- package/commands/handoff.md +42 -0
- package/commands/proceed-with-the-recommendation.md +20 -0
- package/commands/recall.md +49 -0
- package/commands/reconcile.md +47 -0
- package/commands/superpowers.md +2 -2
- package/commands/verify-install.md +55 -0
- package/hooks/companion-preference.mjs +190 -0
- package/hooks/gateguard.mjs +60 -25
- package/hooks/goal-drift-stop.mjs +183 -0
- package/hooks/observe.sh +15 -1
- package/hooks/route-prompt.mjs +180 -0
- package/hooks/route-table.json +35 -0
- package/hooks/three-section-close.mjs +134 -132
- package/lib/cli-anything.mjs +0 -0
- package/lib/compound-engineering.mjs +0 -0
- package/lib/gateguard-state.mjs +54 -2
- package/lib/goal-drift-gate.mjs +50 -0
- package/lib/goal-state.mjs +285 -0
- package/lib/observe-event.mjs +0 -0
- package/lib/plugin-metadata.mjs +126 -12
- package/lib/pm-marketplace.mjs +0 -0
- package/lib/pm-skills.mjs +0 -0
- package/lib/recall-index.mjs +175 -0
- package/lib/resolve-home-dir.mjs +0 -0
- package/lib/skill-distill.mjs +222 -0
- package/lib/skill-tiers.mjs +0 -0
- package/lib/unified-plugin.mjs +0 -0
- package/llms.txt +1 -1
- package/package.json +11 -7
- package/plugins/beginner.json +8 -3
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
- package/plugins/continuous-improvement/agents/README.md +2 -2
- package/plugins/continuous-improvement/bin/backfill.mjs +0 -0
- package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
- package/plugins/continuous-improvement/bin/mcp-server.mjs +215 -2
- package/plugins/continuous-improvement/bin/observe.mjs +0 -0
- package/plugins/continuous-improvement/commands/audit.md +34 -0
- package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
- package/plugins/continuous-improvement/commands/discipline.md +14 -0
- package/plugins/continuous-improvement/commands/distill.md +39 -0
- package/plugins/continuous-improvement/commands/goal-check.md +53 -0
- package/plugins/continuous-improvement/commands/grill-me.md +40 -0
- package/plugins/continuous-improvement/commands/grill-with-docs.md +38 -0
- package/plugins/continuous-improvement/commands/handoff.md +42 -0
- package/plugins/continuous-improvement/commands/proceed-with-the-recommendation.md +20 -0
- package/plugins/continuous-improvement/commands/recall.md +49 -0
- package/plugins/continuous-improvement/commands/reconcile.md +47 -0
- package/plugins/continuous-improvement/commands/superpowers.md +2 -2
- package/plugins/continuous-improvement/commands/verify-install.md +55 -0
- package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
- package/plugins/continuous-improvement/hooks/gateguard.mjs +60 -25
- package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
- package/plugins/continuous-improvement/hooks/hooks.json +18 -2
- package/plugins/continuous-improvement/hooks/observe.sh +15 -1
- package/plugins/continuous-improvement/hooks/route-prompt.mjs +180 -0
- package/plugins/continuous-improvement/hooks/route-table.json +35 -0
- package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +137 -0
- package/plugins/continuous-improvement/lib/goal-drift-gate.mjs +50 -0
- package/plugins/continuous-improvement/lib/goal-state.mjs +285 -0
- package/plugins/continuous-improvement/lib/observe-event.mjs +0 -0
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +126 -12
- package/plugins/continuous-improvement/lib/recall-index.mjs +175 -0
- package/plugins/continuous-improvement/lib/resolve-home-dir.mjs +0 -0
- package/plugins/continuous-improvement/lib/skill-distill.mjs +222 -0
- package/plugins/continuous-improvement/skills/README.md +8 -0
- package/plugins/continuous-improvement/skills/audit/SKILL.md +73 -0
- package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +13 -17
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +36 -17
- package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +81 -0
- package/plugins/continuous-improvement/skills/grill-me/SKILL.md +66 -0
- package/plugins/continuous-improvement/skills/grill-with-docs/SKILL.md +252 -0
- package/plugins/continuous-improvement/skills/handoff/SKILL.md +59 -0
- package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +82 -6
- package/plugins/continuous-improvement/skills/ralph/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/recall/SKILL.md +50 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +80 -0
- package/plugins/continuous-improvement/skills/recovery-classification/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/skill-distillation/SKILL.md +57 -0
- package/plugins/continuous-improvement/skills/state-reconciliation/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/strategic-compact/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/superpowers/SKILL.md +76 -2
- package/plugins/continuous-improvement/skills/token-budget-advisor/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +28 -20
- package/plugins/continuous-improvement/skills/wild-risa-balance/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +3 -2
- package/plugins/expert.json +27 -2
- package/skills/README.md +3 -0
- package/skills/audit.md +73 -0
- package/skills/deploy-receipt.md +13 -17
- package/skills/gateguard.md +36 -17
- package/skills/goal-monitor.md +81 -0
- package/skills/grill-me.md +66 -0
- package/skills/grill-with-docs.md +252 -0
- package/skills/handoff.md +59 -0
- package/skills/para-memory-files.md +1 -1
- package/skills/proceed-with-the-recommendation.md +82 -6
- package/skills/ralph.md +1 -0
- package/skills/recall.md +50 -0
- package/skills/reconcile.md +80 -0
- package/skills/recovery-classification.md +1 -0
- package/skills/safety-guard.md +1 -0
- package/skills/skill-distillation.md +57 -0
- package/skills/state-reconciliation.md +1 -0
- package/skills/strategic-compact.md +1 -0
- package/skills/superpowers.md +76 -2
- package/skills/token-budget-advisor.md +1 -1
- package/skills/verification-loop.md +28 -20
- package/skills/wild-risa-balance.md +1 -0
- package/skills/workspace-surface-audit.md +1 -1
- package/skills/worktree-safety.md +3 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// three-section-close.
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(0, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
57
53
|
}
|
|
58
|
-
|
|
59
54
|
function safeJsonParse(text) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(text);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
65
61
|
}
|
|
66
|
-
|
|
67
62
|
function extractLastAssistantText(transcriptPath) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
lastText
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
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
|
+
}
|