fapony 0.3.4 → 0.3.6
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/README.md +13 -6
- package/package.json +4 -5
- package/skill/move-to-done/SKILL.md +13 -3
- package/skill/plan-with-pony/SKILL.md +6 -2
- package/src/adapters/cli.ts +12 -7
- package/src/adapters/hooks/bug-markers.ts +50 -0
- package/src/adapters/hooks/compute-hint-impact.ts +22 -5
- package/src/adapters/hooks/context-data.ts +16 -10
- package/src/adapters/hooks/index.ts +6 -0
- package/src/adapters/hooks/read-hint.ts +17 -1
- package/src/adapters/hooks/session-start.ts +17 -6
- package/src/adapters/hooks/stop.ts +132 -6
- package/src/adapters/mcp/tools/index.ts +6 -1
- package/src/adapters/mcp/tools/mem.ts +40 -121
- package/src/commands.ts +180 -0
- package/src/core/debt-format.ts +2 -2
- package/src/core/since.ts +26 -0
- package/src/debt/cli.ts +20 -6
- package/src/debt/load.ts +136 -2
- package/src/digest/collect.ts +1 -19
- package/src/hook.ts +4 -0
- package/src/mem/commands/plan.ts +399 -13
- package/src/mem/commands/read.ts +181 -36
- package/src/mem/commands/write.ts +26 -31
- package/src/mem/engine.ts +249 -0
- package/src/mem/index.ts +69 -5
- package/src/memory.ts +2 -1
- package/src/seed/plan-seed.ts +118 -28
- package/src/seed/review-seed.ts +34 -10
- package/src/update.ts +1 -1
- package/src/test.ts +0 -2
package/src/mem/commands/read.ts
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
|
+
import { parseSince } from "../../core/since.js";
|
|
5
6
|
import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
|
|
7
|
+
import { CLI_FIND_EXCLUDE, engineFind } from "../engine.js";
|
|
6
8
|
import { doneLines, fmtClose, fmtRow } from "../render.js";
|
|
7
9
|
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
8
10
|
import type { CloseRow, WorkRow } from "../store.js";
|
|
9
11
|
import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
|
|
10
|
-
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
12
|
+
import { checkTickedLine, planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
11
13
|
import { THRESHOLD } from "./rotate.js";
|
|
12
14
|
|
|
13
15
|
/** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
|
|
@@ -65,58 +67,186 @@ export const cmdStale = () => {
|
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
export const cmdFind = (a: string[]) => {
|
|
68
|
-
// mem find <
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
// mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]
|
|
71
|
+
// Query logic lives in the shared engine (../engine.ts) — this wrapper owns
|
|
72
|
+
// only argv parsing + the single-line print. MCP memFind calls the same
|
|
73
|
+
// engine with no kind default (contract); CLI keeps its legacy default of
|
|
74
|
+
// hiding bookkeeping rows unless --kind names them (PLAN-unify-mem-engine).
|
|
75
|
+
const flagVal = (flag: string, i: number): string | undefined => {
|
|
76
|
+
const t = a[i];
|
|
77
|
+
const eq = `${flag}=`;
|
|
78
|
+
if (t.startsWith(eq)) return t.slice(eq.length);
|
|
79
|
+
return a[i + 1];
|
|
80
|
+
};
|
|
81
|
+
const consumed = (flag: string, i: number): number => {
|
|
82
|
+
if (a[i].startsWith(`${flag}=`)) return 1;
|
|
83
|
+
return 2;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let kind: string[] | undefined;
|
|
87
|
+
let files: string[] | undefined;
|
|
88
|
+
let sinceRaw: string | undefined;
|
|
89
|
+
let limit: number | undefined;
|
|
90
|
+
let open = false;
|
|
91
|
+
const positional: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < a.length; ) {
|
|
94
|
+
const t = a[i];
|
|
95
|
+
if (t === "--kind" || t.startsWith("--kind=")) {
|
|
96
|
+
const v = flagVal("--kind", i);
|
|
97
|
+
if (!v || v.startsWith("--")) {
|
|
98
|
+
console.error(
|
|
99
|
+
`--kind needs a value — usage: ${memCmd} find "<text>" --kind bug,decision`,
|
|
100
|
+
);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
kind = v
|
|
104
|
+
.split(",")
|
|
105
|
+
.map((s) => s.trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
i += consumed("--kind", i);
|
|
108
|
+
} else if (t === "--files" || t.startsWith("--files=")) {
|
|
109
|
+
const v = flagVal("--files", i);
|
|
110
|
+
if (!v || v.startsWith("--")) {
|
|
111
|
+
console.error(
|
|
112
|
+
`--files needs a value — usage: ${memCmd} find --files path/to/file.ts[,more]`,
|
|
113
|
+
);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
files = v
|
|
117
|
+
.split(",")
|
|
118
|
+
.map((s) => s.trim().replace(/^\.\//, ""))
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
i += consumed("--files", i);
|
|
121
|
+
} else if (t === "--since" || t.startsWith("--since=")) {
|
|
122
|
+
const v = flagVal("--since", i);
|
|
123
|
+
if (!v || v.startsWith("--")) {
|
|
124
|
+
console.error(`--since needs a value — use <N>d or YYYY-MM-DD`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
sinceRaw = v;
|
|
128
|
+
i += consumed("--since", i);
|
|
129
|
+
} else if (t === "--limit" || t.startsWith("--limit=")) {
|
|
130
|
+
const v = flagVal("--limit", i);
|
|
131
|
+
const n = Number(v);
|
|
132
|
+
if (!v || v.startsWith("--") || !Number.isInteger(n) || n < 0) {
|
|
133
|
+
console.error(
|
|
134
|
+
`--limit needs a non-negative integer — got "${v ?? ""}"`,
|
|
135
|
+
);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
limit = n;
|
|
139
|
+
i += consumed("--limit", i);
|
|
140
|
+
} else if (t === "--open") {
|
|
141
|
+
open = true;
|
|
142
|
+
i += 1;
|
|
143
|
+
} else {
|
|
144
|
+
positional.push(t);
|
|
145
|
+
i += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const q = positional.join(" ").trim();
|
|
150
|
+
if (
|
|
151
|
+
!q &&
|
|
152
|
+
!kind?.length &&
|
|
153
|
+
!files?.length &&
|
|
154
|
+
!sinceRaw &&
|
|
155
|
+
limit === undefined &&
|
|
156
|
+
!open
|
|
157
|
+
) {
|
|
158
|
+
console.error(
|
|
159
|
+
`usage: ${memCmd} find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--open]`,
|
|
160
|
+
);
|
|
72
161
|
process.exit(1);
|
|
73
162
|
}
|
|
163
|
+
|
|
164
|
+
let sinceIso: string | undefined;
|
|
165
|
+
if (sinceRaw) {
|
|
166
|
+
try {
|
|
167
|
+
sinceIso = parseSince(sinceRaw).iso;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
74
174
|
// allRows, not rows: find is recall — rotated history counts
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
175
|
+
const { rows: newest } = engineFind(allRows(), {
|
|
176
|
+
text: q || undefined,
|
|
177
|
+
files,
|
|
178
|
+
kind,
|
|
179
|
+
excludeKind: kind?.length ? undefined : CLI_FIND_EXCLUDE,
|
|
180
|
+
sinceIso,
|
|
181
|
+
limit,
|
|
182
|
+
open: open || undefined,
|
|
183
|
+
});
|
|
184
|
+
// Legacy order: oldest first (engine returns newest first — same set, CLI print order unchanged)
|
|
185
|
+
const hits = [...newest].reverse();
|
|
186
|
+
for (const r of hits) {
|
|
187
|
+
if ("id" in r) {
|
|
188
|
+
console.log(
|
|
189
|
+
`- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text ?? ""}${r.spec ? ` → ${r.spec}` : ""}`,
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
const label = "ref" in r && r.ref ? `${r.ref}(${r.kind})` : r.kind;
|
|
193
|
+
const body = ("text" in r && r.text) || ("spec" in r && r.spec) || "";
|
|
194
|
+
console.log(`- [${label}] ${r.ts.slice(0, 10)} ${r.kind} ${body}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
93
197
|
if (!hits.length) console.log("(no matches)");
|
|
94
198
|
};
|
|
95
199
|
|
|
96
|
-
/** Read a plan file and extract unchecked
|
|
97
|
-
const
|
|
200
|
+
/** Read a plan file and extract checked + unchecked items from the first ## section. */
|
|
201
|
+
const readPlanSectionItems = (
|
|
202
|
+
planPath: string,
|
|
203
|
+
): { checked: string[]; unchecked: string[] } => {
|
|
98
204
|
try {
|
|
99
205
|
const text = readFileSync(planPath, "utf8");
|
|
100
206
|
// Skip frontmatter
|
|
101
207
|
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
|
|
102
208
|
// Find the first ## section (TL;DR)
|
|
103
209
|
const start = body.search(/^##\s+/m);
|
|
104
|
-
if (start < 0) return [];
|
|
210
|
+
if (start < 0) return { checked: [], unchecked: [] };
|
|
105
211
|
const rest = body.slice(start);
|
|
106
212
|
const next = rest.slice(3).search(/^##\s+/m);
|
|
107
213
|
const block = next < 0 ? rest : rest.slice(0, next + 3);
|
|
108
|
-
// Extract
|
|
109
|
-
const
|
|
214
|
+
// Extract checkboxes — ticked and unticked
|
|
215
|
+
const checked: string[] = [];
|
|
216
|
+
const unchecked: string[] = [];
|
|
110
217
|
for (const line of block.split("\n")) {
|
|
111
|
-
|
|
112
|
-
if (m)
|
|
218
|
+
let m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
|
|
219
|
+
if (m) {
|
|
220
|
+
unchecked.push(m[1].trim());
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
m = /^\s*[-*]\s+\[[xX]\]\s+(.+)$/.exec(line);
|
|
224
|
+
if (m) checked.push(m[1].trim());
|
|
113
225
|
}
|
|
114
|
-
return
|
|
226
|
+
return { checked, unchecked };
|
|
115
227
|
} catch {
|
|
116
|
-
return [];
|
|
228
|
+
return { checked: [], unchecked: [] };
|
|
117
229
|
}
|
|
118
230
|
};
|
|
119
231
|
|
|
232
|
+
// Chunk 9 (PLAN-seed-and-surface): one line under the next-chunk display
|
|
233
|
+
// saying whether the last ticked chunk actually closed. Verified shas stay
|
|
234
|
+
// silent; only the newest ticked chunk is ever mentioned — one line per
|
|
235
|
+
// kickoff, never a list (else vela's 64/72 sha-less chunks become noise).
|
|
236
|
+
const closureHint = (checked: string[]): string | null => {
|
|
237
|
+
const last = checked[checked.length - 1];
|
|
238
|
+
if (!last) return null;
|
|
239
|
+
const { missing, diverged, cited } = checkTickedLine(last, root);
|
|
240
|
+
const label = /chunk\s+([^\s—–-]+)/i.exec(last)?.[1] ?? "latest";
|
|
241
|
+
if (missing.length)
|
|
242
|
+
return `⚠ chunk ${label} is ticked but ${missing[0]} is not in git — nothing proves it closed`;
|
|
243
|
+
if (diverged.length)
|
|
244
|
+
return `⚠ chunk ${label} is ticked but ${diverged[0]} is not on HEAD (rebased away?)`;
|
|
245
|
+
if (!cited)
|
|
246
|
+
return `⚠ chunk ${label} is ticked but cites no commit — nothing to verify it closed`;
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
|
|
120
250
|
/** Check if a plan has `priority: high` in its frontmatter. */
|
|
121
251
|
const hasHighPriority = (planPath: string): boolean => {
|
|
122
252
|
try {
|
|
@@ -200,6 +330,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
200
330
|
// If no arg or arg didn't match an id/spec, check if it's a plan file
|
|
201
331
|
let planFile: string | null = null;
|
|
202
332
|
let planCheckboxes: string[] = [];
|
|
333
|
+
let planChecked: string[] = [];
|
|
203
334
|
|
|
204
335
|
if (arg && !resolvedSpec && !target) {
|
|
205
336
|
if (matches.length === 1) {
|
|
@@ -233,14 +364,18 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
233
364
|
|
|
234
365
|
// Read plan file if we found one (or if a .md arg matched a spec)
|
|
235
366
|
if (planFile) {
|
|
236
|
-
|
|
367
|
+
const items = readPlanSectionItems(planFile);
|
|
368
|
+
planCheckboxes = items.unchecked;
|
|
369
|
+
planChecked = items.checked;
|
|
237
370
|
} else if (arg && resolvedSpec && arg.endsWith(".md")) {
|
|
238
371
|
// The spec resolved from the log might be a plan file — try to read it
|
|
239
372
|
const dir = planDir;
|
|
240
373
|
const byName = join(dir, basename(arg));
|
|
241
374
|
if (existsSync(byName)) {
|
|
242
375
|
planFile = byName;
|
|
243
|
-
|
|
376
|
+
const items = readPlanSectionItems(planFile);
|
|
377
|
+
planCheckboxes = items.unchecked;
|
|
378
|
+
planChecked = items.checked;
|
|
244
379
|
}
|
|
245
380
|
}
|
|
246
381
|
|
|
@@ -301,7 +436,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
301
436
|
const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
|
|
302
437
|
if (uniqueRecent.length) {
|
|
303
438
|
suggestions.push({
|
|
304
|
-
text:
|
|
439
|
+
text: `Recently touched: ${uniqueRecent.join(", ")}`,
|
|
305
440
|
run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
|
|
306
441
|
});
|
|
307
442
|
}
|
|
@@ -310,7 +445,7 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
310
445
|
try {
|
|
311
446
|
if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
|
|
312
447
|
suggestions.push({
|
|
313
|
-
text: "lint baseline
|
|
448
|
+
text: "lint baseline not captured yet — pre-existing red will count as yours",
|
|
314
449
|
run: "fapony lint-baseline --capture",
|
|
315
450
|
});
|
|
316
451
|
}
|
|
@@ -466,6 +601,12 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
466
601
|
if (!specRows.length && !specDecisions.length && !specCloses.length) {
|
|
467
602
|
console.log("(no entries for this spec)");
|
|
468
603
|
}
|
|
604
|
+
// The spec is also a plan file on disk — say whether the last ticked
|
|
605
|
+
// chunk actually closed (chunk 9). Silent when it verifies.
|
|
606
|
+
if (planFile) {
|
|
607
|
+
const hint = closureHint(planChecked);
|
|
608
|
+
if (hint) console.log(`\n${hint}`);
|
|
609
|
+
}
|
|
469
610
|
} else if (target) {
|
|
470
611
|
// id = a brief for that task
|
|
471
612
|
const claims = claimsOf(all);
|
|
@@ -525,6 +666,10 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
525
666
|
} else {
|
|
526
667
|
console.log(`\n(all chunks checked — ready to ship or archive)`);
|
|
527
668
|
}
|
|
669
|
+
// Whether the last ticked chunk actually closed (chunk 9) — one line,
|
|
670
|
+
// silent when its sha verifies.
|
|
671
|
+
const hint = closureHint(planChecked);
|
|
672
|
+
if (hint) console.log(hint);
|
|
528
673
|
} else if (arg) {
|
|
529
674
|
console.error(`no id "${arg}" in the log`);
|
|
530
675
|
process.exit(1);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// commands/write.ts — mutating commands: add, close, claim, release, synced, hook
|
|
2
2
|
|
|
3
|
+
import { CapError, engineAdd, engineClose } from "../engine.js";
|
|
3
4
|
import { claimsOf, openRows } from "../selectors.js";
|
|
4
5
|
import type { WorkKind } from "../store.js";
|
|
5
|
-
import { KINDS, memCmd,
|
|
6
|
+
import { KINDS, memCmd, put, root, rows } from "../store.js";
|
|
6
7
|
|
|
7
8
|
export const cmdAdd = async (a: string[]) => {
|
|
8
9
|
// mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
|
|
@@ -65,33 +66,23 @@ export const cmdAdd = async (a: string[]) => {
|
|
|
65
66
|
}
|
|
66
67
|
text = filtered.join(" ");
|
|
67
68
|
}
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (a[0] === "hold" && !process.env.MEM_FORCE) {
|
|
83
|
-
const openHold = openRows(all).filter((r) => r.kind === "hold").length;
|
|
84
|
-
if (openHold >= CAP_HOLD) {
|
|
85
|
-
console.error(
|
|
86
|
-
`open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first (or MEM_FORCE=1)`,
|
|
87
|
-
);
|
|
88
|
-
process.exit(1);
|
|
69
|
+
// Domain rules (caps, id) live in the shared engine — this wrapper owns
|
|
70
|
+
// only argv surface and the MEM_FORCE hint wording (PLAN-unify-mem-engine).
|
|
71
|
+
try {
|
|
72
|
+
const { id } = engineAdd({ kind: a[0], text, spec, files });
|
|
73
|
+
console.log(id);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e instanceof CapError) {
|
|
76
|
+
const hint =
|
|
77
|
+
e.cap === "next"
|
|
78
|
+
? " (or MEM_FORCE=1 if you really must)"
|
|
79
|
+
: " (or MEM_FORCE=1)";
|
|
80
|
+
console.error(`${e.message}${hint}`);
|
|
81
|
+
} else {
|
|
82
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
89
83
|
}
|
|
84
|
+
process.exit(1);
|
|
90
85
|
}
|
|
91
|
-
// ponytail: prevent id collisions — logic centralized in nextId (store.ts)
|
|
92
|
-
const id = nextId(all);
|
|
93
|
-
put({ id, kind: a[0] as WorkKind, text, spec, files });
|
|
94
|
-
console.log(id);
|
|
95
86
|
};
|
|
96
87
|
|
|
97
88
|
export const cmdClose = async (a: string[]) => {
|
|
@@ -101,10 +92,6 @@ export const cmdClose = async (a: string[]) => {
|
|
|
101
92
|
console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
|
|
102
93
|
process.exit(1);
|
|
103
94
|
}
|
|
104
|
-
if (!rows().some((r) => "id" in r && r.id === a[0])) {
|
|
105
|
-
console.error(`no id "${a[0]}" in the log`);
|
|
106
|
-
process.exit(1);
|
|
107
|
-
}
|
|
108
95
|
const rest = a.slice(1);
|
|
109
96
|
const useStdin = rest.includes("--stdin");
|
|
110
97
|
let text: string;
|
|
@@ -113,7 +100,15 @@ export const cmdClose = async (a: string[]) => {
|
|
|
113
100
|
} else {
|
|
114
101
|
text = rest.join(" ");
|
|
115
102
|
}
|
|
116
|
-
|
|
103
|
+
// Tombstone rules (id must exist, text required) live in the shared engine —
|
|
104
|
+
// note this tightens CLI: an empty-text close now errors instead of writing
|
|
105
|
+
// a textless tombstone (intentional, PLAN-unify-mem-engine §4).
|
|
106
|
+
try {
|
|
107
|
+
engineClose({ id: a[0], text });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
117
112
|
};
|
|
118
113
|
|
|
119
114
|
export const cmdClaim = (a: string[]) => {
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// src/mem/engine.ts — the single validation+write engine for mem add/close.
|
|
2
|
+
//
|
|
3
|
+
// Both entry points call it: CLI (commands/write.ts cmdAdd/cmdClose) and MCP
|
|
4
|
+
// (adapters/mcp/tools/mem.ts memAdd/memClose). Every domain rule lives here
|
|
5
|
+
// exactly once — kind check, files[] required, hold-requires-spec, open
|
|
6
|
+
// next/hold caps. Wrappers own only their surface: argv parsing +
|
|
7
|
+
// console.error/exit (CLI), worktree-shape checks + JSON results (MCP).
|
|
8
|
+
//
|
|
9
|
+
// Precondition: the store is initialized (initStore) before calling — cli.ts
|
|
10
|
+
// does it at dispatch, the MCP wrapper does it per call with its worktree.
|
|
11
|
+
// (PLAN-unify-mem-engine chunk 1)
|
|
12
|
+
|
|
13
|
+
import { openRows } from "./selectors.js";
|
|
14
|
+
import { KINDS, nextId, put, rows, type WorkKind } from "./store.js";
|
|
15
|
+
|
|
16
|
+
export const CAP_NEXT = 15;
|
|
17
|
+
export const CAP_HOLD = 10;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when an open next/hold cap is full. Carries which cap so the CLI
|
|
21
|
+
* wrapper can append its MEM_FORCE hint while MCP passes the bare message
|
|
22
|
+
* (the two surfaces keep their exact legacy strings — PLAN §4).
|
|
23
|
+
*/
|
|
24
|
+
export class CapError extends Error {
|
|
25
|
+
readonly cap: "next" | "hold";
|
|
26
|
+
constructor(cap: "next" | "hold", message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CapError";
|
|
29
|
+
this.cap = cap;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EngineAddArgs {
|
|
34
|
+
kind: string;
|
|
35
|
+
text: string;
|
|
36
|
+
files: string[];
|
|
37
|
+
spec?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EngineAddResult {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: string;
|
|
43
|
+
text: string;
|
|
44
|
+
files: string[];
|
|
45
|
+
spec?: string;
|
|
46
|
+
ts: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function engineAdd(a: EngineAddArgs): EngineAddResult {
|
|
50
|
+
if (!KINDS.includes(a.kind as WorkKind)) {
|
|
51
|
+
throw new Error(`kind must be one of ${KINDS.join("|")} — got "${a.kind}"`);
|
|
52
|
+
}
|
|
53
|
+
if (a.files.length === 0) {
|
|
54
|
+
throw new Error("files must contain at least one path");
|
|
55
|
+
}
|
|
56
|
+
if (!a.text.trim()) {
|
|
57
|
+
throw new Error("text is required and must not be empty");
|
|
58
|
+
}
|
|
59
|
+
// Without a spec a hold can never be resolved by rotate — reject at write.
|
|
60
|
+
if (a.kind === "hold" && !a.spec) {
|
|
61
|
+
throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const all = rows();
|
|
65
|
+
if (a.kind === "next" && !process.env.MEM_FORCE) {
|
|
66
|
+
const openNext = openRows(all).filter((r) => r.kind === "next").length;
|
|
67
|
+
if (openNext >= CAP_NEXT) {
|
|
68
|
+
throw new CapError(
|
|
69
|
+
"next",
|
|
70
|
+
`open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (a.kind === "hold" && !process.env.MEM_FORCE) {
|
|
75
|
+
const openHold = openRows(all).filter((r) => r.kind === "hold").length;
|
|
76
|
+
if (openHold >= CAP_HOLD) {
|
|
77
|
+
throw new CapError(
|
|
78
|
+
"hold",
|
|
79
|
+
`open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const id = nextId(all);
|
|
85
|
+
const ts = new Date().toISOString();
|
|
86
|
+
put({
|
|
87
|
+
id,
|
|
88
|
+
kind: a.kind as WorkKind,
|
|
89
|
+
text: a.text,
|
|
90
|
+
spec: a.spec,
|
|
91
|
+
files: a.files,
|
|
92
|
+
});
|
|
93
|
+
return { id, kind: a.kind, text: a.text, files: a.files, spec: a.spec, ts };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface EngineCloseArgs {
|
|
97
|
+
id: string;
|
|
98
|
+
text: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface EngineCloseResult {
|
|
102
|
+
ref: string;
|
|
103
|
+
text: string;
|
|
104
|
+
ts: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function engineClose(a: EngineCloseArgs): EngineCloseResult {
|
|
108
|
+
if (!a.id.trim()) {
|
|
109
|
+
throw new Error("id is required");
|
|
110
|
+
}
|
|
111
|
+
if (!a.text.trim()) {
|
|
112
|
+
throw new Error("text is required and must not be empty");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const all = rows();
|
|
116
|
+
if (!all.some((r) => "id" in r && r.id === a.id)) {
|
|
117
|
+
throw new Error(`no id "${a.id}" in the log`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ts = new Date().toISOString();
|
|
121
|
+
put({ kind: "close", ref: a.id, text: a.text });
|
|
122
|
+
|
|
123
|
+
return { ref: a.id, text: a.text, ts };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- find ---
|
|
127
|
+
//
|
|
128
|
+
// The single query engine for mem find. Both entry points call it with rows
|
|
129
|
+
// they already read — CLI (commands/read.ts cmdFind, via allRows() which
|
|
130
|
+
// includes rotated archives) and MCP (adapters/mcp/tools/mem.ts memFind, via
|
|
131
|
+
// readMemLog whose loose log*.jsonl regex includes log.YYYY-MM-DD.jsonl too).
|
|
132
|
+
// Archive scope is therefore identical on both sides with no parameter for it
|
|
133
|
+
// (PLAN-unify-mem-engine chunk 2 §4.1: the "MCP reads live only" premise was
|
|
134
|
+
// wrong — both see archives, base rate 0 archive files vs 211 live rows).
|
|
135
|
+
//
|
|
136
|
+
// The engine is pure: it takes rows + typed params, never touches argv
|
|
137
|
+
// strings or the store. Wrappers own their surface — CLI parses
|
|
138
|
+
// --kind/--files/--since/--limit/--open at the argv layer, MCP validates its JSON
|
|
139
|
+
// shape — and each passes its own kind default (PLAN §5 escape):
|
|
140
|
+
// MCP passes no exclude (contract: "every kind, no default filter", locked by
|
|
141
|
+
// test), CLI passes the bookkeeping exclude to keep its legacy output.
|
|
142
|
+
|
|
143
|
+
export const FIND_DEFAULT_LIMIT = 20;
|
|
144
|
+
|
|
145
|
+
/** Bookkeeping rows the CLI hides unless explicitly asked via --kind. */
|
|
146
|
+
export const CLI_FIND_EXCLUDE = ["close", "synced", "claim", "release"];
|
|
147
|
+
|
|
148
|
+
export interface EngineFindArgs {
|
|
149
|
+
/** Substring over text/spec/ref, case-insensitive. Omit = no text filter. */
|
|
150
|
+
text?: string;
|
|
151
|
+
/** Repo-relative paths — stored files[] first, text/spec/ref fallback. */
|
|
152
|
+
files?: string[];
|
|
153
|
+
/** Include filter — when non-empty, wins over excludeKind. */
|
|
154
|
+
kind?: string[];
|
|
155
|
+
/** Exclude filter — applied only when kind is empty. */
|
|
156
|
+
excludeKind?: string[];
|
|
157
|
+
/** ISO timestamp — only rows at or after this time (inclusive). */
|
|
158
|
+
sinceIso?: string;
|
|
159
|
+
/** Max rows returned (total still counts all matches). Default 20. */
|
|
160
|
+
limit?: number;
|
|
161
|
+
/**
|
|
162
|
+
* true = unresolved work only: drops bookkeeping kinds
|
|
163
|
+
* (close/claim/release/synced) plus work rows a close row points at.
|
|
164
|
+
* Mirrors selectors.openRows but stays generic (no store types) so MCP rows
|
|
165
|
+
* qualify. Default false — recall shows closed rows too.
|
|
166
|
+
*/
|
|
167
|
+
open?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface EngineFindResult<T> {
|
|
171
|
+
rows: T[];
|
|
172
|
+
total: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type FindableRow = {
|
|
176
|
+
kind: string;
|
|
177
|
+
text?: string;
|
|
178
|
+
spec?: string;
|
|
179
|
+
ref?: string;
|
|
180
|
+
files?: string[];
|
|
181
|
+
ts: string;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export function engineFind<T extends FindableRow>(
|
|
185
|
+
all: T[],
|
|
186
|
+
a: EngineFindArgs,
|
|
187
|
+
): EngineFindResult<T> {
|
|
188
|
+
let out = [...all];
|
|
189
|
+
|
|
190
|
+
if (a.open === true) {
|
|
191
|
+
const dead = new Set(
|
|
192
|
+
all
|
|
193
|
+
.filter((r) => r.kind === "close" && typeof r.ref === "string")
|
|
194
|
+
.map((r) => r.ref as string),
|
|
195
|
+
);
|
|
196
|
+
const BOOKKEEPING = new Set(["close", "claim", "release", "synced"]);
|
|
197
|
+
out = out.filter(
|
|
198
|
+
(r) =>
|
|
199
|
+
!BOOKKEEPING.has(r.kind) &&
|
|
200
|
+
!("id" in r && typeof r.id === "string" && dead.has(r.id)),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (a.sinceIso) {
|
|
205
|
+
const since = a.sinceIso;
|
|
206
|
+
out = out.filter((r) => !(r.ts < since));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (a.kind && a.kind.length > 0) {
|
|
210
|
+
const keep = new Set(a.kind);
|
|
211
|
+
out = out.filter((r) => keep.has(r.kind));
|
|
212
|
+
} else if (a.excludeKind && a.excludeKind.length > 0) {
|
|
213
|
+
const drop = new Set(a.excludeKind);
|
|
214
|
+
out = out.filter((r) => !drop.has(r.kind));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (a.text?.trim()) {
|
|
218
|
+
const needle = a.text.toLowerCase();
|
|
219
|
+
out = out.filter((r) =>
|
|
220
|
+
`${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`
|
|
221
|
+
.toLowerCase()
|
|
222
|
+
.includes(needle),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (a.files && a.files.length > 0) {
|
|
227
|
+
// Rows written by `mem add --files` carry files[] — match that first.
|
|
228
|
+
// Older rows (and any row whose author skipped --files) have none, so the
|
|
229
|
+
// text/spec/ref substring stays as the fallback: low recall by nature,
|
|
230
|
+
// a limit of the data rather than of the query.
|
|
231
|
+
const paths = a.files.map((f) => f.toLowerCase());
|
|
232
|
+
out = out.filter((r) => {
|
|
233
|
+
const stored = (r.files ?? []).map((f) => f.toLowerCase());
|
|
234
|
+
if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
|
|
235
|
+
return true;
|
|
236
|
+
const hay =
|
|
237
|
+
`${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
|
|
238
|
+
return paths.some((p) => hay.includes(p));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Newest first — the canonical order. The CLI wrapper reprints oldest-first
|
|
243
|
+
// to keep its legacy output byte-identical (same set, legacy order).
|
|
244
|
+
out.sort((x, y) => y.ts.localeCompare(x.ts));
|
|
245
|
+
|
|
246
|
+
const total = out.length;
|
|
247
|
+
const limit = Math.max(0, a.limit ?? FIND_DEFAULT_LIMIT);
|
|
248
|
+
return { rows: out.slice(0, limit), total };
|
|
249
|
+
}
|