continuous-improvement 3.9.2 → 3.12.3
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 +3 -3
- package/CHANGELOG.md +95 -0
- package/LICENSE +21 -21
- package/QUICKSTART.md +13 -8
- package/README.md +94 -162
- package/SKILL.md +1 -1
- package/bin/analyze.sh +161 -161
- package/bin/backfill.mjs +19 -19
- package/bin/check-docs-substrings.mjs +73 -0
- package/bin/check-scripts-citation-drift.mjs +210 -0
- package/bin/check-skill-count.mjs +110 -0
- package/bin/check-test-imports-only.mjs +126 -0
- package/bin/check-tool-count.mjs +129 -0
- package/bin/companion-preference-status.mjs +231 -0
- package/bin/gateguard-clear.mjs +53 -0
- package/bin/generate-plugin-manifests.mjs +8 -1
- package/bin/harvest-friction.mjs +1 -1
- package/bin/hook-stats.mjs +21 -21
- package/bin/install.mjs +185 -28
- package/bin/mcp-server.mjs +216 -3
- package/bin/refresh-third-party.mjs +315 -313
- package/commands/audit.md +34 -0
- package/commands/companion-preference.md +58 -0
- package/commands/continuous-improvement.md +115 -115
- package/commands/dashboard.md +56 -56
- 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/harvest.md +1 -1
- package/commands/proceed-with-the-recommendation.md +20 -0
- package/commands/recall.md +49 -0
- package/commands/reconcile.md +47 -0
- package/commands/seven-laws.md +16 -16
- package/commands/superpowers.md +3 -3
- package/commands/verify-install.md +55 -0
- package/commands/workspace-surface-audit.md +77 -77
- package/hooks/companion-preference.mjs +190 -0
- package/hooks/gateguard.mjs +72 -25
- package/hooks/goal-drift-stop.mjs +183 -0
- package/hooks/observe.sh +15 -1
- package/hooks/recall-briefing.mjs +167 -0
- package/hooks/route-prompt.mjs +180 -0
- package/hooks/route-table.json +35 -0
- package/hooks/session.sh +106 -106
- package/hooks/three-section-close.mjs +134 -132
- package/instinct-packs/go.json +58 -58
- package/instinct-packs/meta.json +16 -16
- package/instinct-packs/python.json +58 -58
- package/instinct-packs/react.json +58 -58
- package/lib/gateguard-state.mjs +54 -2
- package/lib/goal-drift-gate.mjs +50 -0
- package/lib/goal-state.mjs +285 -0
- package/lib/plugin-metadata.mjs +134 -15
- package/lib/recall-briefing.mjs +57 -0
- package/lib/recall-index.mjs +175 -0
- package/lib/skill-distill.mjs +222 -0
- package/llms.txt +2 -2
- package/package.json +12 -7
- package/plugins/beginner.json +9 -4
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
- package/plugins/continuous-improvement/LICENSE +21 -21
- package/plugins/continuous-improvement/agents/README.md +3 -3
- package/plugins/continuous-improvement/bin/backfill.mjs +19 -19
- package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
- package/plugins/continuous-improvement/bin/mcp-server.mjs +216 -3
- package/plugins/continuous-improvement/commands/audit.md +34 -0
- package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
- package/plugins/continuous-improvement/commands/continuous-improvement.md +115 -115
- package/plugins/continuous-improvement/commands/dashboard.md +56 -56
- 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/harvest.md +1 -1
- 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/seven-laws.md +16 -16
- package/plugins/continuous-improvement/commands/superpowers.md +3 -3
- package/plugins/continuous-improvement/commands/verify-install.md +55 -0
- package/plugins/continuous-improvement/commands/workspace-surface-audit.md +77 -77
- package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
- package/plugins/continuous-improvement/hooks/gateguard.mjs +72 -25
- package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
- package/plugins/continuous-improvement/hooks/hooks.json +23 -2
- package/plugins/continuous-improvement/hooks/observe.sh +15 -1
- package/plugins/continuous-improvement/hooks/recall-briefing.mjs +167 -0
- 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/session.sh +106 -106
- package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
- package/plugins/continuous-improvement/instinct-packs/go.json +58 -58
- package/plugins/continuous-improvement/instinct-packs/meta.json +16 -16
- package/plugins/continuous-improvement/instinct-packs/python.json +58 -58
- package/plugins/continuous-improvement/instinct-packs/react.json +58 -58
- 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/plugin-metadata.mjs +134 -15
- package/plugins/continuous-improvement/lib/recall-briefing.mjs +57 -0
- package/plugins/continuous-improvement/lib/recall-index.mjs +175 -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/continuous-improvement/SKILL.md +1 -1
- 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 +77 -3
- package/plugins/continuous-improvement/skills/tdd-workflow/SKILL.md +411 -411
- 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 +28 -3
- 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 +77 -3
- package/skills/tdd-workflow.md +411 -411
- 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,58 +1,58 @@
|
|
|
1
|
-
[
|
|
2
|
-
{
|
|
3
|
-
"id": "react-check-existing-components",
|
|
4
|
-
"trigger": "when creating a new React component",
|
|
5
|
-
"body": "Search the codebase for existing components that solve the same problem before creating new ones. Check shared/, components/, and ui/ directories.",
|
|
6
|
-
"confidence": 0.65,
|
|
7
|
-
"domain": "workflow"
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"id": "react-prefer-server-components",
|
|
11
|
-
"trigger": "when creating components in Next.js App Router",
|
|
12
|
-
"body": "Default to Server Components. Only add 'use client' when the component needs useState, useEffect, event handlers, or browser APIs.",
|
|
13
|
-
"confidence": 0.7,
|
|
14
|
-
"domain": "patterns"
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"id": "react-key-prop-lists",
|
|
18
|
-
"trigger": "when rendering lists with .map()",
|
|
19
|
-
"body": "Always use a stable, unique key prop. Never use array index as key unless the list is static and never reordered.",
|
|
20
|
-
"confidence": 0.7,
|
|
21
|
-
"domain": "patterns"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"id": "react-effect-cleanup",
|
|
25
|
-
"trigger": "when writing useEffect with subscriptions or timers",
|
|
26
|
-
"body": "Always return a cleanup function from useEffect when setting up subscriptions, event listeners, or timers to prevent memory leaks.",
|
|
27
|
-
"confidence": 0.7,
|
|
28
|
-
"domain": "patterns"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"id": "react-memo-expensive",
|
|
32
|
-
"trigger": "when a component re-renders with expensive calculations",
|
|
33
|
-
"body": "Use useMemo for expensive computations and React.memo for components that receive the same props frequently. Don't memo everything — only what's measurably slow.",
|
|
34
|
-
"confidence": 0.6,
|
|
35
|
-
"domain": "patterns"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"id": "react-form-validation",
|
|
39
|
-
"trigger": "when building forms",
|
|
40
|
-
"body": "Check if react-hook-form or zod is already in the project before building custom form validation. Prefer library solutions over hand-rolled validation.",
|
|
41
|
-
"confidence": 0.65,
|
|
42
|
-
"domain": "tooling"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"id": "react-error-boundary",
|
|
46
|
-
"trigger": "when adding a new page or route",
|
|
47
|
-
"body": "Ensure error boundaries exist around new pages/routes. In Next.js App Router, add error.tsx. In other React apps, wrap with ErrorBoundary component.",
|
|
48
|
-
"confidence": 0.6,
|
|
49
|
-
"domain": "patterns"
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
"id": "react-test-user-behavior",
|
|
53
|
-
"trigger": "when writing React component tests",
|
|
54
|
-
"body": "Test user behavior, not implementation details. Use @testing-library/react. Query by role, label, or text — not by class name or test ID.",
|
|
55
|
-
"confidence": 0.65,
|
|
56
|
-
"domain": "testing"
|
|
57
|
-
}
|
|
58
|
-
]
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "react-check-existing-components",
|
|
4
|
+
"trigger": "when creating a new React component",
|
|
5
|
+
"body": "Search the codebase for existing components that solve the same problem before creating new ones. Check shared/, components/, and ui/ directories.",
|
|
6
|
+
"confidence": 0.65,
|
|
7
|
+
"domain": "workflow"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "react-prefer-server-components",
|
|
11
|
+
"trigger": "when creating components in Next.js App Router",
|
|
12
|
+
"body": "Default to Server Components. Only add 'use client' when the component needs useState, useEffect, event handlers, or browser APIs.",
|
|
13
|
+
"confidence": 0.7,
|
|
14
|
+
"domain": "patterns"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "react-key-prop-lists",
|
|
18
|
+
"trigger": "when rendering lists with .map()",
|
|
19
|
+
"body": "Always use a stable, unique key prop. Never use array index as key unless the list is static and never reordered.",
|
|
20
|
+
"confidence": 0.7,
|
|
21
|
+
"domain": "patterns"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "react-effect-cleanup",
|
|
25
|
+
"trigger": "when writing useEffect with subscriptions or timers",
|
|
26
|
+
"body": "Always return a cleanup function from useEffect when setting up subscriptions, event listeners, or timers to prevent memory leaks.",
|
|
27
|
+
"confidence": 0.7,
|
|
28
|
+
"domain": "patterns"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "react-memo-expensive",
|
|
32
|
+
"trigger": "when a component re-renders with expensive calculations",
|
|
33
|
+
"body": "Use useMemo for expensive computations and React.memo for components that receive the same props frequently. Don't memo everything — only what's measurably slow.",
|
|
34
|
+
"confidence": 0.6,
|
|
35
|
+
"domain": "patterns"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "react-form-validation",
|
|
39
|
+
"trigger": "when building forms",
|
|
40
|
+
"body": "Check if react-hook-form or zod is already in the project before building custom form validation. Prefer library solutions over hand-rolled validation.",
|
|
41
|
+
"confidence": 0.65,
|
|
42
|
+
"domain": "tooling"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "react-error-boundary",
|
|
46
|
+
"trigger": "when adding a new page or route",
|
|
47
|
+
"body": "Ensure error boundaries exist around new pages/routes. In Next.js App Router, add error.tsx. In other React apps, wrap with ErrorBoundary component.",
|
|
48
|
+
"confidence": 0.6,
|
|
49
|
+
"domain": "patterns"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "react-test-user-behavior",
|
|
53
|
+
"trigger": "when writing React component tests",
|
|
54
|
+
"body": "Test user behavior, not implementation details. Use @testing-library/react. Query by role, label, or text — not by class name or test ID.",
|
|
55
|
+
"confidence": 0.65,
|
|
56
|
+
"domain": "testing"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// goal-state.mts — Pure goal-drift scorer for the Clear Goal discipline.
|
|
2
|
+
//
|
|
3
|
+
// No I/O. Turns a task_plan.md body + a list of recent observation rows into a
|
|
4
|
+
// DriftReport that says whether recent tool activity still relates to the
|
|
5
|
+
// stated goal. The MCP tool ci_goal_check (src/bin/mcp-server.mts) wires the
|
|
6
|
+
// file reads around these functions; keeping the logic pure lets the unit
|
|
7
|
+
// tests cover the scoring edges without touching the filesystem.
|
|
8
|
+
//
|
|
9
|
+
// Goal source: a markdown plan with a `## Goal` section (seeded by ci_plan_init).
|
|
10
|
+
// Two optional sections sharpen the signal:
|
|
11
|
+
// ## Goal Keywords — comma/newline list; overrides auto-extraction
|
|
12
|
+
// ## Goal Scope — `paths:` and `forbidden:` glob lists
|
|
13
|
+
// When ## Goal Keywords is absent, keywords are auto-extracted from the prose.
|
|
14
|
+
const STOPWORDS = new Set([
|
|
15
|
+
"the", "and", "for", "with", "that", "this", "from", "into", "your", "will",
|
|
16
|
+
"should", "must", "have", "has", "are", "was", "were", "but", "not", "all",
|
|
17
|
+
"any", "can", "use", "using", "used", "via", "per", "out", "its", "their",
|
|
18
|
+
"them", "then", "than", "when", "what", "which", "who", "how", "why", "where",
|
|
19
|
+
"make", "made", "adds", "new", "get", "got", "set", "also", "only", "each",
|
|
20
|
+
"task", "goal", "plan", "work", "working", "build", "building", "code",
|
|
21
|
+
]);
|
|
22
|
+
// 4 suits Latin/Cyrillic/Thai prose (Thai combining marks are now kept; see
|
|
23
|
+
// extractKeywordsFromProse regex), but it silently drops most Korean words
|
|
24
|
+
// (typically 2 chars). A script-aware floor for Korean is a logged follow-up
|
|
25
|
+
// (see CLAUDE.md Deferred).
|
|
26
|
+
const KEYWORD_MIN_LENGTH = 4;
|
|
27
|
+
const KEYWORD_CAP = 20;
|
|
28
|
+
const DEFAULT_WINDOW = 30;
|
|
29
|
+
const DEFAULT_THRESHOLD = 0.3;
|
|
30
|
+
const MAX_MISMATCHES = 5;
|
|
31
|
+
/**
|
|
32
|
+
* Extract the body of a `## Heading` section. Stops at the next heading of any
|
|
33
|
+
* level. Heading match is case-insensitive on the trimmed text. Returns "" when
|
|
34
|
+
* the section is absent or empty.
|
|
35
|
+
*/
|
|
36
|
+
export function getSection(markdown, heading) {
|
|
37
|
+
const lines = markdown.split(/\r?\n/);
|
|
38
|
+
const out = [];
|
|
39
|
+
let inSection = false;
|
|
40
|
+
const wanted = heading.trim().toLowerCase();
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
43
|
+
if (inSection)
|
|
44
|
+
break;
|
|
45
|
+
inSection = line.replace(/^#{1,6}\s+/, "").trim().toLowerCase() === wanted;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (inSection)
|
|
49
|
+
out.push(line);
|
|
50
|
+
}
|
|
51
|
+
return out.join("\n").trim();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Tokenize prose into goal keywords: lowercase, split on any non-letter /
|
|
55
|
+
* non-number / non-mark (Unicode-aware, so accented Latin / Cyrillic / CJK /
|
|
56
|
+
* Thai survive), drop stopwords, pure-digit tokens, and tokens shorter than
|
|
57
|
+
* KEYWORD_MIN_LENGTH. Deduped, capped at KEYWORD_CAP.
|
|
58
|
+
*/
|
|
59
|
+
export function extractKeywordsFromProse(prose) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const raw of prose.toLowerCase().split(/[^\p{L}\p{N}\p{M}]+/u)) {
|
|
63
|
+
const word = raw.trim();
|
|
64
|
+
if (word.length < KEYWORD_MIN_LENGTH)
|
|
65
|
+
continue;
|
|
66
|
+
if (/^\p{N}+$/u.test(word))
|
|
67
|
+
continue;
|
|
68
|
+
if (STOPWORDS.has(word))
|
|
69
|
+
continue;
|
|
70
|
+
if (seen.has(word))
|
|
71
|
+
continue;
|
|
72
|
+
seen.add(word);
|
|
73
|
+
out.push(word);
|
|
74
|
+
if (out.length >= KEYWORD_CAP)
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function parseKeywordList(body) {
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const token of body.split(/[,\n]/)) {
|
|
83
|
+
const word = token.replace(/^[-*\s]+/, "").trim().toLowerCase();
|
|
84
|
+
if (!word || seen.has(word))
|
|
85
|
+
continue;
|
|
86
|
+
seen.add(word);
|
|
87
|
+
out.push(word);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function parseScopeField(scopeBody, field) {
|
|
92
|
+
for (const line of scopeBody.split(/\r?\n/)) {
|
|
93
|
+
const match = line.match(/^\s*[-*]?\s*([A-Za-z_]+)\s*:\s*(.+)$/);
|
|
94
|
+
if (match && match[1].toLowerCase() === field) {
|
|
95
|
+
return match[2]
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((glob) => glob.trim())
|
|
98
|
+
.filter((glob) => glob.length > 0);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse a plan markdown into a GoalSpec. Returns null when there is no `## Goal`
|
|
105
|
+
* section (the scorer needs a goal to score against).
|
|
106
|
+
*/
|
|
107
|
+
export function parseGoalFromPlan(planMarkdown) {
|
|
108
|
+
const prose = getSection(planMarkdown, "Goal");
|
|
109
|
+
if (!prose)
|
|
110
|
+
return null;
|
|
111
|
+
const keywordsSection = getSection(planMarkdown, "Goal Keywords");
|
|
112
|
+
const scopeSection = getSection(planMarkdown, "Goal Scope");
|
|
113
|
+
const parsedKeywords = keywordsSection ? parseKeywordList(keywordsSection) : [];
|
|
114
|
+
// An empty/malformed Goal Keywords section (blank bullets, commas-only) must
|
|
115
|
+
// degrade to prose extraction, exactly like an absent section — otherwise the
|
|
116
|
+
// scorer runs with zero keywords and reports all on-goal work as drift.
|
|
117
|
+
const keywords = parsedKeywords.length > 0 ? parsedKeywords : extractKeywordsFromProse(prose);
|
|
118
|
+
return {
|
|
119
|
+
prose,
|
|
120
|
+
keywords,
|
|
121
|
+
paths: scopeSection ? parseScopeField(scopeSection, "paths") : [],
|
|
122
|
+
forbidden: scopeSection ? parseScopeField(scopeSection, "forbidden") : [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function globToRegExp(glob) {
|
|
126
|
+
let re = "";
|
|
127
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
128
|
+
const char = glob[i];
|
|
129
|
+
if (char === "*") {
|
|
130
|
+
if (glob[i + 1] === "*") {
|
|
131
|
+
re += ".*";
|
|
132
|
+
i += 1;
|
|
133
|
+
if (glob[i + 1] === "/")
|
|
134
|
+
i += 1;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
re += "[^/]*";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if ("\\^$.|?+()[]{}".includes(char)) {
|
|
141
|
+
re += `\\${char}`;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
re += char;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return new RegExp(`^${re}$`);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether a (possibly absolute) path matches a relative glob. Tries the full
|
|
151
|
+
* normalized path and every `/`-boundary suffix, so `src/auth/**` matches both
|
|
152
|
+
* `src/auth/x.ts` and `/home/me/repo/src/auth/x.ts`.
|
|
153
|
+
*/
|
|
154
|
+
export function pathMatchesGlob(rawPath, glob) {
|
|
155
|
+
const path = rawPath.replace(/\\/g, "/");
|
|
156
|
+
const re = globToRegExp(glob);
|
|
157
|
+
if (re.test(path))
|
|
158
|
+
return true;
|
|
159
|
+
const parts = path.split("/");
|
|
160
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
161
|
+
if (re.test(parts.slice(i).join("/")))
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Score the most recent `window` observations against the goal. An observation
|
|
168
|
+
* "matches" when a goal keyword appears in its tool name / input / output, or
|
|
169
|
+
* its input path falls under a `## Goal Scope` paths glob. An input under a
|
|
170
|
+
* `forbidden` glob is a hard drift signal regardless of keywords.
|
|
171
|
+
*/
|
|
172
|
+
export function scoreObservations(observations, goal, opts = {}) {
|
|
173
|
+
// Reject an explicitly-provided out-of-range window instead of silently
|
|
174
|
+
// coercing it to the default — an operator typo (window=0, -5, 2.5) must not
|
|
175
|
+
// read as "unset". An absent window legitimately means "use the default".
|
|
176
|
+
if (opts.window !== undefined && (!Number.isInteger(opts.window) || opts.window <= 0)) {
|
|
177
|
+
throw new RangeError(`window must be a positive integer; got ${opts.window}. Omit it to use the default of ${DEFAULT_WINDOW}.`);
|
|
178
|
+
}
|
|
179
|
+
const window = opts.window ?? DEFAULT_WINDOW;
|
|
180
|
+
const threshold = typeof opts.threshold === "number" && Number.isFinite(opts.threshold)
|
|
181
|
+
? opts.threshold
|
|
182
|
+
: DEFAULT_THRESHOLD;
|
|
183
|
+
const recent = observations.slice(-window);
|
|
184
|
+
const total = recent.length;
|
|
185
|
+
if (total === 0) {
|
|
186
|
+
return {
|
|
187
|
+
status: "no-data",
|
|
188
|
+
score: 0,
|
|
189
|
+
threshold,
|
|
190
|
+
matching: 0,
|
|
191
|
+
forbidden: 0,
|
|
192
|
+
total: 0,
|
|
193
|
+
goalKeywords: goal.keywords,
|
|
194
|
+
topMismatches: [],
|
|
195
|
+
reason: "No observations in the window yet.",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const keywords = goal.keywords.map((kw) => kw.toLowerCase()).filter((kw) => kw.length > 0);
|
|
199
|
+
let matching = 0;
|
|
200
|
+
let forbidden = 0;
|
|
201
|
+
const mismatches = [];
|
|
202
|
+
for (const obs of recent) {
|
|
203
|
+
const input = (obs.input_summary ?? "").toString();
|
|
204
|
+
const output = (obs.output_summary ?? "").toString();
|
|
205
|
+
const tool = (obs.tool ?? "").toString();
|
|
206
|
+
const haystack = `${tool}\n${input}\n${output}`.toLowerCase();
|
|
207
|
+
const record = { ts: (obs.ts ?? "").toString(), tool, summary: input || output };
|
|
208
|
+
const isForbidden = goal.forbidden.length > 0 &&
|
|
209
|
+
input.length > 0 &&
|
|
210
|
+
goal.forbidden.some((glob) => pathMatchesGlob(input, glob));
|
|
211
|
+
if (isForbidden) {
|
|
212
|
+
forbidden += 1;
|
|
213
|
+
mismatches.push(record);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const keywordHit = keywords.some((kw) => haystack.includes(kw));
|
|
217
|
+
const pathHit = goal.paths.length > 0 &&
|
|
218
|
+
input.length > 0 &&
|
|
219
|
+
goal.paths.some((glob) => pathMatchesGlob(input, glob));
|
|
220
|
+
if (keywordHit || pathHit) {
|
|
221
|
+
matching += 1;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
mismatches.push(record);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const score = matching / total;
|
|
228
|
+
let status;
|
|
229
|
+
let reason;
|
|
230
|
+
if (forbidden > 0) {
|
|
231
|
+
status = "drift";
|
|
232
|
+
reason = `${forbidden} of ${total} recent observations touched forbidden paths.`;
|
|
233
|
+
}
|
|
234
|
+
else if (score >= threshold) {
|
|
235
|
+
status = "on-goal";
|
|
236
|
+
reason = `${matching}/${total} recent observations relate to the goal (threshold ${threshold}).`;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
status = "drift";
|
|
240
|
+
reason = `Only ${matching}/${total} recent observations relate to the goal (threshold ${threshold}).`;
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
status,
|
|
244
|
+
score,
|
|
245
|
+
threshold,
|
|
246
|
+
matching,
|
|
247
|
+
forbidden,
|
|
248
|
+
total,
|
|
249
|
+
goalKeywords: goal.keywords,
|
|
250
|
+
topMismatches: mismatches.slice(-MAX_MISMATCHES).reverse(),
|
|
251
|
+
reason,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Render a DriftReport as a markdown block for the MCP tool output. Pure
|
|
256
|
+
* string -> string so the formatting is unit-testable.
|
|
257
|
+
*/
|
|
258
|
+
export function formatDriftReport(report) {
|
|
259
|
+
const label = report.status === "on-goal"
|
|
260
|
+
? "ON GOAL"
|
|
261
|
+
: report.status === "drift"
|
|
262
|
+
? "DRIFT"
|
|
263
|
+
: "NO DATA";
|
|
264
|
+
const pct = Math.round(report.score * 100);
|
|
265
|
+
const lines = [
|
|
266
|
+
`**Status:** ${label}`,
|
|
267
|
+
`**Score:** ${pct}% (${report.matching}/${report.total} matched, threshold ${Math.round(report.threshold * 100)}%)`,
|
|
268
|
+
`**Reason:** ${report.reason}`,
|
|
269
|
+
];
|
|
270
|
+
if (report.goalKeywords.length > 0) {
|
|
271
|
+
lines.push(`**Goal keywords:** ${report.goalKeywords.join(", ")}`);
|
|
272
|
+
}
|
|
273
|
+
if (report.topMismatches.length > 0) {
|
|
274
|
+
lines.push("", `**Top off-goal activity (most recent ${report.topMismatches.length}):**`);
|
|
275
|
+
for (const mismatch of report.topMismatches) {
|
|
276
|
+
const stamp = mismatch.ts ? `[${mismatch.ts}] ` : "";
|
|
277
|
+
const summary = mismatch.summary.length > 100 ? `${mismatch.summary.slice(0, 100)}…` : mismatch.summary;
|
|
278
|
+
lines.push(`- ${stamp}${mismatch.tool || "(tool?)"} — ${summary || "(no summary)"}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (report.status === "drift") {
|
|
282
|
+
lines.push("", "_Drift detected. Either steer back to the goal, or update the `## Goal` section in your plan if the goal has legitimately changed._");
|
|
283
|
+
}
|
|
284
|
+
return lines.join("\n");
|
|
285
|
+
}
|