continuous-improvement 3.9.2 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +61 -0
  3. package/QUICKSTART.md +13 -8
  4. package/README.md +84 -153
  5. package/bin/backfill.mjs +0 -0
  6. package/bin/check-doc-runtime-claims.mjs +0 -0
  7. package/bin/check-docs-substrings.mjs +73 -0
  8. package/bin/check-everything-mirror.mjs +0 -0
  9. package/bin/check-routing-targets.mjs +0 -0
  10. package/bin/check-scripts-citation-drift.mjs +210 -0
  11. package/bin/check-skill-count.mjs +110 -0
  12. package/bin/check-skill-law-tag.mjs +0 -0
  13. package/bin/check-skill-mirror.mjs +0 -0
  14. package/bin/check-skill-tiers.mjs +0 -0
  15. package/bin/check-test-imports-only.mjs +126 -0
  16. package/bin/check-third-party-shape.mjs +0 -0
  17. package/bin/companion-preference-status.mjs +231 -0
  18. package/bin/gateguard-clear.mjs +53 -0
  19. package/bin/generate-plugin-manifests.mjs +7 -1
  20. package/bin/harvest-friction.mjs +0 -0
  21. package/bin/hook-stats.mjs +0 -0
  22. package/bin/install.mjs +182 -26
  23. package/bin/lint-transcript.mjs +0 -0
  24. package/bin/mcp-server.mjs +215 -2
  25. package/bin/observe.mjs +0 -0
  26. package/bin/pre-commit-block-strays.sh +0 -0
  27. package/bin/refresh-third-party.mjs +315 -313
  28. package/bin/unified-cli.mjs +0 -0
  29. package/commands/audit.md +34 -0
  30. package/commands/companion-preference.md +58 -0
  31. package/commands/discipline.md +14 -0
  32. package/commands/distill.md +39 -0
  33. package/commands/goal-check.md +53 -0
  34. package/commands/grill-me.md +40 -0
  35. package/commands/grill-with-docs.md +38 -0
  36. package/commands/handoff.md +42 -0
  37. package/commands/proceed-with-the-recommendation.md +20 -0
  38. package/commands/recall.md +49 -0
  39. package/commands/reconcile.md +47 -0
  40. package/commands/superpowers.md +2 -2
  41. package/commands/verify-install.md +55 -0
  42. package/hooks/companion-preference.mjs +190 -0
  43. package/hooks/gateguard.mjs +60 -25
  44. package/hooks/goal-drift-stop.mjs +183 -0
  45. package/hooks/observe.sh +15 -1
  46. package/hooks/route-prompt.mjs +180 -0
  47. package/hooks/route-table.json +35 -0
  48. package/hooks/three-section-close.mjs +134 -132
  49. package/lib/cli-anything.mjs +0 -0
  50. package/lib/compound-engineering.mjs +0 -0
  51. package/lib/gateguard-state.mjs +54 -2
  52. package/lib/goal-drift-gate.mjs +50 -0
  53. package/lib/goal-state.mjs +285 -0
  54. package/lib/observe-event.mjs +0 -0
  55. package/lib/plugin-metadata.mjs +126 -12
  56. package/lib/pm-marketplace.mjs +0 -0
  57. package/lib/pm-skills.mjs +0 -0
  58. package/lib/recall-index.mjs +175 -0
  59. package/lib/resolve-home-dir.mjs +0 -0
  60. package/lib/skill-distill.mjs +222 -0
  61. package/lib/skill-tiers.mjs +0 -0
  62. package/lib/unified-plugin.mjs +0 -0
  63. package/llms.txt +1 -1
  64. package/package.json +11 -7
  65. package/plugins/beginner.json +8 -3
  66. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
  67. package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
  68. package/plugins/continuous-improvement/agents/README.md +2 -2
  69. package/plugins/continuous-improvement/bin/backfill.mjs +0 -0
  70. package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
  71. package/plugins/continuous-improvement/bin/mcp-server.mjs +215 -2
  72. package/plugins/continuous-improvement/bin/observe.mjs +0 -0
  73. package/plugins/continuous-improvement/commands/audit.md +34 -0
  74. package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
  75. package/plugins/continuous-improvement/commands/discipline.md +14 -0
  76. package/plugins/continuous-improvement/commands/distill.md +39 -0
  77. package/plugins/continuous-improvement/commands/goal-check.md +53 -0
  78. package/plugins/continuous-improvement/commands/grill-me.md +40 -0
  79. package/plugins/continuous-improvement/commands/grill-with-docs.md +38 -0
  80. package/plugins/continuous-improvement/commands/handoff.md +42 -0
  81. package/plugins/continuous-improvement/commands/proceed-with-the-recommendation.md +20 -0
  82. package/plugins/continuous-improvement/commands/recall.md +49 -0
  83. package/plugins/continuous-improvement/commands/reconcile.md +47 -0
  84. package/plugins/continuous-improvement/commands/superpowers.md +2 -2
  85. package/plugins/continuous-improvement/commands/verify-install.md +55 -0
  86. package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
  87. package/plugins/continuous-improvement/hooks/gateguard.mjs +60 -25
  88. package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
  89. package/plugins/continuous-improvement/hooks/hooks.json +18 -2
  90. package/plugins/continuous-improvement/hooks/observe.sh +15 -1
  91. package/plugins/continuous-improvement/hooks/route-prompt.mjs +180 -0
  92. package/plugins/continuous-improvement/hooks/route-table.json +35 -0
  93. package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
  94. package/plugins/continuous-improvement/lib/gateguard-state.mjs +137 -0
  95. package/plugins/continuous-improvement/lib/goal-drift-gate.mjs +50 -0
  96. package/plugins/continuous-improvement/lib/goal-state.mjs +285 -0
  97. package/plugins/continuous-improvement/lib/observe-event.mjs +0 -0
  98. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +126 -12
  99. package/plugins/continuous-improvement/lib/recall-index.mjs +175 -0
  100. package/plugins/continuous-improvement/lib/resolve-home-dir.mjs +0 -0
  101. package/plugins/continuous-improvement/lib/skill-distill.mjs +222 -0
  102. package/plugins/continuous-improvement/skills/README.md +8 -0
  103. package/plugins/continuous-improvement/skills/audit/SKILL.md +73 -0
  104. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +13 -17
  105. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +36 -17
  106. package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +81 -0
  107. package/plugins/continuous-improvement/skills/grill-me/SKILL.md +66 -0
  108. package/plugins/continuous-improvement/skills/grill-with-docs/SKILL.md +252 -0
  109. package/plugins/continuous-improvement/skills/handoff/SKILL.md +59 -0
  110. package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +1 -1
  111. package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +82 -6
  112. package/plugins/continuous-improvement/skills/ralph/SKILL.md +1 -0
  113. package/plugins/continuous-improvement/skills/recall/SKILL.md +50 -0
  114. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +80 -0
  115. package/plugins/continuous-improvement/skills/recovery-classification/SKILL.md +1 -0
  116. package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +1 -0
  117. package/plugins/continuous-improvement/skills/skill-distillation/SKILL.md +57 -0
  118. package/plugins/continuous-improvement/skills/state-reconciliation/SKILL.md +1 -0
  119. package/plugins/continuous-improvement/skills/strategic-compact/SKILL.md +1 -0
  120. package/plugins/continuous-improvement/skills/superpowers/SKILL.md +76 -2
  121. package/plugins/continuous-improvement/skills/token-budget-advisor/SKILL.md +1 -1
  122. package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +28 -20
  123. package/plugins/continuous-improvement/skills/wild-risa-balance/SKILL.md +1 -0
  124. package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
  125. package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +3 -2
  126. package/plugins/expert.json +27 -2
  127. package/skills/README.md +3 -0
  128. package/skills/audit.md +73 -0
  129. package/skills/deploy-receipt.md +13 -17
  130. package/skills/gateguard.md +36 -17
  131. package/skills/goal-monitor.md +81 -0
  132. package/skills/grill-me.md +66 -0
  133. package/skills/grill-with-docs.md +252 -0
  134. package/skills/handoff.md +59 -0
  135. package/skills/para-memory-files.md +1 -1
  136. package/skills/proceed-with-the-recommendation.md +82 -6
  137. package/skills/ralph.md +1 -0
  138. package/skills/recall.md +50 -0
  139. package/skills/reconcile.md +80 -0
  140. package/skills/recovery-classification.md +1 -0
  141. package/skills/safety-guard.md +1 -0
  142. package/skills/skill-distillation.md +57 -0
  143. package/skills/state-reconciliation.md +1 -0
  144. package/skills/strategic-compact.md +1 -0
  145. package/skills/superpowers.md +76 -2
  146. package/skills/token-budget-advisor.md +1 -1
  147. package/skills/verification-loop.md +28 -20
  148. package/skills/wild-risa-balance.md +1 -0
  149. package/skills/workspace-surface-audit.md +1 -1
  150. package/skills/worktree-safety.md +3 -2
