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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime UserPromptSubmit prompt-router hook.
|
|
4
|
+
*
|
|
5
|
+
* Reads the user prompt, matches it against patterns in
|
|
6
|
+
* hooks/route-table.json, and emits a single `<system-reminder>` recommending
|
|
7
|
+
* the matched skill. When no row matches, the hook emits nothing — the prompt
|
|
8
|
+
* passes through untouched.
|
|
9
|
+
*
|
|
10
|
+
* Stdin : JSON { prompt, session_id?, transcript_path?, cwd? }
|
|
11
|
+
* Stdout : JSON { hookSpecificOutput: { hookEventName: "UserPromptSubmit",
|
|
12
|
+
* additionalContext: string } }
|
|
13
|
+
* OR empty when there is no match.
|
|
14
|
+
* Exit : 0 always — routing decisions live in stdout, never in exit code.
|
|
15
|
+
*
|
|
16
|
+
* Fail-open: any unexpected error reading stdin / parsing the route table /
|
|
17
|
+
* compiling a pattern → emit nothing and exit 0. The hook never blocks a
|
|
18
|
+
* prompt on its own bugs.
|
|
19
|
+
*
|
|
20
|
+
* Telemetry: each match appends one JSONL line to
|
|
21
|
+
* ~/.claude/instincts/<project-hash>/route-prompt.jsonl with the row index,
|
|
22
|
+
* skill, and pattern. Telemetry failures never change the decision.
|
|
23
|
+
*
|
|
24
|
+
* Route table schema (hooks/route-table.json):
|
|
25
|
+
* {
|
|
26
|
+
* "rows": [
|
|
27
|
+
* { "pattern": "regex source", "flags": "i", "skill": "skill-name",
|
|
28
|
+
* "reason": "one-line description" }
|
|
29
|
+
* ]
|
|
30
|
+
* }
|
|
31
|
+
* Rows are evaluated in order; first match wins.
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
import { execFileSync } from "node:child_process";
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
39
|
+
function readStdin() {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(0, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function resolveHome() {
|
|
48
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
49
|
+
}
|
|
50
|
+
function resolveProjectRoot() {
|
|
51
|
+
const fromEnv = process.env.CLAUDE_PROJECT_DIR;
|
|
52
|
+
if (fromEnv)
|
|
53
|
+
return fromEnv;
|
|
54
|
+
try {
|
|
55
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
58
|
+
}).trim();
|
|
59
|
+
if (root)
|
|
60
|
+
return root;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// not in a git repo
|
|
64
|
+
}
|
|
65
|
+
return "global";
|
|
66
|
+
}
|
|
67
|
+
function telemetryPath(home) {
|
|
68
|
+
const hash = createHash("sha256")
|
|
69
|
+
.update(resolveProjectRoot())
|
|
70
|
+
.digest("hex")
|
|
71
|
+
.slice(0, 12);
|
|
72
|
+
return join(home, ".claude", "instincts", hash, "route-prompt.jsonl");
|
|
73
|
+
}
|
|
74
|
+
function writeTelemetry(home, event) {
|
|
75
|
+
try {
|
|
76
|
+
const path = telemetryPath(home);
|
|
77
|
+
const dir = dirname(path);
|
|
78
|
+
if (!existsSync(dir))
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// fail-open
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function loadRouteTable() {
|
|
87
|
+
// Hook lives at <plugin-root>/hooks/route-prompt.mjs at runtime; the table
|
|
88
|
+
// sits next to it. import.meta.url gives the file:// URL of the compiled
|
|
89
|
+
// .mjs.
|
|
90
|
+
try {
|
|
91
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
92
|
+
const tablePath = join(here, "route-table.json");
|
|
93
|
+
const raw = readFileSync(tablePath, "utf8");
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (!parsed || !Array.isArray(parsed.rows))
|
|
96
|
+
return null;
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function findMatch(prompt, table) {
|
|
104
|
+
for (let i = 0; i < table.rows.length; i++) {
|
|
105
|
+
const row = table.rows[i];
|
|
106
|
+
if (!row || typeof row.pattern !== "string" || typeof row.skill !== "string") {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
let rx;
|
|
110
|
+
try {
|
|
111
|
+
rx = new RegExp(row.pattern, row.flags ?? "");
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (rx.test(prompt)) {
|
|
117
|
+
return { row, index: i };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
function buildContext(row) {
|
|
123
|
+
const reason = row.reason
|
|
124
|
+
? `${row.reason}\nConsider invoking \`/${row.skill}\` now.`
|
|
125
|
+
: `Consider invoking \`/${row.skill}\` now.`;
|
|
126
|
+
return `<system-reminder>\nPrompt-router suggestion: ${reason}\n</system-reminder>`;
|
|
127
|
+
}
|
|
128
|
+
function emit(output) {
|
|
129
|
+
if (output !== null) {
|
|
130
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
131
|
+
}
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
function main() {
|
|
135
|
+
const raw = readStdin();
|
|
136
|
+
let payload;
|
|
137
|
+
try {
|
|
138
|
+
payload = JSON.parse(raw);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
emit(null);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const prompt = payload.prompt;
|
|
145
|
+
if (typeof prompt !== "string" || prompt.length === 0) {
|
|
146
|
+
emit(null);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const table = loadRouteTable();
|
|
150
|
+
if (!table) {
|
|
151
|
+
emit(null);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const match = findMatch(prompt, table);
|
|
155
|
+
const home = resolveHome();
|
|
156
|
+
if (!match) {
|
|
157
|
+
writeTelemetry(home, {
|
|
158
|
+
ts: new Date().toISOString(),
|
|
159
|
+
hook: "route-prompt",
|
|
160
|
+
matched: false,
|
|
161
|
+
});
|
|
162
|
+
emit(null);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
writeTelemetry(home, {
|
|
166
|
+
ts: new Date().toISOString(),
|
|
167
|
+
hook: "route-prompt",
|
|
168
|
+
matched: true,
|
|
169
|
+
row_index: match.index,
|
|
170
|
+
skill: match.row.skill,
|
|
171
|
+
pattern: match.row.pattern,
|
|
172
|
+
});
|
|
173
|
+
emit({
|
|
174
|
+
hookSpecificOutput: {
|
|
175
|
+
hookEventName: "UserPromptSubmit",
|
|
176
|
+
additionalContext: buildContext(match.row),
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
main();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Declarative prompt-router for the continuous-improvement UserPromptSubmit hook. Each row maps a regex (prompt-side trigger) to a skill the agent should consider invoking. First match wins; rows are evaluated in order. Edit this file to grow the routing table — no code change required.",
|
|
3
|
+
"rows": [
|
|
4
|
+
{
|
|
5
|
+
"pattern": "\\b(write|add|new)\\s+(a\\s+)?(failing\\s+)?test\\b|\\btdd\\b|\\bred[- ]green[- ]refactor\\b",
|
|
6
|
+
"flags": "i",
|
|
7
|
+
"skill": "tdd-workflow",
|
|
8
|
+
"reason": "Prompt mentions writing tests, TDD, or RED-GREEN-REFACTOR."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"pattern": "\\b(verify|verification|prove\\s+it\\s+works|smallest\\s+check|check\\s+it\\s+passes)\\b",
|
|
12
|
+
"flags": "i",
|
|
13
|
+
"skill": "verification-loop",
|
|
14
|
+
"reason": "Prompt asks for verification before declaring done."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"pattern": "\\b(plan|blueprint|design\\s+(the\\s+)?approach|step[- ]by[- ]step\\s+plan|roadmap)\\b",
|
|
18
|
+
"flags": "i",
|
|
19
|
+
"skill": "planning-with-files",
|
|
20
|
+
"reason": "Prompt asks for a plan, blueprint, or design before implementation."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"pattern": "\\b(security\\s+review|threat\\s+model|owasp|vulnerab|auth(entication|orization)\\s+check)\\b",
|
|
24
|
+
"flags": "i",
|
|
25
|
+
"skill": "security-review",
|
|
26
|
+
"reason": "Prompt touches security review, threat modeling, or auth surface."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"pattern": "\\b(code\\s+review|review\\s+(my|the|this)\\s+(code|pr|diff|patch)|critique\\s+(the|this)\\s+code)\\b",
|
|
30
|
+
"flags": "i",
|
|
31
|
+
"skill": "code-review",
|
|
32
|
+
"reason": "Prompt asks for a code review on existing changes."
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -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
|
}
|
package/lib/cli-anything.mjs
CHANGED
|
File without changes
|
|
File without changes
|
package/lib/gateguard-state.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function resolveSessionDir() {
|
|
|
29
29
|
return fromEnv;
|
|
30
30
|
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
31
31
|
const projectRoot = resolveProjectRoot();
|
|
32
|
-
const projectHash = createHash("sha256").update(projectRoot).digest("hex").slice(0, 12);
|
|
32
|
+
const projectHash = createHash("sha256").update(canonicalizeProjectRoot(projectRoot)).digest("hex").slice(0, 12);
|
|
33
33
|
return join(home, ".claude", "instincts", projectHash);
|
|
34
34
|
}
|
|
35
35
|
function resolveProjectRoot() {
|
|
@@ -79,7 +79,59 @@ export function markFileCleared(state, filePath) {
|
|
|
79
79
|
...state,
|
|
80
80
|
cleared_files: {
|
|
81
81
|
...state.cleared_files,
|
|
82
|
-
[filePath]: { cleared_at: new Date().toISOString() },
|
|
82
|
+
[canonicalizeFileKey(filePath)]: { cleared_at: new Date().toISOString() },
|
|
83
83
|
},
|
|
84
84
|
};
|
|
85
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
|
+
}
|