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.
Files changed (160) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -21
  4. package/QUICKSTART.md +13 -8
  5. package/README.md +94 -162
  6. package/SKILL.md +1 -1
  7. package/bin/analyze.sh +161 -161
  8. package/bin/backfill.mjs +19 -19
  9. package/bin/check-docs-substrings.mjs +73 -0
  10. package/bin/check-scripts-citation-drift.mjs +210 -0
  11. package/bin/check-skill-count.mjs +110 -0
  12. package/bin/check-test-imports-only.mjs +126 -0
  13. package/bin/check-tool-count.mjs +129 -0
  14. package/bin/companion-preference-status.mjs +231 -0
  15. package/bin/gateguard-clear.mjs +53 -0
  16. package/bin/generate-plugin-manifests.mjs +8 -1
  17. package/bin/harvest-friction.mjs +1 -1
  18. package/bin/hook-stats.mjs +21 -21
  19. package/bin/install.mjs +185 -28
  20. package/bin/mcp-server.mjs +216 -3
  21. package/bin/refresh-third-party.mjs +315 -313
  22. package/commands/audit.md +34 -0
  23. package/commands/companion-preference.md +58 -0
  24. package/commands/continuous-improvement.md +115 -115
  25. package/commands/dashboard.md +56 -56
  26. package/commands/discipline.md +14 -0
  27. package/commands/distill.md +39 -0
  28. package/commands/goal-check.md +53 -0
  29. package/commands/grill-me.md +40 -0
  30. package/commands/grill-with-docs.md +38 -0
  31. package/commands/handoff.md +42 -0
  32. package/commands/harvest.md +1 -1
  33. package/commands/proceed-with-the-recommendation.md +20 -0
  34. package/commands/recall.md +49 -0
  35. package/commands/reconcile.md +47 -0
  36. package/commands/seven-laws.md +16 -16
  37. package/commands/superpowers.md +3 -3
  38. package/commands/verify-install.md +55 -0
  39. package/commands/workspace-surface-audit.md +77 -77
  40. package/hooks/companion-preference.mjs +190 -0
  41. package/hooks/gateguard.mjs +72 -25
  42. package/hooks/goal-drift-stop.mjs +183 -0
  43. package/hooks/observe.sh +15 -1
  44. package/hooks/recall-briefing.mjs +167 -0
  45. package/hooks/route-prompt.mjs +180 -0
  46. package/hooks/route-table.json +35 -0
  47. package/hooks/session.sh +106 -106
  48. package/hooks/three-section-close.mjs +134 -132
  49. package/instinct-packs/go.json +58 -58
  50. package/instinct-packs/meta.json +16 -16
  51. package/instinct-packs/python.json +58 -58
  52. package/instinct-packs/react.json +58 -58
  53. package/lib/gateguard-state.mjs +54 -2
  54. package/lib/goal-drift-gate.mjs +50 -0
  55. package/lib/goal-state.mjs +285 -0
  56. package/lib/plugin-metadata.mjs +134 -15
  57. package/lib/recall-briefing.mjs +57 -0
  58. package/lib/recall-index.mjs +175 -0
  59. package/lib/skill-distill.mjs +222 -0
  60. package/llms.txt +2 -2
  61. package/package.json +12 -7
  62. package/plugins/beginner.json +9 -4
  63. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
  64. package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
  65. package/plugins/continuous-improvement/LICENSE +21 -21
  66. package/plugins/continuous-improvement/agents/README.md +3 -3
  67. package/plugins/continuous-improvement/bin/backfill.mjs +19 -19
  68. package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
  69. package/plugins/continuous-improvement/bin/mcp-server.mjs +216 -3
  70. package/plugins/continuous-improvement/commands/audit.md +34 -0
  71. package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
  72. package/plugins/continuous-improvement/commands/continuous-improvement.md +115 -115
  73. package/plugins/continuous-improvement/commands/dashboard.md +56 -56
  74. package/plugins/continuous-improvement/commands/discipline.md +14 -0
  75. package/plugins/continuous-improvement/commands/distill.md +39 -0
  76. package/plugins/continuous-improvement/commands/goal-check.md +53 -0
  77. package/plugins/continuous-improvement/commands/grill-me.md +40 -0
  78. package/plugins/continuous-improvement/commands/grill-with-docs.md +38 -0
  79. package/plugins/continuous-improvement/commands/handoff.md +42 -0
  80. package/plugins/continuous-improvement/commands/harvest.md +1 -1
  81. package/plugins/continuous-improvement/commands/proceed-with-the-recommendation.md +20 -0
  82. package/plugins/continuous-improvement/commands/recall.md +49 -0
  83. package/plugins/continuous-improvement/commands/reconcile.md +47 -0
  84. package/plugins/continuous-improvement/commands/seven-laws.md +16 -16
  85. package/plugins/continuous-improvement/commands/superpowers.md +3 -3
  86. package/plugins/continuous-improvement/commands/verify-install.md +55 -0
  87. package/plugins/continuous-improvement/commands/workspace-surface-audit.md +77 -77
  88. package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
  89. package/plugins/continuous-improvement/hooks/gateguard.mjs +72 -25
  90. package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
  91. package/plugins/continuous-improvement/hooks/hooks.json +23 -2
  92. package/plugins/continuous-improvement/hooks/observe.sh +15 -1
  93. package/plugins/continuous-improvement/hooks/recall-briefing.mjs +167 -0
  94. package/plugins/continuous-improvement/hooks/route-prompt.mjs +180 -0
  95. package/plugins/continuous-improvement/hooks/route-table.json +35 -0
  96. package/plugins/continuous-improvement/hooks/session.sh +106 -106
  97. package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
  98. package/plugins/continuous-improvement/instinct-packs/go.json +58 -58
  99. package/plugins/continuous-improvement/instinct-packs/meta.json +16 -16
  100. package/plugins/continuous-improvement/instinct-packs/python.json +58 -58
  101. package/plugins/continuous-improvement/instinct-packs/react.json +58 -58
  102. package/plugins/continuous-improvement/lib/gateguard-state.mjs +137 -0
  103. package/plugins/continuous-improvement/lib/goal-drift-gate.mjs +50 -0
  104. package/plugins/continuous-improvement/lib/goal-state.mjs +285 -0
  105. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +134 -15
  106. package/plugins/continuous-improvement/lib/recall-briefing.mjs +57 -0
  107. package/plugins/continuous-improvement/lib/recall-index.mjs +175 -0
  108. package/plugins/continuous-improvement/lib/skill-distill.mjs +222 -0
  109. package/plugins/continuous-improvement/skills/README.md +8 -0
  110. package/plugins/continuous-improvement/skills/audit/SKILL.md +73 -0
  111. package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +1 -1
  112. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +13 -17
  113. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +36 -17
  114. package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +81 -0
  115. package/plugins/continuous-improvement/skills/grill-me/SKILL.md +66 -0
  116. package/plugins/continuous-improvement/skills/grill-with-docs/SKILL.md +252 -0
  117. package/plugins/continuous-improvement/skills/handoff/SKILL.md +59 -0
  118. package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +1 -1
  119. package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +82 -6
  120. package/plugins/continuous-improvement/skills/ralph/SKILL.md +1 -0
  121. package/plugins/continuous-improvement/skills/recall/SKILL.md +50 -0
  122. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +80 -0
  123. package/plugins/continuous-improvement/skills/recovery-classification/SKILL.md +1 -0
  124. package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +1 -0
  125. package/plugins/continuous-improvement/skills/skill-distillation/SKILL.md +57 -0
  126. package/plugins/continuous-improvement/skills/state-reconciliation/SKILL.md +1 -0
  127. package/plugins/continuous-improvement/skills/strategic-compact/SKILL.md +1 -0
  128. package/plugins/continuous-improvement/skills/superpowers/SKILL.md +77 -3
  129. package/plugins/continuous-improvement/skills/tdd-workflow/SKILL.md +411 -411
  130. package/plugins/continuous-improvement/skills/token-budget-advisor/SKILL.md +1 -1
  131. package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +28 -20
  132. package/plugins/continuous-improvement/skills/wild-risa-balance/SKILL.md +1 -0
  133. package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
  134. package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +3 -2
  135. package/plugins/expert.json +28 -3
  136. package/skills/README.md +3 -0
  137. package/skills/audit.md +73 -0
  138. package/skills/deploy-receipt.md +13 -17
  139. package/skills/gateguard.md +36 -17
  140. package/skills/goal-monitor.md +81 -0
  141. package/skills/grill-me.md +66 -0
  142. package/skills/grill-with-docs.md +252 -0
  143. package/skills/handoff.md +59 -0
  144. package/skills/para-memory-files.md +1 -1
  145. package/skills/proceed-with-the-recommendation.md +82 -6
  146. package/skills/ralph.md +1 -0
  147. package/skills/recall.md +50 -0
  148. package/skills/reconcile.md +80 -0
  149. package/skills/recovery-classification.md +1 -0
  150. package/skills/safety-guard.md +1 -0
  151. package/skills/skill-distillation.md +57 -0
  152. package/skills/state-reconciliation.md +1 -0
  153. package/skills/strategic-compact.md +1 -0
  154. package/skills/superpowers.md +77 -3
  155. package/skills/tdd-workflow.md +411 -411
  156. package/skills/token-budget-advisor.md +1 -1
  157. package/skills/verification-loop.md +28 -20
  158. package/skills/wild-risa-balance.md +1 -0
  159. package/skills/workspace-surface-audit.md +1 -1
  160. 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
+ ]
@@ -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
+ }
@@ -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
+ }