@@ -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 prose, but it silently drops most Korean/Thai words
23
+ // (typically 2 chars) even though the Unicode splitter now keeps them — so the
24
+ // goal-state half of the tokenizer fix is only partial for those scripts. A
25
+ // script-aware floor is a logged follow-up (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 (Unicode-aware, so accented Latin / Cyrillic / CJK survive), drop
56
+ * stopwords, pure-digit tokens, and tokens shorter than KEYWORD_MIN_LENGTH.
57
+ * 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}]+/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
+ }
File without changes
@@ -26,7 +26,7 @@ const KEYWORDS = [
26
26
  "transcript-linter",
27
27
  ];
28
28
  const CLAUDE_PLUGIN_CATEGORY = "productivity";
29
- const SHARED_PLUGIN_DESCRIPTION = "Stops Claude Code from skipping research, claiming 'done' without verifying, and repeating yesterday's mistakes. The 7 Laws of AI Agent Discipline — 13 enforcement skills, gating hooks, and the Mulahazah auto-leveling instinct engine.";
29
+ const SHARED_PLUGIN_DESCRIPTION = "Stops Claude Code from skipping research, claiming 'done' without verifying, and repeating yesterday's mistakes. The 7 Laws of AI Agent Discipline — 25 bundled skills, gating hooks, the Mulahazah auto-leveling instinct engine, and a GitHub Action transcript linter.";
30
30
  // Four vendored upstream companions registered alongside the CI plugin.
