continuous-improvement 3.9.2 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +61 -0
- package/QUICKSTART.md +13 -8
- package/README.md +84 -153
- package/bin/backfill.mjs +0 -0
- package/bin/check-doc-runtime-claims.mjs +0 -0
- package/bin/check-docs-substrings.mjs +73 -0
- package/bin/check-everything-mirror.mjs +0 -0
- package/bin/check-routing-targets.mjs +0 -0
- package/bin/check-scripts-citation-drift.mjs +210 -0
- package/bin/check-skill-count.mjs +110 -0
- package/bin/check-skill-law-tag.mjs +0 -0
- package/bin/check-skill-mirror.mjs +0 -0
- package/bin/check-skill-tiers.mjs +0 -0
- package/bin/check-test-imports-only.mjs +126 -0
- package/bin/check-third-party-shape.mjs +0 -0
- package/bin/companion-preference-status.mjs +231 -0
- package/bin/gateguard-clear.mjs +53 -0
- package/bin/generate-plugin-manifests.mjs +7 -1
- package/bin/harvest-friction.mjs +0 -0
- package/bin/hook-stats.mjs +0 -0
- package/bin/install.mjs +182 -26
- package/bin/lint-transcript.mjs +0 -0
- package/bin/mcp-server.mjs +215 -2
- package/bin/observe.mjs +0 -0
- package/bin/pre-commit-block-strays.sh +0 -0
- package/bin/refresh-third-party.mjs +315 -313
- package/bin/unified-cli.mjs +0 -0
- package/commands/audit.md +34 -0
- package/commands/companion-preference.md +58 -0
- package/commands/discipline.md +14 -0
- package/commands/distill.md +39 -0
- package/commands/goal-check.md +53 -0
- package/commands/grill-me.md +40 -0
- package/commands/grill-with-docs.md +38 -0
- package/commands/handoff.md +42 -0
- package/commands/proceed-with-the-recommendation.md +20 -0
- package/commands/recall.md +49 -0
- package/commands/reconcile.md +47 -0
- package/commands/superpowers.md +2 -2
- package/commands/verify-install.md +55 -0
- package/hooks/companion-preference.mjs +190 -0
- package/hooks/gateguard.mjs +60 -25
- package/hooks/goal-drift-stop.mjs +183 -0
- package/hooks/observe.sh +15 -1
- package/hooks/route-prompt.mjs +180 -0
- package/hooks/route-table.json +35 -0
- package/hooks/three-section-close.mjs +134 -132
- package/lib/cli-anything.mjs +0 -0
- package/lib/compound-engineering.mjs +0 -0
- package/lib/gateguard-state.mjs +54 -2
- package/lib/goal-drift-gate.mjs +50 -0
- package/lib/goal-state.mjs +285 -0
- package/lib/observe-event.mjs +0 -0
- package/lib/plugin-metadata.mjs +126 -12
- package/lib/pm-marketplace.mjs +0 -0
- package/lib/pm-skills.mjs +0 -0
- package/lib/recall-index.mjs +175 -0
- package/lib/resolve-home-dir.mjs +0 -0
- package/lib/skill-distill.mjs +222 -0
- package/lib/skill-tiers.mjs +0 -0
- package/lib/unified-plugin.mjs +0 -0
- package/llms.txt +1 -1
- package/package.json +11 -7
- package/plugins/beginner.json +8 -3
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
- package/plugins/continuous-improvement/agents/README.md +2 -2
- package/plugins/continuous-improvement/bin/backfill.mjs +0 -0
- package/plugins/continuous-improvement/bin/gateguard-clear.mjs +53 -0
- package/plugins/continuous-improvement/bin/mcp-server.mjs +215 -2
- package/plugins/continuous-improvement/bin/observe.mjs +0 -0
- package/plugins/continuous-improvement/commands/audit.md +34 -0
- package/plugins/continuous-improvement/commands/companion-preference.md +58 -0
- package/plugins/continuous-improvement/commands/discipline.md +14 -0
- package/plugins/continuous-improvement/commands/distill.md +39 -0
- package/plugins/continuous-improvement/commands/goal-check.md +53 -0
- package/plugins/continuous-improvement/commands/grill-me.md +40 -0
- package/plugins/continuous-improvement/commands/grill-with-docs.md +38 -0
- package/plugins/continuous-improvement/commands/handoff.md +42 -0
- package/plugins/continuous-improvement/commands/proceed-with-the-recommendation.md +20 -0
- package/plugins/continuous-improvement/commands/recall.md +49 -0
- package/plugins/continuous-improvement/commands/reconcile.md +47 -0
- package/plugins/continuous-improvement/commands/superpowers.md +2 -2
- package/plugins/continuous-improvement/commands/verify-install.md +55 -0
- package/plugins/continuous-improvement/hooks/companion-preference.mjs +190 -0
- package/plugins/continuous-improvement/hooks/gateguard.mjs +60 -25
- package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +183 -0
- package/plugins/continuous-improvement/hooks/hooks.json +18 -2
- package/plugins/continuous-improvement/hooks/observe.sh +15 -1
- package/plugins/continuous-improvement/hooks/route-prompt.mjs +180 -0
- package/plugins/continuous-improvement/hooks/route-table.json +35 -0
- package/plugins/continuous-improvement/hooks/three-section-close.mjs +134 -132
- package/plugins/continuous-improvement/lib/gateguard-state.mjs +137 -0
- package/plugins/continuous-improvement/lib/goal-drift-gate.mjs +50 -0
- package/plugins/continuous-improvement/lib/goal-state.mjs +285 -0
- package/plugins/continuous-improvement/lib/observe-event.mjs +0 -0
- package/plugins/continuous-improvement/lib/plugin-metadata.mjs +126 -12
- package/plugins/continuous-improvement/lib/recall-index.mjs +175 -0
- package/plugins/continuous-improvement/lib/resolve-home-dir.mjs +0 -0
- package/plugins/continuous-improvement/lib/skill-distill.mjs +222 -0
- package/plugins/continuous-improvement/skills/README.md +8 -0
- package/plugins/continuous-improvement/skills/audit/SKILL.md +73 -0
- package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +13 -17
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +36 -17
- package/plugins/continuous-improvement/skills/goal-monitor/SKILL.md +81 -0
- package/plugins/continuous-improvement/skills/grill-me/SKILL.md +66 -0
- package/plugins/continuous-improvement/skills/grill-with-docs/SKILL.md +252 -0
- package/plugins/continuous-improvement/skills/handoff/SKILL.md +59 -0
- package/plugins/continuous-improvement/skills/para-memory-files/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +82 -6
- package/plugins/continuous-improvement/skills/ralph/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/recall/SKILL.md +50 -0
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +80 -0
- package/plugins/continuous-improvement/skills/recovery-classification/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/skill-distillation/SKILL.md +57 -0
- package/plugins/continuous-improvement/skills/state-reconciliation/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/strategic-compact/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/superpowers/SKILL.md +76 -2
- package/plugins/continuous-improvement/skills/token-budget-advisor/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +28 -20
- package/plugins/continuous-improvement/skills/wild-risa-balance/SKILL.md +1 -0
- package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +3 -2
- package/plugins/expert.json +27 -2
- package/skills/README.md +3 -0
- package/skills/audit.md +73 -0
- package/skills/deploy-receipt.md +13 -17
- package/skills/gateguard.md +36 -17
- package/skills/goal-monitor.md +81 -0
- package/skills/grill-me.md +66 -0
- package/skills/grill-with-docs.md +252 -0
- package/skills/handoff.md +59 -0
- package/skills/para-memory-files.md +1 -1
- package/skills/proceed-with-the-recommendation.md +82 -6
- package/skills/ralph.md +1 -0
- package/skills/recall.md +50 -0
- package/skills/reconcile.md +80 -0
- package/skills/recovery-classification.md +1 -0
- package/skills/safety-guard.md +1 -0
- package/skills/skill-distillation.md +57 -0
- package/skills/state-reconciliation.md +1 -0
- package/skills/strategic-compact.md +1 -0
- package/skills/superpowers.md +76 -2
- package/skills/token-budget-advisor.md +1 -1
- package/skills/verification-loop.md +28 -20
- package/skills/wild-risa-balance.md +1 -0
- package/skills/workspace-surface-audit.md +1 -1
- package/skills/worktree-safety.md +3 -2
|
@@ -0,0 +1,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
|
+
}
|
package/lib/observe-event.mjs
CHANGED
|
File without changes
|
package/lib/plugin-metadata.mjs
CHANGED
|
@@ -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 —
|
|
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
|
|
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,
|
|
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
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
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
|
}
|
package/lib/pm-marketplace.mjs
CHANGED
|
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
|
+
}
|
package/lib/resolve-home-dir.mjs
CHANGED
|
File without changes
|