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
@@ -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 = "Makes Claude Code reason harder, recall past corrections, and learn from every session so its competence compounds run over run. The Mulahazah engine turns each fix into a reusable instinct, so a lesson learned once is applied automatically next time — no re-teaching. Built on the 7 Laws of AI Agent Discipline (research, plan, verify, reflect, learn) and shipped as 25 bundled skills, instinct-aware hooks, an MCP toolset for recall and reflection, and a GitHub Action transcript linter that feeds real work history back into sharper instincts.";
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
@@ -76,7 +76,7 @@ const THIRD_PARTY_COMPANIONS = [
76
76
  },
77
77
  {
78
78
  name: "oh-my-claudecode",
79
- description: "Multi-agent orchestration system for Claude Code — 39 skills + 19 agents covering audit, plan, build, verify, ship, release, retrospective, and ops. Includes ralph (autonomous PRD loop), release, ultrawork, ultraqa, team, trace, visual-verdict, debug, deep-dive, deep-interview, autopilot, autoresearch. Vendored snapshot at third-party/oh-my-claudecode/ pinned to upstream SHA aacde3e (v4.13.6). Heavy overlap with continuous-improvement /ralph and /superpowers — pick per task.",
79
+ description: "Multi-agent orchestration system for Claude Code — 38 skills + 19 agents covering audit, plan, build, verify, ship, release, retrospective, and ops. Includes ralph (autonomous PRD loop), release, ultrawork, ultraqa, team, trace, visual-verdict, debug, deep-dive, deep-interview, autopilot, autoresearch. Vendored snapshot at third-party/oh-my-claudecode/ pinned to upstream SHA aacde3e (v4.13.6). Heavy overlap with continuous-improvement /ralph and /superpowers — pick per task.",
80
80
  version: "4.13.6",
81
81
  source: "./third-party/oh-my-claudecode",
82
82
  author: {
@@ -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
- 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.",
379
+ description: "Beginner mode: see what your agent learned, list its instincts, and request a session reflection. Bundles four grounding skills (gateguard, para-memory-files, tdd-workflow, verification-loop) so research, memory, tests, and verification happen by default — every edit starts from facts, not guesses.",
380
+ hooks: ["PreToolUse", "PostToolUse", "UserPromptSubmit"],
381
+ hookDescription: "Silently captures every tool call as observations and routes prompts to the matching skill via the route table. Lightweight and non-blocking.",
291
382
  },
292
383
  expert: {
293
- 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.",
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 sharp and learnings survive context resets.",
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,41 @@ 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
+ };
485
+ const recallBriefingCommand = {
486
+ type: "command",
487
+ command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/recall-briefing.mjs\"",
488
+ timeout: 5,
489
+ };
379
490
  return {
380
- description: "Gateguard fact-forcing PreToolUse, observation, session lifecycle, and 3-section-close discipline hooks for continuous-improvement.",
491
+ description: "Gateguard fact-forcing PreToolUse, companion-preference enforcement, observation, session lifecycle, 3-section-close discipline, goal-drift Stop gate, and UserPromptSubmit lazy-routing plus opt-in proactive recall-briefing hooks for continuous-improvement.",
381
492
  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] }],
493
+ // gateguard runs FIRST on PreToolUse so its block decision short-circuits
494
+ // before companion-preference sees the call. companion-preference runs
495
+ // second on Skill tool calls; it is a no-op under ci-first (the default)
496
+ // and never blocks under companions-first. observe.sh only runs on
497
+ // PostToolUse: gateguard-blocked calls are intentionally not observed so
498
+ // PreToolUse stays at two subprocesses on the hot path. route-prompt
499
+ // fires on UserPromptSubmit and emits a system-reminder when a prompt
500
+ // pattern in hooks/route-table.json matches; non-matching prompts pass
501
+ // through with no output.
502
+ PreToolUse: [
503
+ { hooks: [gateguardCommand, companionPreferenceCommand] },
504
+ ],
387
505
  PostToolUse: [{ hooks: [observeCommand] }],