31
31
  // Each entry points at a pinned-SHA snapshot under third-party/<name>/.
32
32
  // See third-party/MANIFEST.md for refresh recipes and per-snapshot
@@ -127,6 +127,26 @@ const BEGINNER_TOOL_ENTRIES = [
127
127
  required: ["summary"],
128
128
  },
129
129
  },
130
+ {
131
+ name: "ci_gateguard_clear",
132
+ description: "Clear the GateGuard gate for one or more files after presenting the required facts (importers, affected APIs, data schema, the user's instruction). Records canonical per-file clearance in the session state the hook reads, so the next Edit/Write to those paths is allowed. Clearance matches regardless of drive-letter case or path separator. Available in beginner mode because the gate fires for every install.",
133
+ manifestWhat: "Clear the GateGuard gate for files after presenting facts",
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ file_paths: {
138
+ type: "array",
139
+ description: "File paths to clear — the paths named in the GateGuard block reason",
140
+ items: { type: "string" },
141
+ },
142
+ file_path: {
143
+ type: "string",
144
+ description: "A single file path to clear (alternative to file_paths)",
145
+ },
146
+ },
147
+ required: [],
148
+ },
149
+ },
130
150
  ];
131
151
  const EXPERT_TOOL_ENTRIES = [
132
152
  {
@@ -282,17 +302,88 @@ const EXPERT_TOOL_ENTRIES = [
282
302
  required: ["pack"],
283
303
  },
284
304
  },
305
+ {
306
+ name: "ci_goal_check",
307
+ description: "Check whether recent tool activity still relates to the stated goal. Reads the '## Goal' section of task_plan.md (plus optional '## Goal Keywords' and '## Goal Scope' sections), scores the last N observations, and reports a drift score with the top off-goal tool calls.",
308
+ manifestWhat: "Detect when a session has drifted from its stated goal",
309
+ inputSchema: {
310
+ type: "object",
311
+ properties: {
312
+ limit: {
313
+ type: "number",
314
+ description: "How many recent observations to score (default: 30)",
315
+ default: 30,
316
+ },
317
+ goal_file: {
318
+ type: "string",
319
+ description: "Path to a goal/plan markdown file. Defaults to task_plan.md in the project root, then ~/.claude/instincts/<hash>/goal.md",
320
+ },
321
+ },
322
+ required: [],
323
+ },
324
+ },
325
+ {
326
+ name: "ci_recall",
327
+ description: "Search past tool-call observations with BM25 ranking. Answers 'have I seen this before?' against ~/.claude/instincts/<hash>/observations.jsonl and returns the most relevant past activity with redacted snippets. Lexical, not semantic.",
328
+ manifestWhat: "Search past sessions for relevant prior activity (episodic recall)",
329
+ inputSchema: {
330
+ type: "object",
331
+ properties: {
332
+ query: { type: "string", description: "Search terms, e.g. an error message or a past task" },
333
+ k: {
334
+ type: "number",
335
+ description: "How many results to return (default: 5)",
336
+ default: 5,
337
+ },
338
+ since: {
339
+ type: "string",
340
+ description: "Only search rows newer than this — ISO timestamp or relative window like '7d', '24h', '30m'",
341
+ },
342
+ },
343
+ required: ["query"],
344
+ },
345
+ },
346
+ {
347
+ name: "ci_distill_candidates",
348
+ description: "List distillation candidates: tool sequences that recurred across multiple successful sessions and could become reusable instincts. Read-only analysis — proposes nothing until you run ci_distill_propose.",
349
+ manifestWhat: "Find repeated successful tool sequences worth turning into skills",
350
+ inputSchema: { type: "object", properties: {}, required: [] },
351
+ },
352
+ {
353
+ name: "ci_distill_propose",
354
+ description: "Write a DRAFT instinct for a distillation candidate to ~/.claude/instincts/<hash>/drafts/. The draft has a placeholder body for you to edit; it does not affect behavior until promoted.",
355
+ manifestWhat: "Draft a reusable instinct from a repeated successful pattern",
356
+ inputSchema: {
357
+ type: "object",
358
+ properties: {
359
+ id: { type: "string", description: "Candidate id from ci_distill_candidates" },
360
+ },
361
+ required: ["id"],
362
+ },
363
+ },
364
+ {
365
+ name: "ci_distill_promote",
366
+ description: "Promote an edited draft into a live project instinct at 0.5 confidence (SUGGEST tier) and consume the draft. Run after editing the draft body produced by ci_distill_propose.",
367
+ manifestWhat: "Promote an edited draft into a live instinct",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ id: { type: "string", description: "Draft id to promote" },
372
+ },
373
+ required: ["id"],
374
+ },
375
+ },
285
376
  ];
