fapony 0.6.2 → 0.7.1
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 +144 -156
- package/package.json +5 -6
- package/skill/define-convention/SKILL.md +3 -3
- package/skill/lookup-before-edit/SKILL.md +2 -1
- package/skill/move-to-done/SKILL.md +18 -22
- package/skill/plan-with-pony/SKILL.md +4 -4
- package/skill/review-pony/SKILL.md +10 -12
- package/src/adapters/cli.ts +11 -52
- package/src/adapters/hooks/index.ts +0 -67
- package/src/adapters/hooks/mv-guard.ts +2 -2
- package/src/analyze/discover.ts +1 -1
- package/src/analyze/index.ts +0 -1
- package/src/commands.ts +8 -39
- package/src/core/config.ts +0 -6
- package/src/core/fapony-dir.ts +71 -0
- package/src/core/hook-helpers.ts +1 -2
- package/src/debt/load.ts +3 -5
- package/src/debt/promotion.ts +2 -2
- package/src/debt/scan.ts +1 -1
- package/src/digest/collect.ts +6 -7
- package/src/fael.ts +139 -0
- package/src/hook.ts +1 -81
- package/src/init.ts +30 -90
- package/src/install/antigravity.ts +8 -99
- package/src/install/claude.ts +66 -200
- package/src/install/codex.ts +6 -170
- package/src/install/detect.ts +4 -14
- package/src/install/opencode.ts +50 -486
- package/src/install/types.ts +1 -39
- package/src/install/zcode.ts +5 -91
- package/src/install.ts +9 -35
- package/src/memory.ts +10 -69
- package/src/plan/index.ts +34 -0
- package/src/plan/next.ts +181 -0
- package/src/plan/store.ts +41 -0
- package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
- package/src/seed/plan-seed.ts +10 -9
- package/src/setup.ts +4 -22
- package/src/update.ts +1 -1
- package/templates/PLAN.md +1 -1
- package/src/adapters/hooks/bug-markers.ts +0 -60
- package/src/adapters/hooks/context-data.ts +0 -139
- package/src/adapters/hooks/edit-hint.ts +0 -215
- package/src/adapters/hooks/read-hint.ts +0 -410
- package/src/adapters/hooks/session-start.ts +0 -112
- package/src/adapters/hooks/stop.ts +0 -768
- package/src/adapters/mcp/tools/index.ts +0 -149
- package/src/adapters/mcp/tools/mem.ts +0 -252
- package/src/adapters/mcp/transport.ts +0 -147
- package/src/analyze/cache.ts +0 -162
- package/src/core/mem-log.ts +0 -375
- package/src/init-mem.ts +0 -143
- package/src/install/cursor.ts +0 -167
- package/src/install/utils.ts +0 -29
- package/src/mem/commands/read.ts +0 -738
- package/src/mem/commands/rotate.ts +0 -59
- package/src/mem/commands/where.ts +0 -56
- package/src/mem/commands/write.ts +0 -273
- package/src/mem/engine.ts +0 -315
- package/src/mem/index.ts +0 -201
- package/src/mem/render.ts +0 -66
- package/src/mem/selectors.ts +0 -160
- package/src/mem/store.ts +0 -356
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// commands/rotate.ts — compact log.jsonl once it grows past a row-count threshold
|
|
2
|
-
// keep open work rows + active claims; the rest (close/release/synced of already-closed refs)
|
|
3
|
-
// is git mv'd to a separate archive file (not deleted) — old history remains, searchable via git log/git show
|
|
4
|
-
|
|
5
|
-
import { existsSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { rotateKeep } from "../selectors.js";
|
|
8
|
-
import { appendRaw, dir, LOG, rows } from "../store.js";
|
|
9
|
-
|
|
10
|
-
// ponytail: threshold = total row count, not just open — the fear is a bloated file / slow grep when many people use it at once
|
|
11
|
-
// (the view being unreadable is a separate problem the CAP in write.ts already handles)
|
|
12
|
-
// 3000 estimated from one solo week = ~2k rows — tune with MEM_ROTATE_THRESHOLD if the pace differs a lot from this
|
|
13
|
-
export const THRESHOLD = Number(process.env.MEM_ROTATE_THRESHOLD) || 3000;
|
|
14
|
-
|
|
15
|
-
export const cmdRotate = (a: string[]) => {
|
|
16
|
-
const apply = a.includes("--apply");
|
|
17
|
-
const all = rows();
|
|
18
|
-
const over = all.length >= THRESHOLD;
|
|
19
|
-
|
|
20
|
-
if (!apply) {
|
|
21
|
-
console.log(
|
|
22
|
-
`${all.length} rows (threshold ${THRESHOLD})${over ? " — over the threshold, run --apply" : " — under the threshold"}`,
|
|
23
|
-
);
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
if (!over && !process.env.MEM_FORCE) {
|
|
27
|
-
console.log(
|
|
28
|
-
`${all.length}/${THRESHOLD} rows — under the threshold, no rotate needed (MEM_FORCE=1 to do it anyway)`,
|
|
29
|
-
);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const keep = rotateKeep(all);
|
|
34
|
-
const stamp = new Date().toISOString().slice(0, 10);
|
|
35
|
-
const archived = join(dir, `log.${stamp}.jsonl`);
|
|
36
|
-
if (existsSync(archived)) {
|
|
37
|
-
console.error(
|
|
38
|
-
`${archived} already exists (rotated today?) — remove or move it first to run again`,
|
|
39
|
-
);
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// like plan-sweep: stage first to stop git mv failing silently if the file is untracked (exit 128)
|
|
44
|
-
Bun.spawnSync(["git", "add", LOG]);
|
|
45
|
-
const mv = Bun.spawnSync(["git", "mv", LOG, archived]);
|
|
46
|
-
if (mv.exitCode !== 0) {
|
|
47
|
-
console.error(
|
|
48
|
-
`git mv failed (${mv.stderr.toString().trim()}) — rotate aborted`,
|
|
49
|
-
);
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
writeFileSync(LOG, "");
|
|
54
|
-
for (const r of keep) appendRaw(LOG, r);
|
|
55
|
-
|
|
56
|
-
console.log(
|
|
57
|
-
`rotated: ${all.length} rows → archive ${archived.replace(`${dir}/`, "")} (git history intact), ${keep.length} row(s) left in log.jsonl (open + active claims)`,
|
|
58
|
-
);
|
|
59
|
-
};
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
// src/mem/commands/where.ts — `fapony mem where`: show which mem dir is resolved and how
|
|
2
|
-
|
|
3
|
-
import { whereMemDir } from "../../memory.js";
|
|
4
|
-
|
|
5
|
-
const USAGE = "usage: fapony mem [--mem-dir <path>] where [--from <dir>]";
|
|
6
|
-
|
|
7
|
-
export function cmdWhere(args: string[], memDir?: string): void {
|
|
8
|
-
let fromDir: string | undefined;
|
|
9
|
-
|
|
10
|
-
for (let i = 0; i < args.length; i++) {
|
|
11
|
-
if (args[i] === "--from") {
|
|
12
|
-
const v = args[i + 1];
|
|
13
|
-
if (!v || v.startsWith("--")) {
|
|
14
|
-
console.error(`fapony mem where: --from needs a value\n${USAGE}`);
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
17
|
-
fromDir = v;
|
|
18
|
-
i++;
|
|
19
|
-
} else if (args[i] === "-h" || args[i] === "--help") {
|
|
20
|
-
console.log(USAGE);
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const result = whereMemDir(fromDir, memDir);
|
|
26
|
-
|
|
27
|
-
if (result.step === "ambiguous") {
|
|
28
|
-
console.error(
|
|
29
|
-
"fapony mem: two mem dirs at the same level, refusing to guess",
|
|
30
|
-
);
|
|
31
|
-
for (const c of result.candidates ?? []) console.error(` ${c}`);
|
|
32
|
-
console.error("cd into one of them, set paths.memDir, or pass --mem-dir");
|
|
33
|
-
process.exit(1);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (!result.dir) {
|
|
37
|
-
console.log(
|
|
38
|
-
`fapony mem where — no .fapony/.memory/ found (step: ${result.step})`,
|
|
39
|
-
);
|
|
40
|
-
console.log(" Create one with: fapony init <path>");
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const labels: Record<string, string> = {
|
|
45
|
-
flag: "--mem-dir flag",
|
|
46
|
-
config: "paths.memDir in fapony.config.json",
|
|
47
|
-
"walk-up": "walked up from cwd",
|
|
48
|
-
"repo-root": "git repo root fallback",
|
|
49
|
-
ambiguous: "ambiguous",
|
|
50
|
-
none: "not found",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
console.log(`fapony mem where`);
|
|
54
|
-
console.log(` dir: ${result.dir}`);
|
|
55
|
-
console.log(` step: ${labels[result.step] ?? result.step}`);
|
|
56
|
-
}
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
// commands/write.ts — mutating commands: add, close, claim, release, synced, hook
|
|
2
|
-
|
|
3
|
-
import { CapError, engineAdd, engineClose } from "../engine.js";
|
|
4
|
-
import { claimsOf, openRows } from "../selectors.js";
|
|
5
|
-
import type { WorkKind } from "../store.js";
|
|
6
|
-
import { KINDS, memCmd, put, root, rows } from "../store.js";
|
|
7
|
-
|
|
8
|
-
export const cmdAdd = async (a: string[]) => {
|
|
9
|
-
// mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
|
|
10
|
-
// mem add <kind> --stdin --files f1,f2 [spec.md] ← read text from stdin (avoids shell metachar issues)
|
|
11
|
-
// ponytail: a wrong kind = that row silently vanishes from the view — better to die right here
|
|
12
|
-
if (!KINDS.includes(a[0] as WorkKind)) {
|
|
13
|
-
console.error(
|
|
14
|
-
`kind must be one of ${KINDS.join("|")} — got "${a[0] ?? ""}"`,
|
|
15
|
-
);
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
18
|
-
// hold requires a spec — checked after parse (the spec may come before --files)
|
|
19
|
-
// PLAN-convention-debt chunk 3: files[] is required — a measured optional field
|
|
20
|
-
// had fill rate 0 (required+enum = 50/50) and recall on old logs without files[] caught only
|
|
21
|
-
// 48.1% — fix the write side, not the read side: a row that does not name the file = unfindable when you touch that file
|
|
22
|
-
|
|
23
|
-
// Positional reparse (PLAN-comma-x chunk 2): walk argv once, consume every
|
|
24
|
-
// known flag occurrence, keep the rest as positional = text + optional
|
|
25
|
-
// trailing spec.md. The old filter removed only the first --files value by
|
|
26
|
-
// identity, so `--files a --files b` left the stray `b` in the row text
|
|
27
|
-
// (live corruption, row muds6zg5) — and a text word equal to the files
|
|
28
|
-
// value was eaten instead. Flag strip stays positional, never by value:
|
|
29
|
-
// a key like "fix" may legitimately sit inside the text.
|
|
30
|
-
const positional: string[] = [];
|
|
31
|
-
const rawFiles: string[] = [];
|
|
32
|
-
let filesValueMissing = false;
|
|
33
|
-
let useStdin = false;
|
|
34
|
-
let sawKey = false;
|
|
35
|
-
let keyVal: string | undefined;
|
|
36
|
-
const rest = a.slice(1);
|
|
37
|
-
for (let i = 0; i < rest.length; i++) {
|
|
38
|
-
const t = rest[i];
|
|
39
|
-
if (t === "--stdin") {
|
|
40
|
-
useStdin = true;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (t === "--files") {
|
|
44
|
-
const v = rest[i + 1];
|
|
45
|
-
if (v !== undefined && !v.startsWith("--")) {
|
|
46
|
-
rawFiles.push(v);
|
|
47
|
-
i++;
|
|
48
|
-
} else {
|
|
49
|
-
filesValueMissing = true; // dies in validation below
|
|
50
|
-
}
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (t === "--key" || t.startsWith("--key=")) {
|
|
54
|
-
const eq = t.startsWith("--key=");
|
|
55
|
-
const v = eq ? t.slice("--key=".length) : rest[i + 1];
|
|
56
|
-
if (!sawKey) {
|
|
57
|
-
sawKey = true;
|
|
58
|
-
keyVal = v; // first occurrence wins — validated below
|
|
59
|
-
}
|
|
60
|
-
// Consume the value token on every occurrence so a stray repeat never
|
|
61
|
-
// leaks into the text (same leak class as a repeated --files).
|
|
62
|
-
if (!eq && v !== undefined && !v.startsWith("--")) i++;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
positional.push(t);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Validation order unchanged from the old parser: files → key → hold → text.
|
|
69
|
-
if (filesValueMissing || rawFiles.length === 0) {
|
|
70
|
-
console.error(
|
|
71
|
-
`--files is required — usage: ${memCmd} add ${a[0]} "<text>" --files path/to/file.ts[,more] [spec.md]`,
|
|
72
|
-
);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
const files: string[] = [];
|
|
76
|
-
for (const v of rawFiles) {
|
|
77
|
-
const parts = v
|
|
78
|
-
.split(",")
|
|
79
|
-
.map((s) => s.trim().replace(/^\.\//, ""))
|
|
80
|
-
.filter(Boolean);
|
|
81
|
-
if (parts.length === 0) {
|
|
82
|
-
console.error(
|
|
83
|
-
`--files needs at least one path — usage: ${memCmd} add ${a[0]} "<text>" --files path/to/file.ts[,more]`,
|
|
84
|
-
);
|
|
85
|
-
process.exit(1);
|
|
86
|
-
}
|
|
87
|
-
files.push(...parts);
|
|
88
|
-
}
|
|
89
|
-
// --key is optional; pattern validation lives in engineAdd (one checker,
|
|
90
|
-
// both surfaces — PLAN-mem-keys chunk 1). argv layer only extracts it.
|
|
91
|
-
// Accepts `--key value` and `--key=value` alike (same as cmdFind) — an
|
|
92
|
-
// exact-match lookup for "--key" would leave a `--key=x` token inside the
|
|
93
|
-
// text and write a keyless row with exit 0 (silent drop, never allowed).
|
|
94
|
-
if (sawKey && (!keyVal || keyVal.startsWith("--"))) {
|
|
95
|
-
console.error(
|
|
96
|
-
`--key needs a value — usage: ${memCmd} add ${a[0]} "<text>" --files f1,f2 --key fix-stop-dedupe`,
|
|
97
|
-
);
|
|
98
|
-
process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
const spec = positional.at(-1)?.endsWith(".md")
|
|
101
|
-
? positional.pop()
|
|
102
|
-
: undefined;
|
|
103
|
-
if (a[0] === "hold" && !spec) {
|
|
104
|
-
console.error(
|
|
105
|
-
`hold requires a spec — usage: ${memCmd} add hold "..." --files f1,f2 <spec.md>`,
|
|
106
|
-
);
|
|
107
|
-
process.exit(1);
|
|
108
|
-
}
|
|
109
|
-
let text: string;
|
|
110
|
-
if (useStdin) {
|
|
111
|
-
text = (await Bun.stdin.text()).trim();
|
|
112
|
-
if (!text) {
|
|
113
|
-
console.error("stdin was empty — text is required");
|
|
114
|
-
process.exit(1);
|
|
115
|
-
}
|
|
116
|
-
} else {
|
|
117
|
-
// text is required
|
|
118
|
-
if (positional.length === 0) {
|
|
119
|
-
console.error(
|
|
120
|
-
`text is required — usage: ${memCmd} add <kind> "<text>" --files f1,f2 [spec.md]`,
|
|
121
|
-
);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
text = positional.join(" ");
|
|
125
|
-
}
|
|
126
|
-
// Domain rules (caps, id) live in the shared engine — this wrapper owns
|
|
127
|
-
// only argv surface and the MEM_FORCE hint wording (PLAN-unify-mem-engine).
|
|
128
|
-
try {
|
|
129
|
-
const { id } = engineAdd({ kind: a[0], text, spec, files, key: keyVal });
|
|
130
|
-
console.log(id);
|
|
131
|
-
} catch (e) {
|
|
132
|
-
if (e instanceof CapError) {
|
|
133
|
-
const hint =
|
|
134
|
-
e.cap === "next"
|
|
135
|
-
? " (or MEM_FORCE=1 if you really must)"
|
|
136
|
-
: " (or MEM_FORCE=1)";
|
|
137
|
-
console.error(`${e.message}${hint}`);
|
|
138
|
-
} else {
|
|
139
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
140
|
-
}
|
|
141
|
-
process.exit(1);
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
export const cmdClose = async (a: string[]) => {
|
|
146
|
-
// mem close <id> "<what was done / commit>" — the tombstone voids the claim by itself
|
|
147
|
-
// mem close <id> --stdin ← read text from stdin
|
|
148
|
-
if (!a[0]) {
|
|
149
|
-
console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
152
|
-
const rest = a.slice(1);
|
|
153
|
-
const useStdin = rest.includes("--stdin");
|
|
154
|
-
let text: string;
|
|
155
|
-
if (useStdin) {
|
|
156
|
-
text = (await Bun.stdin.text()).trim();
|
|
157
|
-
} else {
|
|
158
|
-
text = rest.join(" ");
|
|
159
|
-
}
|
|
160
|
-
// Tombstone rules (id must exist, text required) live in the shared engine —
|
|
161
|
-
// note this tightens CLI: an empty-text close now errors instead of writing
|
|
162
|
-
// a textless tombstone (intentional, PLAN-unify-mem-engine §4).
|
|
163
|
-
try {
|
|
164
|
-
engineClose({ id: a[0], text });
|
|
165
|
-
} catch (e) {
|
|
166
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
167
|
-
process.exit(1);
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
export const cmdClaim = (a: string[]) => {
|
|
172
|
-
// mem claim <id> — id must be real, open, kind=next|bug, with no active claim pending
|
|
173
|
-
if (!a[0]) {
|
|
174
|
-
console.error(`id is required — usage: ${memCmd} claim <id>`);
|
|
175
|
-
process.exit(1);
|
|
176
|
-
}
|
|
177
|
-
const all = rows();
|
|
178
|
-
const open = openRows(all);
|
|
179
|
-
const target = open.find((r) => r.id === a[0]);
|
|
180
|
-
if (!target) {
|
|
181
|
-
const exists = all.find((r) => "id" in r && r.id === a[0]);
|
|
182
|
-
console.error(
|
|
183
|
-
exists
|
|
184
|
-
? `id "${a[0]}" exists but is already closed (close tombstone)`
|
|
185
|
-
: `no id "${a[0]}" in the log`,
|
|
186
|
-
);
|
|
187
|
-
process.exit(1);
|
|
188
|
-
}
|
|
189
|
-
if (target.kind !== "next" && target.kind !== "bug") {
|
|
190
|
-
console.error(
|
|
191
|
-
`only next/bug can be claimed — id "${a[0]}" is ${target.kind}`,
|
|
192
|
-
);
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
195
|
-
const claims = claimsOf(all);
|
|
196
|
-
if (claims.has(a[0])) {
|
|
197
|
-
const c = claims.get(a[0])!;
|
|
198
|
-
console.error(
|
|
199
|
-
`id "${a[0]}" is already claimed by ${c.agent} — release it first`,
|
|
200
|
-
);
|
|
201
|
-
process.exit(1);
|
|
202
|
-
}
|
|
203
|
-
put({ kind: "claim", ref: a[0] });
|
|
204
|
-
console.log(`claimed ${a[0]}`);
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
export const cmdRelease = async (a: string[]) => {
|
|
208
|
-
// mem release <id> "<reason>?"
|
|
209
|
-
// mem release <id> --stdin ← read text from stdin
|
|
210
|
-
if (!a[0]) {
|
|
211
|
-
console.error(`id is required — usage: ${memCmd} release <id> [reason]`);
|
|
212
|
-
process.exit(1);
|
|
213
|
-
}
|
|
214
|
-
const all = rows();
|
|
215
|
-
const claims = claimsOf(all);
|
|
216
|
-
if (!claims.has(a[0])) {
|
|
217
|
-
console.error(`id "${a[0]}" has no active claim`);
|
|
218
|
-
process.exit(1);
|
|
219
|
-
}
|
|
220
|
-
const rest = a.slice(1);
|
|
221
|
-
const useStdin = rest.includes("--stdin");
|
|
222
|
-
let text: string | undefined;
|
|
223
|
-
if (useStdin) {
|
|
224
|
-
const t = (await Bun.stdin.text()).trim();
|
|
225
|
-
text = t || undefined;
|
|
226
|
-
} else {
|
|
227
|
-
text = rest.join(" ") || undefined;
|
|
228
|
-
}
|
|
229
|
-
put({ kind: "release", ref: a[0], text });
|
|
230
|
-
console.log(`released ${a[0]}`);
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
export const cmdSynced = (a: string[]) => {
|
|
234
|
-
// mem synced [path.md ...] — declare the spec now matches the log (none given = every spec the log mentions)
|
|
235
|
-
const specs = a.length
|
|
236
|
-
? a
|
|
237
|
-
: [
|
|
238
|
-
...new Set(
|
|
239
|
-
rows()
|
|
240
|
-
.filter((r) => r.kind === "synced" || ("spec" in r && r.spec))
|
|
241
|
-
.map((r) => ("spec" in r ? r.spec : undefined))
|
|
242
|
-
.filter(Boolean) as string[],
|
|
243
|
-
),
|
|
244
|
-
];
|
|
245
|
-
for (const spec of specs) put({ kind: "synced", spec });
|
|
246
|
-
console.log(`synced ${specs.length} spec`);
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
export const cmdHook = async () => {
|
|
250
|
-
// PostToolUse: stdin = {tool_input:{file_path}} → editing this app's spec is done = mark synced automatically
|
|
251
|
-
const j = (await Bun.stdin.json().catch(() => null)) as Record<
|
|
252
|
-
string,
|
|
253
|
-
unknown
|
|
254
|
-
> | null;
|
|
255
|
-
const f =
|
|
256
|
-
j && typeof j === "object" && "tool_input" in j
|
|
257
|
-
? ((j as { tool_input?: { file_path?: string } }).tool_input?.file_path ??
|
|
258
|
-
"")
|
|
259
|
-
: "";
|
|
260
|
-
const { planDir, doneDir } = await import("../store.js");
|
|
261
|
-
const relF = f.startsWith(root) ? f.slice(root.length + 1) : f;
|
|
262
|
-
// match PLAN*.md under this project's plan/ or done/
|
|
263
|
-
const planRe = new RegExp(
|
|
264
|
-
`^${planDir.replace(`${root}/`, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/.*PLAN.*\\.md$`,
|
|
265
|
-
);
|
|
266
|
-
const doneRe = new RegExp(
|
|
267
|
-
`^${doneDir.replace(`${root}/`, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/.*PLAN.*\\.md$`,
|
|
268
|
-
);
|
|
269
|
-
if (planRe.test(relF) || doneRe.test(relF)) {
|
|
270
|
-
put({ kind: "synced", spec: relF });
|
|
271
|
-
console.log(`synced ${relF}`);
|
|
272
|
-
}
|
|
273
|
-
};
|
package/src/mem/engine.ts
DELETED
|
@@ -1,315 +0,0 @@
|
|
|
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 { KEY_RE } from "../core/mem-log.js";
|
|
14
|
-
import { openRows } from "./selectors.js";
|
|
15
|
-
import { KINDS, nextId, put, rows, type WorkKind } from "./store.js";
|
|
16
|
-
|
|
17
|
-
export const CAP_NEXT = 15;
|
|
18
|
-
export const CAP_HOLD = 10;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Thrown when an open next/hold cap is full. Carries which cap so the CLI
|
|
22
|
-
* wrapper can append its MEM_FORCE hint while MCP passes the bare message
|
|
23
|
-
* (the two surfaces keep their exact legacy strings — PLAN §4).
|
|
24
|
-
*/
|
|
25
|
-
export class CapError extends Error {
|
|
26
|
-
readonly cap: "next" | "hold";
|
|
27
|
-
constructor(cap: "next" | "hold", message: string) {
|
|
28
|
-
super(message);
|
|
29
|
-
this.name = "CapError";
|
|
30
|
-
this.cap = cap;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface EngineAddArgs {
|
|
35
|
-
kind: string;
|
|
36
|
-
text: string;
|
|
37
|
-
files: string[];
|
|
38
|
-
spec?: string;
|
|
39
|
-
/** Problem identity — optional, but validated against KEY_RE whenever present. */
|
|
40
|
-
key?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface EngineAddResult {
|
|
44
|
-
id: string;
|
|
45
|
-
kind: string;
|
|
46
|
-
text: string;
|
|
47
|
-
files: string[];
|
|
48
|
-
spec?: string;
|
|
49
|
-
key?: string;
|
|
50
|
-
ts: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function engineAdd(a: EngineAddArgs): EngineAddResult {
|
|
54
|
-
if (!KINDS.includes(a.kind as WorkKind)) {
|
|
55
|
-
throw new Error(`kind must be one of ${KINDS.join("|")} — got "${a.kind}"`);
|
|
56
|
-
}
|
|
57
|
-
if (a.files.length === 0) {
|
|
58
|
-
throw new Error("files must contain at least one path");
|
|
59
|
-
}
|
|
60
|
-
if (!a.text.trim()) {
|
|
61
|
-
throw new Error("text is required and must not be empty");
|
|
62
|
-
}
|
|
63
|
-
// Without a spec a hold can never be resolved by rotate — reject at write.
|
|
64
|
-
if (a.kind === "hold" && !a.spec) {
|
|
65
|
-
throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
|
|
66
|
-
}
|
|
67
|
-
// One validator for both surfaces (CLI argv reaches here too) — reject loudly
|
|
68
|
-
// with a usable example, never silently drop the key (SPEC-mem-keys §Validation).
|
|
69
|
-
if (a.key !== undefined && !KEY_RE.test(a.key)) {
|
|
70
|
-
throw new Error(
|
|
71
|
-
`key must match [a-z0-9-]{3,40} — e.g. "fix-stop-dedupe", got "${a.key}"`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const all = rows();
|
|
76
|
-
if (a.kind === "next" && !process.env.MEM_FORCE) {
|
|
77
|
-
const openNext = openRows(all).filter((r) => r.kind === "next").length;
|
|
78
|
-
if (openNext >= CAP_NEXT) {
|
|
79
|
-
throw new CapError(
|
|
80
|
-
"next",
|
|
81
|
-
`open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
if (a.kind === "hold" && !process.env.MEM_FORCE) {
|
|
86
|
-
const openHold = openRows(all).filter((r) => r.kind === "hold").length;
|
|
87
|
-
if (openHold >= CAP_HOLD) {
|
|
88
|
-
throw new CapError(
|
|
89
|
-
"hold",
|
|
90
|
-
`open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const id = nextId(all);
|
|
96
|
-
const ts = new Date().toISOString();
|
|
97
|
-
// Every row written from here on is v:2 — key optional, but the version
|
|
98
|
-
// stamps the schema so a reader can tell new rows from v:1 legacy ones.
|
|
99
|
-
// JSON.stringify drops the undefined key, so keyless rows carry only v.
|
|
100
|
-
put({
|
|
101
|
-
id,
|
|
102
|
-
kind: a.kind as WorkKind,
|
|
103
|
-
text: a.text,
|
|
104
|
-
spec: a.spec,
|
|
105
|
-
files: a.files,
|
|
106
|
-
key: a.key,
|
|
107
|
-
v: 2,
|
|
108
|
-
});
|
|
109
|
-
return {
|
|
110
|
-
id,
|
|
111
|
-
kind: a.kind,
|
|
112
|
-
text: a.text,
|
|
113
|
-
files: a.files,
|
|
114
|
-
spec: a.spec,
|
|
115
|
-
key: a.key,
|
|
116
|
-
ts,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export interface EngineCloseArgs {
|
|
121
|
-
id: string;
|
|
122
|
-
text: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface EngineCloseResult {
|
|
126
|
-
ref: string;
|
|
127
|
-
text: string;
|
|
128
|
-
ts: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function engineClose(a: EngineCloseArgs): EngineCloseResult {
|
|
132
|
-
if (!a.id.trim()) {
|
|
133
|
-
throw new Error("id is required");
|
|
134
|
-
}
|
|
135
|
-
if (!a.text.trim()) {
|
|
136
|
-
throw new Error("text is required and must not be empty");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const all = rows();
|
|
140
|
-
if (!all.some((r) => "id" in r && r.id === a.id)) {
|
|
141
|
-
throw new Error(`no id "${a.id}" in the log`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const ts = new Date().toISOString();
|
|
145
|
-
put({ kind: "close", ref: a.id, text: a.text });
|
|
146
|
-
|
|
147
|
-
return { ref: a.id, text: a.text, ts };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// --- find ---
|
|
151
|
-
//
|
|
152
|
-
// The single query engine for mem find. Both entry points call it with rows
|
|
153
|
-
// they already read — CLI (commands/read.ts cmdFind, via allRows() which
|
|
154
|
-
// includes rotated archives) and MCP (adapters/mcp/tools/mem.ts memFind, via
|
|
155
|
-
// readMemLog whose loose log*.jsonl regex includes log.YYYY-MM-DD.jsonl too).
|
|
156
|
-
// Archive scope is therefore identical on both sides with no parameter for it
|
|
157
|
-
// (PLAN-unify-mem-engine chunk 2 §4.1: the "MCP reads live only" premise was
|
|
158
|
-
// wrong — both see archives, base rate 0 archive files vs 211 live rows).
|
|
159
|
-
//
|
|
160
|
-
// The engine is pure: it takes rows + typed params, never touches argv
|
|
161
|
-
// strings or the store. Wrappers own their surface — CLI parses
|
|
162
|
-
// --kind/--files/--since/--limit/--open at the argv layer, MCP validates its JSON
|
|
163
|
-
// shape — and each passes its own kind default (PLAN §5 escape):
|
|
164
|
-
// MCP passes no exclude (contract: "every kind, no default filter", locked by
|
|
165
|
-
// test), CLI passes the bookkeeping exclude to keep its legacy output.
|
|
166
|
-
|
|
167
|
-
export const FIND_DEFAULT_LIMIT = 20;
|
|
168
|
-
|
|
169
|
-
/** Bookkeeping rows the CLI hides unless explicitly asked via --kind. */
|
|
170
|
-
export const CLI_FIND_EXCLUDE = ["close", "synced", "claim", "release"];
|
|
171
|
-
|
|
172
|
-
export interface EngineFindArgs {
|
|
173
|
-
/** Substring over text/spec/ref, case-insensitive. Omit = no text filter. */
|
|
174
|
-
text?: string;
|
|
175
|
-
/** Repo-relative paths — stored files[] first, text/spec/ref fallback. */
|
|
176
|
-
files?: string[];
|
|
177
|
-
/** Include filter — when non-empty, wins over excludeKind. */
|
|
178
|
-
kind?: string[];
|
|
179
|
-
/** Exclude filter — applied only when kind is empty. */
|
|
180
|
-
excludeKind?: string[];
|
|
181
|
-
/** ISO timestamp — only rows at or after this time (inclusive). */
|
|
182
|
-
sinceIso?: string;
|
|
183
|
-
/** Max rows returned (total still counts all matches). Default 20. */
|
|
184
|
-
limit?: number;
|
|
185
|
-
/**
|
|
186
|
-
* true = unresolved work only: drops bookkeeping kinds
|
|
187
|
-
* (close/claim/release/synced) plus work rows a close row points at.
|
|
188
|
-
* Mirrors selectors.openRows but stays generic (no store types) so MCP rows
|
|
189
|
-
* qualify. Default false — recall shows closed rows too.
|
|
190
|
-
*/
|
|
191
|
-
open?: boolean;
|
|
192
|
-
/**
|
|
193
|
-
* Exact problem-identity match — rows whose effective key differs fall out
|
|
194
|
-
* (v:1 rows included; they stay reachable via files/text). A close row
|
|
195
|
-
* matches through the key of the work row its ref points at (derived at
|
|
196
|
-
* read time, never stored). A pure miss also returns knownKeys.
|
|
197
|
-
*/
|
|
198
|
-
key?: string;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export interface EngineFindResult<T> {
|
|
202
|
-
rows: T[];
|
|
203
|
-
total: number;
|
|
204
|
-
/** Present only on a pure key miss — every distinct key in `all`, sorted. */
|
|
205
|
-
knownKeys?: string[];
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export type FindableRow = {
|
|
209
|
-
kind: string;
|
|
210
|
-
text?: string;
|
|
211
|
-
spec?: string;
|
|
212
|
-
ref?: string;
|
|
213
|
-
files?: string[];
|
|
214
|
-
key?: string;
|
|
215
|
-
ts: string;
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
export function engineFind<T extends FindableRow>(
|
|
219
|
-
all: T[],
|
|
220
|
-
a: EngineFindArgs,
|
|
221
|
-
): EngineFindResult<T> {
|
|
222
|
-
let out = [...all];
|
|
223
|
-
|
|
224
|
-
if (a.open === true) {
|
|
225
|
-
const dead = new Set(
|
|
226
|
-
all
|
|
227
|
-
.filter((r) => r.kind === "close" && typeof r.ref === "string")
|
|
228
|
-
.map((r) => r.ref as string),
|
|
229
|
-
);
|
|
230
|
-
const BOOKKEEPING = new Set(["close", "claim", "release", "synced"]);
|
|
231
|
-
out = out.filter(
|
|
232
|
-
(r) =>
|
|
233
|
-
!BOOKKEEPING.has(r.kind) &&
|
|
234
|
-
!("id" in r && typeof r.id === "string" && dead.has(r.id)),
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (a.sinceIso) {
|
|
239
|
-
const since = a.sinceIso;
|
|
240
|
-
out = out.filter((r) => !(r.ts < since));
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (a.kind && a.kind.length > 0) {
|
|
244
|
-
const keep = new Set(a.kind);
|
|
245
|
-
out = out.filter((r) => keep.has(r.kind));
|
|
246
|
-
} else if (a.excludeKind && a.excludeKind.length > 0) {
|
|
247
|
-
const drop = new Set(a.excludeKind);
|
|
248
|
-
out = out.filter((r) => !drop.has(r.kind));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Exact key match, before text/files — a wrong key must never fall through
|
|
252
|
-
// to substring luck. Close rows derive their key from the ref'd work row
|
|
253
|
-
// (read-time derive: match only; the row itself stays keyless). knownKeys
|
|
254
|
-
// fires only on a pure key miss — answer a wrong guess with the real list.
|
|
255
|
-
let knownKeys: string[] | undefined;
|
|
256
|
-
if (a.key) {
|
|
257
|
-
const want = a.key;
|
|
258
|
-
const idKey = new Map<string, string>();
|
|
259
|
-
for (const r of all) {
|
|
260
|
-
if ("id" in r && typeof r.id === "string" && r.key) {
|
|
261
|
-
idKey.set(r.id, r.key);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
const eff = (r: T): string | undefined =>
|
|
265
|
-
r.key ??
|
|
266
|
-
(r.kind === "close" && typeof r.ref === "string"
|
|
267
|
-
? idKey.get(r.ref)
|
|
268
|
-
: undefined);
|
|
269
|
-
out = out.filter((r) => eff(r) === want);
|
|
270
|
-
if (!all.some((r) => eff(r) === want)) {
|
|
271
|
-
const distinct = new Set<string>();
|
|
272
|
-
for (const r of all) {
|
|
273
|
-
if (r.key) distinct.add(r.key);
|
|
274
|
-
}
|
|
275
|
-
knownKeys = [...distinct].sort();
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (a.text?.trim()) {
|
|
280
|
-
const needle = a.text.toLowerCase();
|
|
281
|
-
out = out.filter((r) =>
|
|
282
|
-
`${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`
|
|
283
|
-
.toLowerCase()
|
|
284
|
-
.includes(needle),
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (a.files && a.files.length > 0) {
|
|
289
|
-
// Rows written by `mem add --files` carry files[] — match that first.
|
|
290
|
-
// Older rows (and any row whose author skipped --files) have none, so the
|
|
291
|
-
// text/spec/ref substring stays as the fallback: low recall by nature,
|
|
292
|
-
// a limit of the data rather than of the query.
|
|
293
|
-
const paths = a.files.map((f) => f.toLowerCase());
|
|
294
|
-
out = out.filter((r) => {
|
|
295
|
-
const stored = (r.files ?? []).map((f) => f.toLowerCase());
|
|
296
|
-
if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
|
|
297
|
-
return true;
|
|
298
|
-
const hay =
|
|
299
|
-
`${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
|
|
300
|
-
return paths.some((p) => hay.includes(p));
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Newest first — the canonical order. The CLI wrapper reprints oldest-first
|
|
305
|
-
// to keep its legacy output byte-identical (same set, legacy order).
|
|
306
|
-
out.sort((x, y) => y.ts.localeCompare(x.ts));
|
|
307
|
-
|
|
308
|
-
const total = out.length;
|
|
309
|
-
const limit = Math.max(0, a.limit ?? FIND_DEFAULT_LIMIT);
|
|
310
|
-
return {
|
|
311
|
-
rows: out.slice(0, limit),
|
|
312
|
-
total,
|
|
313
|
-
...(knownKeys ? { knownKeys } : {}),
|
|
314
|
-
};
|
|
315
|
-
}
|