506
+ UserPromptSubmit: [{ hooks: [routePromptCommand, recallBriefingCommand] }],
388
507
  SessionStart: [{ hooks: [sessionCommand] }],
389
508
  SessionEnd: [{ hooks: [sessionCommand] }],
390
- Stop: [{ hooks: [threeSectionCloseCommand] }],
509
+ Stop: [{ hooks: [threeSectionCloseCommand, goalDriftStopCommand] }],
391
510
  },
392
511
  };
393
512
  }
@@ -0,0 +1,57 @@
1
+ // recall-briefing.mts — Pure decision core for the proactive recall-briefing hook.
2
+ //
3
+ // No I/O. Given (enabled, already-briefed, prompt, ranked recall hits), decides
4
+ // whether to inject a one-time-per-session "you've touched related work before"
5
+ // system-reminder and renders it. The hook (src/hooks/recall-briefing.mts) does
6
+ // the filesystem work — read the prompt, read observations.jsonl, build the BM25
7
+ // index (recall-index.mts), track per-session state — then calls decideBriefing.
8
+ // Keeping the decision pure lets the unit tests cover every gate and boundary
9
+ // (disabled / already-briefed / empty-or-short prompt / no hits / malformed
10
+ // input) without touching the filesystem, per the repo lesson that green gates
11
+ // do not prove boundary safety.
12
+ //
13
+ // This is an amplifier, not a gate: it never blocks a prompt. It only surfaces
14
+ // prior context so the agent reuses a past fix instead of re-deriving it. It is
15
+ // opt-in (the hook checks an env flag) and fires at most once per session.
16
+ export const DEFAULT_MAX_HITS = 3;
17
+ export const DEFAULT_MIN_PROMPT_LENGTH = 12;
18
+ /** Render ranked hits as a compact UserPromptSubmit system-reminder. Pure string. */
19
+ export function formatBriefing(hits) {
20
+ const lines = [
21
+ "<system-reminder>",
22
+ "Recall briefing: this project has prior sessions that touched related work. Before re-deriving a fix, consider the `ci_recall` MCP tool (or `/recall`) for full context. Most relevant past activity:",
23
+ ];
24
+ for (const h of hits) {
25
+ const stamp = h && h.ts ? `[${h.ts}] ` : "";
26
+ const tool = (h && h.tool) || "(tool?)";
27
+ const snippet = ((h && h.snippet) || "").replace(/\s+/g, " ").trim();
28
+ lines.push(`- ${stamp}${tool}: ${snippet || "(no summary)"}`);
29
+ }
30
+ lines.push("</system-reminder>");
31
+ return lines.join("\n");
32
+ }
33
+ /**
34
+ * Decide whether to inject a recall briefing. Fails closed: any disabling
35
+ * condition (opted out, already briefed, prompt below the noise floor, no hits,
36
+ * malformed input) returns shouldBrief=false and never throws.
37
+ */
38
+ export function decideBriefing(params) {
39
+ const none = { shouldBrief: false, reminder: null };
40
+ if (!params || params.enabled !== true)
41
+ return none;
42
+ if (params.alreadyBriefed === true)
43
+ return none;
44
+ const prompt = typeof params.prompt === "string" ? params.prompt : "";
45
+ const minLen = typeof params.minPromptLength === "number" && params.minPromptLength >= 0
46
+ ? params.minPromptLength
47
+ : DEFAULT_MIN_PROMPT_LENGTH;
48
+ if (prompt.trim().length < minLen)
49
+ return none;
50
+ const maxHits = typeof params.maxHits === "number" && params.maxHits > 0
51
+ ? Math.floor(params.maxHits)
52
+ : DEFAULT_MAX_HITS;
53
+ const hits = Array.isArray(params.hits) ? params.hits.slice(0, maxHits) : [];
54
+ if (hits.length === 0)
55
+ return none;
56
+ return { shouldBrief: true, reminder: formatBriefing(hits) };
57
+ }
@@ -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 / non-mark (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}\p{M}]+/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
+ }
@@ -0,0 +1,222 @@
1
+ // skill-distill.mts — Pure trajectory → draft-skill distillation.
2
+ //
3
+ // No I/O. Scans observation rows for repeated tool sequences that ended in a
4
+ // successful verification, and proposes DRAFT instincts capturing the pattern.
5
+ // The MCP tools ci_distill_candidates / ci_distill_propose / ci_distill_promote
6
+ // (src/bin/mcp-server.mts) wire the file reads and writes around these
7
+ // functions.
8
+ //
9
+ // Closed-loop "Learn From Every Session" (Law 7) on the SUCCESS side: the
10
+ // friction harvester (src/bin/harvest-friction.mts) already turns failures into
11
+ // instincts; this turns repeated wins into reusable drafts. Drafts never enter
12
+ // the suggestion pool — they require explicit promotion — so a coincidental
13
+ // tool sequence cannot cargo-cult itself into enforced behavior.
14
+ const MIN_TRAJECTORY_LENGTH = 4;
15
+ const GAP_MS = 10 * 60 * 1000;
16
+ const NGRAM_SIZES = [3, 4, 5];
17
+ const DEFAULT_MIN_OCCURRENCES = 3;
18
+ const DEFAULT_MIN_SESSIONS = 2;
19
+ const DRAFT_CONFIDENCE = 0.4;
20
+ const VERIFY_CMD = /\b(npm\s+(run\s+)?(test|verify(:[\w-]+)?|build)|pnpm\s+(test|build)|yarn\s+(test|build)|pytest|cargo\s+test|go\s+test|jest|vitest|make\s+test)\b/i;
21
+ const FAILURE_MARKER = /\b(error|errors|fail|failed|failing|not found|denied|cannot|exception|traceback)\b|exit code [1-9]/i;
22
+ const SUCCESS_MARKER = /\b(pass|passed|passing|success|succeeded|green|0 failures|all tests)\b|\bok\b/i;
23
+ const REFLECTION_MARKER = /\b(verified|phase[- ]?4|verify:all green|all tests pass)\b/i;
24
+ const NOISE_TOOL = /^(ci_|mcp__|TodoWrite$)/;
25
+ function session(obs) {
26
+ return (obs.session ?? obs.session_id ?? "").toString();
27
+ }
28
+ function text(obs) {
29
+ return `${obs.input_summary ?? ""}\n${obs.output_summary ?? ""}`;
30
+ }
31
+ function classifyTrajectorySuccess(observations) {
32
+ for (const obs of observations) {
33
+ if ((obs.tool ?? "") === "Bash") {
34
+ const input = (obs.input_summary ?? "").toString();
35
+ const output = (obs.output_summary ?? "").toString();
36
+ if (VERIFY_CMD.test(input) && !FAILURE_MARKER.test(output) && (output === "" || SUCCESS_MARKER.test(output))) {
37
+ return "verify-exit-0";
38
+ }
39
+ }
40
+ }
41
+ for (const obs of observations) {
42
+ if (REFLECTION_MARKER.test(text(obs))) {
43
+ return "reflection-pass";
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Split a chronological observation list into contiguous trajectories. A
50
+ * boundary occurs on session change or a time gap longer than GAP_MS. Only
51
+ * trajectories of at least MIN_TRAJECTORY_LENGTH observations are returned;
52
+ * each is classified as succeeded or not.
53
+ */
54
+ export function extractTrajectories(observations) {
55
+ const out = [];
56
+ let group = [];
57
+ const flush = () => {
58
+ if (group.length < MIN_TRAJECTORY_LENGTH) {
59
+ group = [];
60
+ return;
61
+ }
62
+ const successReason = classifyTrajectorySuccess(group);
63
+ out.push({
64
+ observations: group,
65
+ session: session(group[0]),
66
+ startTs: (group[0].ts ?? "").toString(),
67
+ endTs: (group[group.length - 1].ts ?? "").toString(),
68
+ succeeded: successReason !== null,
69
+ successReason,
70
+ });
71
+ group = [];
72
+ };
73
+ for (const obs of observations) {
74
+ if (group.length === 0) {
75
+ group.push(obs);
76
+ continue;
77
+ }
78
+ const prev = group[group.length - 1];
79
+ const sessionChanged = session(obs) !== session(prev);
80
+ const prevMs = Date.parse((prev.ts ?? "").toString());
81
+ const curMs = Date.parse((obs.ts ?? "").toString());
82
+ const prevValid = !Number.isNaN(prevMs);
83
+ const curValid = !Number.isNaN(curMs);
84
+ const gapTooBig = (prevValid && curValid && curMs - prevMs > GAP_MS) || (prevValid !== curValid);
85
+ if (sessionChanged || gapTooBig) {
86
+ flush();
87
+ }
88
+ group.push(obs);
89
+ }
90
+ flush();
91
+ return out;
92
+ }
93
+ function toolSequence(trajectory) {
94
+ return trajectory.observations
95
+ .map((obs) => (obs.tool ?? "").toString())
96
+ .filter((tool) => tool.length > 0 && !NOISE_TOOL.test(tool));
97
+ }
98
+ // Tool names are external data (observations.jsonl). The candidate id is later
99
+ // joined into a draft file path, so it must be slugified to [a-z0-9-] to keep a
100
+ // hostile tool name (`../`, `/`, `\`) from escaping the drafts directory.
101
+ function slugifyNgram(ngram) {
102
+ const slug = ngram
103
+ .join("-")
104
+ .toLowerCase()
105
+ .replace(/[^a-z0-9-]+/g, "-")
106
+ .replace(/-+/g, "-")
107
+ .replace(/^-+|-+$/g, "");
108
+ return slug.length > 0 ? slug : "sequence";
109
+ }
110
+ /**
111
+ * Mine repeated tool-sequence n-grams from SUCCESSFUL trajectories. A candidate
112
+ * must occur at least `minOccurrences` times across at least `minSessions`
113
+ * distinct sessions — so a pattern repeated within a single session is rejected.
114
+ * Sorted by occurrences then n-gram length, both descending.
115
+ */
116
+ export function findCandidates(trajectories, opts = {}) {
117
+ const minOccurrences = opts.minOccurrences ?? DEFAULT_MIN_OCCURRENCES;
118
+ const minSessions = opts.minSessions ?? DEFAULT_MIN_SESSIONS;
119
+ const aggregates = new Map();
120
+ for (const trajectory of trajectories) {
121
+ if (!trajectory.succeeded)
122
+ continue;
123
+ const sequence = toolSequence(trajectory);
124
+ const outcome = trajectory.successReason ?? "unknown";
125
+ for (const size of NGRAM_SIZES) {
126
+ for (let i = 0; i + size <= sequence.length; i += 1) {
127
+ const ngram = sequence.slice(i, i + size);
128
+ const key = ngram.join(">");
129
+ let aggregate = aggregates.get(key);
130
+ if (!aggregate) {
131
+ aggregate = { ngram, occurrences: 0, sessions: new Set(), outcomes: new Set() };
132
+ aggregates.set(key, aggregate);
133
+ }
134
+ aggregate.occurrences += 1;
135
+ aggregate.sessions.add(trajectory.session);
136
+ aggregate.outcomes.add(outcome);
137
+ }
138
+ }
139
+ }
140
+ const candidates = [];
141
+ for (const aggregate of aggregates.values()) {
142
+ if (aggregate.occurrences < minOccurrences)
143
+ continue;
144
+ if (aggregate.sessions.size < minSessions)
145
+ continue;
146
+ candidates.push({
147
+ id: `draft-${slugifyNgram(aggregate.ngram)}`,
148
+ ngram: aggregate.ngram,
149
+ occurrences: aggregate.occurrences,
150
+ sessions: [...aggregate.sessions],
151
+ outcomes: [...aggregate.outcomes],
152
+ });
153
+ }
154
+ candidates.sort((a, b) => b.occurrences !== a.occurrences ? b.occurrences - a.occurrences : b.ngram.length - a.ngram.length);
155
+ return candidates;
156
+ }
157
+ /** Turn a candidate into a DRAFT instinct with a placeholder body for the user to fill in. */
158
+ export function draftFromCandidate(candidate) {
159
+ const arrow = candidate.ngram.join(" → ");
160
+ const outcome = candidate.outcomes.join(", ");
161
+ return {
162
+ id: candidate.id,
163
+ trigger: `Auto-detected pattern: ${candidate.ngram.join(" then ")} (seen ${candidate.occurrences}x across ${candidate.sessions.length} sessions)`,
164
+ body: [
165
+ "When this situation recurs, follow the proven sequence that worked before:",
166
+ "",
167
+ ` ${arrow}`,
168
+ "",
169
+ `This pattern succeeded in ${candidate.occurrences} run(s) across ${candidate.sessions.length} session(s) (outcome: ${outcome}).`,
170
+ "Replace this placeholder with the concrete steps, preconditions, and gotchas before promoting —",
171
+ "the tool sequence alone is evidence, not a recipe.",
172
+ ].join("\n"),
173
+ confidence: DRAFT_CONFIDENCE,
174
+ domain: "workflow",
175
+ ngram: candidate.ngram,
176
+ occurrences: candidate.occurrences,
177
+ sessions: candidate.sessions.length,
178
+ outcome,
179
+ };
180
+ }
181
+ /**
182
+ * Serialize a draft to the instinct YAML format (key: value lines, `---`, body)
183
+ * so it round-trips through the same parser that reads live instincts. Stored
184
+ * under drafts/ so it is not picked up as a live instinct until promoted.
185
+ */
186
+ export function serializeDraft(draft) {
187
+ return [
188
+ `id: ${draft.id}`,
189
+ `trigger: "${draft.trigger}"`,
190
+ `confidence: ${draft.confidence}`,
191
+ `domain: ${draft.domain}`,
192
+ "source: distilled",
193
+ "status: draft",
194
+ `evidence_ngram: "${draft.ngram.join(">")}"`,
195
+ `evidence_occurrences: ${draft.occurrences}`,
196
+ `evidence_sessions: ${draft.sessions}`,
197
+ `evidence_outcome: "${draft.outcome}"`,
198
+ "---",
199
+ draft.body,
200
+ "",
201
+ ].join("\n");
202
+ }
203
+ /** Render candidates as a markdown block for the MCP tool output. */
204
+ export function formatCandidates(candidates, limit = 10) {
205
+ if (candidates.length === 0) {
206
+ return "No distillation candidates found. A candidate needs a tool sequence that recurred across multiple successful sessions. Keep working — patterns accrue as the observation log grows.";
207
+ }
208
+ const shown = candidates.slice(0, limit);
209
+ const lines = [
210
+ `## Distillation Candidates (${candidates.length})`,
211
+ "",
212
+ "Repeated tool sequences from successful sessions. Propose a draft with `ci_distill_propose id=<id>`, edit its body, then `ci_distill_promote id=<id>`.",
213
+ "",
214
+ ];
215
+ for (const candidate of shown) {
216
+ lines.push(`- **${candidate.id}**`, ` Sequence: ${candidate.ngram.join(" → ")}`, ` Seen ${candidate.occurrences}x across ${candidate.sessions.length} session(s) — outcome: ${candidate.outcomes.join(", ")}`);
217
+ }
218
+ if (candidates.length > limit) {
219
+ lines.push("", `_…and ${candidates.length - limit} more._`);
220
+ }
221
+ return lines.join("\n");
222
+ }