286
377
  const MODE_METADATA = {
287
378
  beginner: {
288
379
  description: "Beginner mode: see what your agent learned, list its instincts, and request a session reflection. Bundles four discipline skills (gateguard, para-memory-files, tdd-workflow, verification-loop) so research, memory, tests, and verification happen by default.",
289
- hooks: ["PreToolUse", "PostToolUse"],
290
- hookDescription: "Silently captures every tool call as observations. Lightweight and non-blocking.",
380
+ hooks: ["PreToolUse", "PostToolUse", "UserPromptSubmit"],
381
+ hookDescription: "Silently captures every tool call as observations and routes prompts to the matching discipline skill via the route table. Lightweight and non-blocking.",
291
382
  },
292
383
  expert: {
293
384
  description: "Expert mode: tune confidence, manage instincts, and persist plans on disk. Adds safety, token-budget, and strategic-compact skills plus the /learn-eval command so long sessions stay disciplined and learnings survive context resets.",
294
- hooks: ["PreToolUse", "PostToolUse", "SessionStart", "SessionEnd"],
295
- hookDescription: "Full hook suite: observation capture + session-level instinct loading and auto-reflection.",
385
+ hooks: ["PreToolUse", "PostToolUse", "UserPromptSubmit", "SessionStart", "SessionEnd"],
386
+ hookDescription: "Full hook suite: observation capture, lazy prompt routing, session-level instinct loading, and auto-reflection.",
296
387
  },
297
388
  };
298
389
  function getToolCatalog(mode) {
@@ -361,6 +452,11 @@ export function getPluginHooksConfig() {
361
452
  command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gateguard.mjs\"",
362
453
  timeout: 5,
363
454
  };
455
+ const companionPreferenceCommand = {
456
+ type: "command",
457
+ command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/companion-preference.mjs\"",
458
+ timeout: 5,
459
+ };
364
460
  const observeCommand = {
365
461
  type: "command",
366
462
  command: "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/observe.sh\"",
@@ -376,18 +472,36 @@ export function getPluginHooksConfig() {
376
472
  command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/three-section-close.mjs\"",
377
473
  timeout: 5,
378
474
  };
475
+ const goalDriftStopCommand = {
476
+ type: "command",
477
+ command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/goal-drift-stop.mjs\"",
478
+ timeout: 5,
479
+ };
480
+ const routePromptCommand = {
481
+ type: "command",
482
+ command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/route-prompt.mjs\"",
483
+ timeout: 5,
484
+ };
379
485
  return {
380
- description: "Gateguard fact-forcing PreToolUse, observation, session lifecycle, and 3-section-close discipline hooks for continuous-improvement.",
486
+ description: "Gateguard fact-forcing PreToolUse, companion-preference enforcement, observation, session lifecycle, 3-section-close discipline, goal-drift Stop gate, and UserPromptSubmit lazy-routing hooks for continuous-improvement.",
381
487
  hooks: {
382
- // gateguard runs FIRST so its block decision short-circuits before
383
- // observe.sh records the tool call. observe.sh stays in PreToolUse for
384
- // the observation feed; the Claude Code host runs both regardless of
385
- // gateguard's decision.
386
- PreToolUse: [{ hooks: [gateguardCommand, observeCommand] }],
488
+ // gateguard runs FIRST on PreToolUse so its block decision short-circuits
489
+ // before companion-preference sees the call. companion-preference runs
490
+ // second on Skill tool calls; it is a no-op under ci-first (the default)
491
+ // and never blocks under companions-first. observe.sh only runs on
492
+ // PostToolUse: gateguard-blocked calls are intentionally not observed so
493
+ // PreToolUse stays at two subprocesses on the hot path. route-prompt
494
+ // fires on UserPromptSubmit and emits a system-reminder when a prompt
495
+ // pattern in hooks/route-table.json matches; non-matching prompts pass
496
+ // through with no output.
497
+ PreToolUse: [
498
+ { hooks: [gateguardCommand, companionPreferenceCommand] },
499
+ ],
387
500
  PostToolUse: [{ hooks: [observeCommand] }],
501
+ UserPromptSubmit: [{ hooks: [routePromptCommand] }],
388
502
  SessionStart: [{ hooks: [sessionCommand] }],
389
503
  SessionEnd: [{ hooks: [sessionCommand] }],
390
- Stop: [{ hooks: [threeSectionCloseCommand] }],
504
+ Stop: [{ hooks: [threeSectionCloseCommand, goalDriftStopCommand] }],
391
505
  },
392
506
  };
393
507
  }
File without changes
package/lib/pm-skills.mjs CHANGED
File without changes
@@ -0,0 +1,175 @@
1
+ // recall-index.mts — Pure BM25 episodic search over observation rows.
2
+ //
3
+ // No I/O. Turns a list of observation rows into a searchable in-memory index
4
+ // and answers ranked queries with redacted snippets. The MCP tool ci_recall
5
+ // (src/bin/mcp-server.mts) reads observations.jsonl and wires these functions;
6
+ // keeping the index pure lets the unit tests cover tokenization, ranking,
7
+ // recency filtering, and secret redaction without touching the filesystem.
8
+ //
9
+ // v1 is dependency-free (preserves the package's zero-runtime-deps property):
10
+ // the index is rebuilt per query from the row list. At current observation
11
+ // volumes (well under 100k rows) the rebuild is sub-50ms; a node:sqlite FTS5
12
+ // migration is a follow-up if volumes grow.
13
+ const STOPWORDS = new Set([
14
+ "the", "and", "for", "with", "that", "this", "from", "into", "your", "are",
15
+ "was", "were", "but", "not", "all", "any", "can", "its", "out", "has", "had",
16
+ ]);
17
+ const TOKEN_MIN_LENGTH = 2;
18
+ const BM25_K1 = 1.5;
19
+ const BM25_B = 0.75;
20
+ const DEFAULT_K = 5;
21
+ const SNIPPET_RADIUS = 60;
22
+ /** Lowercase, split on any non-letter / non-number (Unicode-aware), drop stopwords and tokens shorter than TOKEN_MIN_LENGTH. */
23
+ export function tokenize(text) {
24
+ const out = [];
25
+ for (const raw of text.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
26
+ if (raw.length < TOKEN_MIN_LENGTH)
27
+ continue;
28
+ if (STOPWORDS.has(raw))
29
+ continue;
30
+ out.push(raw);
31
+ }
32
+ return out;
33
+ }
34
+ /**
35
+ * Redact common secret shapes from a snippet before it is surfaced. Conservative
36
+ * by design: matches AWS access keys, JWT-shaped triplets, bearer tokens,
37
+ * KEY/SECRET/TOKEN/PASSWORD assignments, and long hex strings. Applied to every
38
+ * snippet returned by query().
39
+ */
40
+ export function redactSecrets(text) {
41
+ return text
42
+ .replace(/AKIA[0-9A-Z]{16}/g, "AKIA<redacted>")
43
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi, "Bearer <redacted>")
44
+ .replace(/\b([A-Za-z][A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD))(\s*[:=]\s*)(\S+)/gi, "$1$2<redacted>")
45
+ .replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "<jwt-redacted>")
46
+ .replace(/\b[a-f0-9]{32,}\b/gi, "<hex-redacted>");
47
+ }
48
+ function docText(obs) {
49
+ return [obs.tool ?? "", obs.input_summary ?? "", obs.output_summary ?? ""]
50
+ .filter((part) => part.length > 0)
51
+ .join(" ");
52
+ }
53
+ /** Build an in-memory BM25 index from a list of observation rows. */
54
+ export function buildIndex(observations) {
55
+ const docs = [];
56
+ const df = new Map();
57
+ let totalLength = 0;
58
+ observations.forEach((obs, index) => {
59
+ const text = docText(obs);
60
+ const tokens = tokenize(text);
61
+ const tf = new Map();
62
+ for (const token of tokens) {
63
+ tf.set(token, (tf.get(token) ?? 0) + 1);
64
+ }
65
+ for (const term of tf.keys()) {
66
+ df.set(term, (df.get(term) ?? 0) + 1);
67
+ }
68
+ docs.push({ obs, index, text, tf, length: tokens.length });
69
+ totalLength += tokens.length;
70
+ });
71
+ return {
72
+ docs,
73
+ df,
74
+ avgdl: docs.length > 0 ? totalLength / docs.length : 0,
75
+ n: docs.length,
76
+ };
77
+ }
78
+ function idf(index, term) {
79
+ const df = index.df.get(term) ?? 0;
80
+ return Math.log(1 + (index.n - df + 0.5) / (df + 0.5));
81
+ }
82
+ function scoreDoc(index, doc, queryTerms) {
83
+ let score = 0;
84
+ for (const term of queryTerms) {
85
+ const freq = doc.tf.get(term);
86
+ if (!freq)
87
+ continue;
88
+ const denom = freq + BM25_K1 * (1 - BM25_B + (BM25_B * doc.length) / (index.avgdl || 1));
89
+ score += idf(index, term) * ((freq * (BM25_K1 + 1)) / denom);
90
+ }
91
+ return score;
92
+ }
93
+ /** Parse an ISO timestamp or a relative window ("7d", "24h", "30m") into a cutoff epoch-ms. */
94
+ export function parseSince(since, now) {
95
+ const relative = since.trim().match(/^(\d+)\s*([dhm])$/i);
96
+ if (relative) {
97
+ const amount = Number(relative[1]);
98
+ const unit = relative[2].toLowerCase();
99
+ const ms = unit === "d" ? 86_400_000 : unit === "h" ? 3_600_000 : 60_000;
100
+ return now - amount * ms;
101
+ }
102
+ const parsed = Date.parse(since);
103
+ return Number.isNaN(parsed) ? null : parsed;
104
+ }
105
+ function obsSession(obs) {
106
+ return (obs.session ?? obs.session_id ?? "").toString();
107
+ }
108
+ function makeSnippet(text, queryTerms) {
109
+ const lower = text.toLowerCase();
110
+ let pos = -1;
111
+ for (const term of queryTerms) {
112
+ const found = lower.indexOf(term);
113
+ if (found !== -1 && (pos === -1 || found < pos))
114
+ pos = found;
115
+ }
116
+ if (pos === -1)
117
+ pos = 0;
118
+ const start = Math.max(0, pos - SNIPPET_RADIUS);
119
+ const end = Math.min(text.length, pos + SNIPPET_RADIUS);
120
+ let snippet = text.slice(start, end);
121
+ if (start > 0)
122
+ snippet = `…${snippet}`;
123
+ if (end < text.length)
124
+ snippet = `${snippet}…`;
125
+ return redactSecrets(snippet);
126
+ }
127
+ /**
128
+ * Rank observations against a query with BM25. Results are sorted by score
129
+ * descending, ties broken by recency (ts descending). `since` filters out rows
130
+ * older than an ISO timestamp or relative window. Snippets are redacted.
131
+ */
132
+ export function query(index, queryString, opts = {}) {
133
+ const queryTerms = [...new Set(tokenize(queryString))];
134
+ if (queryTerms.length === 0)
135
+ return [];
136
+ const k = opts.k && opts.k > 0 ? opts.k : DEFAULT_K;
137
+ const cutoff = opts.since ? parseSince(opts.since, opts.now ?? Date.now()) : null;
138
+ const hits = [];
139
+ for (const doc of index.docs) {
140
+ const ts = (doc.obs.ts ?? "").toString();
141
+ if (cutoff !== null) {
142
+ // Fail closed: a row with no ts or an unparseable ts has unknown age, so
143
+ // it must not leak through a time-bounded query.
144
+ const tsMs = ts ? Date.parse(ts) : Number.NaN;
145
+ if (Number.isNaN(tsMs) || tsMs < cutoff)
146
+ continue;
147
+ }
148
+ const score = scoreDoc(index, doc, queryTerms);
149
+ if (score <= 0)
150
+ continue;
151
+ hits.push({
152
+ ts,
153
+ session: obsSession(doc.obs),
154
+ tool: (doc.obs.tool ?? "").toString(),
155
+ snippet: makeSnippet(doc.text, queryTerms),
156
+ score,
157
+ index: doc.index,
158
+ });
159
+ }
160
+ hits.sort((a, b) => (b.score !== a.score ? b.score - a.score : b.ts.localeCompare(a.ts)));
161
+ return hits.slice(0, k);
162
+ }
163
+ /** Render ranked hits as a markdown block for the MCP tool output. Pure string -> string. */
164
+ export function formatRecallHits(hits, queryString) {
165
+ if (hits.length === 0) {
166
+ return `No past observations matched "${queryString}". Try broader or different terms — recall is lexical, not semantic.`;
167
+ }
168
+ const lines = [`## Recall: "${queryString}"`, "", `${hits.length} match(es), most relevant first:`, ""];
169
+ for (const hit of hits) {
170
+ const stamp = hit.ts ? `[${hit.ts}] ` : "";
171
+ lines.push(`- ${stamp}**${hit.tool || "(tool?)"}** (score ${hit.score.toFixed(2)})`);
172
+ lines.push(` ${hit.snippet || "(no summary)"}`);
173
+ }
174
+ return lines.join("\n");
175
+ }
File without changes