fapony 0.1.3 → 0.2.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.
Files changed (51) hide show
  1. package/README.md +111 -46
  2. package/fapony.ts +35 -2
  3. package/package.json +3 -2
  4. package/skill/move-to-done/SKILL.md +18 -28
  5. package/skill/plan-with-pony/SKILL.md +8 -3
  6. package/src/analyze.ts +175 -5
  7. package/src/conventions-seed.ts +3 -3
  8. package/src/db/defaults.ts +13 -5
  9. package/src/db/getters.ts +11 -9
  10. package/src/db/load.ts +2 -2
  11. package/src/db/store.ts +0 -75
  12. package/src/db/types.ts +2 -4
  13. package/src/debt.ts +176 -36
  14. package/src/digest/collect.ts +19 -2
  15. package/src/digest/text.ts +25 -0
  16. package/src/hook.ts +665 -24
  17. package/src/init-mem.ts +123 -37
  18. package/src/init.ts +57 -39
  19. package/src/install/claude.ts +44 -8
  20. package/src/install/codex.ts +105 -13
  21. package/src/install/opencode.ts +169 -5
  22. package/src/install.ts +2 -1
  23. package/src/lint-baseline.ts +3 -8
  24. package/src/mcp/evidence.ts +15 -3
  25. package/src/mcp/tools/index.ts +97 -185
  26. package/src/mcp/tools/mem.ts +153 -11
  27. package/src/mcp/transport.ts +5 -13
  28. package/src/mem/commands/read.ts +448 -0
  29. package/src/mem/commands/where.ts +56 -0
  30. package/{templates → src}/mem/commands/write.ts +12 -5
  31. package/src/mem/index.ts +144 -0
  32. package/src/mem/store.ts +350 -0
  33. package/src/memory.ts +238 -40
  34. package/src/plan-seed.ts +26 -6
  35. package/src/review-seed.ts +19 -0
  36. package/src/setup.ts +7 -8
  37. package/src/stats/data.ts +40 -104
  38. package/src/stats/format.ts +8 -9
  39. package/src/stats/index.ts +0 -1
  40. package/templates/PLAN.md +1 -0
  41. package/src/mcp/tools/context.ts +0 -66
  42. package/src/mcp/tools/plans.ts +0 -255
  43. package/src/mcp/tools/stats.ts +0 -96
  44. package/templates/mem/commands/read.ts +0 -194
  45. package/templates/mem/commands/selftest.ts +0 -450
  46. package/templates/mem/mem.ts +0 -68
  47. package/templates/mem/store.ts +0 -285
  48. /package/{templates → src}/mem/commands/plan.ts +0 -0
  49. /package/{templates → src}/mem/commands/rotate.ts +0 -0
  50. /package/{templates → src}/mem/render.ts +0 -0
  51. /package/{templates → src}/mem/selectors.ts +0 -0
@@ -0,0 +1,448 @@
1
+ // commands/read.ts — read-only commands: now (default), done, stale, find, kickoff
2
+
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
6
+ import { claimsOf, openRows, staleReport } from "../selectors.js";
7
+ import type { CloseRow, WorkRow } from "../store.js";
8
+ import { allRows, app, memCmd, planDir, rows } from "../store.js";
9
+ import { planSweepCmd, shippedNotMoved } from "./plan.js";
10
+ import { THRESHOLD } from "./rotate.js";
11
+
12
+ const rotateLine = (n: number) =>
13
+ n >= THRESHOLD
14
+ ? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
15
+ : "";
16
+
17
+ const planSweepLine = () => {
18
+ const pending = shippedNotMoved();
19
+ return pending.length
20
+ ? `\n## 📦 shipped but not archived into done/ (${pending.length})\n` +
21
+ pending.map((n) => `- ${n}`).join("\n") +
22
+ `\n(move: ${planSweepCmd} <file.md> --apply)`
23
+ : "";
24
+ };
25
+
26
+ export const cmdNow = () => {
27
+ // mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
28
+ const all = rows();
29
+ console.log(`# ${app} — ${all.length} entries`);
30
+ printOpenRows(all, { showHold: true });
31
+ const decisionN = openRows(all).filter((r) => r.kind === "decision").length;
32
+ const noteN = openRows(all).filter((r) => r.kind === "note").length;
33
+ console.log(
34
+ `\n## decision ${decisionN} · note ${noteN} — search: ${memCmd} find <word>`,
35
+ );
36
+ const stale = staleReport(all);
37
+ if (stale.length)
38
+ console.log(
39
+ `\n## ⚠ stale (${stale.length})\n` +
40
+ stale.map((l) => `- ${l}`).join("\n"),
41
+ );
42
+ const sweep = planSweepLine();
43
+ if (sweep) console.log(sweep);
44
+ const rotate = rotateLine(all.length);
45
+ if (rotate) console.log(rotate);
46
+ };
47
+
48
+ export const cmdDone = () => {
49
+ const all = rows();
50
+ for (const l of doneLines(all)) console.log(l);
51
+ };
52
+
53
+ export const cmdStale = () => {
54
+ for (const l of staleReport(rows())) console.log(l);
55
+ };
56
+
57
+ export const cmdFind = (a: string[]) => {
58
+ // mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
59
+ const q = a.join(" ").toLowerCase();
60
+ if (!q) {
61
+ console.error(`usage: ${memCmd} find <word>`);
62
+ process.exit(1);
63
+ }
64
+ // allRows, not rows: find is recall — rotated history counts
65
+ const hits = allRows()
66
+ .filter(
67
+ (r): r is WorkRow =>
68
+ r.kind !== "close" &&
69
+ r.kind !== "synced" &&
70
+ r.kind !== "claim" &&
71
+ r.kind !== "release",
72
+ )
73
+ .filter(
74
+ (r) =>
75
+ r.text.toLowerCase().includes(q) ||
76
+ (r.spec ?? "").toLowerCase().includes(q),
77
+ )
78
+ .slice(-20);
79
+ for (const r of hits)
80
+ console.log(
81
+ `- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind} ${r.text}${r.spec ? ` → ${r.spec}` : ""}`,
82
+ );
83
+ if (!hits.length) console.log("(no matches)");
84
+ };
85
+
86
+ /** Read a plan file and extract unchecked checkboxes from the TL;DR section. */
87
+ const readPlanCheckboxes = (planPath: string): string[] => {
88
+ try {
89
+ const text = readFileSync(planPath, "utf8");
90
+ // Skip frontmatter
91
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
92
+ // Find the first ## section (TL;DR)
93
+ const start = body.search(/^##\s+/m);
94
+ if (start < 0) return [];
95
+ const rest = body.slice(start);
96
+ const next = rest.slice(3).search(/^##\s+/m);
97
+ const block = next < 0 ? rest : rest.slice(0, next + 3);
98
+ // Extract unchecked checkboxes
99
+ const items: string[] = [];
100
+ for (const line of block.split("\n")) {
101
+ const m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
102
+ if (m) items.push(m[1].trim());
103
+ }
104
+ return items;
105
+ } catch {
106
+ return [];
107
+ }
108
+ };
109
+
110
+ /** Check if a plan has `priority: high` in its frontmatter. */
111
+ const hasHighPriority = (planPath: string): boolean => {
112
+ try {
113
+ const head = readFileSync(planPath, "utf8").slice(0, 1024);
114
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head);
115
+ if (!m) return false;
116
+ return /^\s*priority\s*:\s*high\s*$/m.test(m[1]);
117
+ } catch {
118
+ return false;
119
+ }
120
+ };
121
+
122
+ /** Read the title from a plan file (first # heading). */
123
+ const readPlanTitle = (planPath: string): string => {
124
+ try {
125
+ const text = readFileSync(planPath, "utf8");
126
+ const m = /^#\s+(.+)$/m.exec(text);
127
+ return m ? m[1].trim() : "";
128
+ } catch {
129
+ return "";
130
+ }
131
+ };
132
+
133
+ const normSpec = (s: string) => s.replace(/\\/g, "/").replace(/^\.\//, "");
134
+
135
+ // kickoff's spec arg used to be compared to r.spec as a literal string, so
136
+ // `.fapony/plan/PLAN-x.md` had to be retyped byte-for-byte — and a typo printed
137
+ // "(no entries)" with nothing to notice. Resolve against the spec paths the log
138
+ // actually holds: exact path first, then by filename (with or without .md), then by
139
+ // any path suffix. Returns all candidates so the caller can tell unique from ambiguous.
140
+ const resolveSpec = (target: string, specs: string[]): string[] => {
141
+ const t = normSpec(target);
142
+ const all = [...new Set(specs)];
143
+ const exact = all.filter((s) => normSpec(s) === t);
144
+ if (exact.length) return exact;
145
+ const base = basename(t);
146
+ const withMd = base.endsWith(".md") ? base : `${base}.md`;
147
+ const byName = all.filter((s) => {
148
+ const b = basename(normSpec(s));
149
+ return b === base || b === withMd;
150
+ });
151
+ if (byName.length) return byName;
152
+ return all.filter((s) => normSpec(s).endsWith(`/${t}`));
153
+ };
154
+
155
+ export const cmdKickoff = (a: string[]) => {
156
+ // mem kickoff [id|spec.md] [--pick <n>]
157
+ // the spec may be the full path or just its filename
158
+ const all = rows();
159
+
160
+ // Parse --pick flag first
161
+ let pickIdx: number | null = null;
162
+ const pickFlagIdx = a.indexOf("--pick");
163
+ if (pickFlagIdx !== -1) {
164
+ const val = a[pickFlagIdx + 1];
165
+ pickIdx = parseInt(val ?? "", 10);
166
+ if (Number.isNaN(pickIdx) || pickIdx < 1) {
167
+ console.error("--pick requires a positive number");
168
+ process.exit(1);
169
+ }
170
+ }
171
+ // Filter out --pick and its value to get the main arguments
172
+ const mainArgs =
173
+ pickFlagIdx !== -1
174
+ ? a.filter((_, i) => i !== pickFlagIdx && i !== pickFlagIdx + 1)
175
+ : a;
176
+ const arg = mainArgs[0] ?? "";
177
+
178
+ // --- resolve spec/id for the main output ---
179
+ const workAll = all.filter((r): r is WorkRow => "id" in r);
180
+ const byId = new Map(workAll.map((r) => [r.id, r] as const));
181
+ const specNames = [
182
+ ...new Set(
183
+ all.flatMap((r) => ("spec" in r && r.spec ? [r.spec as string] : [])),
184
+ ),
185
+ ].sort();
186
+ const target = byId.get(arg);
187
+ const matches = resolveSpec(arg, specNames);
188
+ let resolvedSpec = matches.find((s) => normSpec(s) === normSpec(arg));
189
+
190
+ // If no arg or arg didn't match an id/spec, check if it's a plan file
191
+ let planFile: string | null = null;
192
+ let planCheckboxes: string[] = [];
193
+
194
+ if (arg && !resolvedSpec && !target) {
195
+ if (matches.length === 1) {
196
+ resolvedSpec = matches[0];
197
+ } else if (matches.length > 1) {
198
+ console.error(
199
+ `"${arg}" could mean ${matches.length} specs — retype one in full:`,
200
+ );
201
+ for (const m of matches) console.error(` ${m}`);
202
+ process.exit(1);
203
+ } else if (arg.endsWith(".md")) {
204
+ // Try to find it as a plan file
205
+ const dir = planDir;
206
+ const candidates = [join(dir, basename(arg)), join(dir, arg), arg];
207
+ for (const c of candidates) {
208
+ if (existsSync(c)) {
209
+ planFile = c;
210
+ break;
211
+ }
212
+ }
213
+ if (!planFile) {
214
+ console.error(`no entries for spec "${arg}"`);
215
+ if (specNames.length) {
216
+ console.error(`specs in the log:`);
217
+ for (const s of specNames) console.error(` ${s}`);
218
+ }
219
+ process.exit(1);
220
+ }
221
+ }
222
+ }
223
+
224
+ // Read plan file if we found one (or if a .md arg matched a spec)
225
+ if (planFile) {
226
+ planCheckboxes = readPlanCheckboxes(planFile);
227
+ } else if (arg && resolvedSpec && arg.endsWith(".md")) {
228
+ // The spec resolved from the log might be a plan file — try to read it
229
+ const dir = planDir;
230
+ const byName = join(dir, basename(arg));
231
+ if (existsSync(byName)) {
232
+ planFile = byName;
233
+ planCheckboxes = readPlanCheckboxes(planFile);
234
+ }
235
+ }
236
+
237
+ // --- main output (unchanged behavior) ---
238
+
239
+ if (!arg && !planFile) {
240
+ // no args = now + a "recent" section = the last 10 closes
241
+ console.log(`# ${app} — ${all.length} entries`);
242
+ printOpenRows(all, { showHold: true });
243
+ console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
244
+ const stale = staleReport(all);
245
+ if (stale.length)
246
+ console.log(
247
+ `\n## ⚠ stale (${stale.length})\n` +
248
+ stale.map((l) => `- ${l}`).join("\n"),
249
+ );
250
+ const sweep = planSweepLine();
251
+ if (sweep) console.log(sweep);
252
+ const rotate = rotateLine(all.length);
253
+ if (rotate) console.log(rotate);
254
+ } else if (resolvedSpec) {
255
+ // spec.md = a brief for that spec
256
+ const open = openRows(all);
257
+ const specRows = open.filter((r) => r.spec === resolvedSpec);
258
+ const specDecisions = all
259
+ .filter(
260
+ (r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
261
+ )
262
+ .slice(-5);
263
+ const specCloses = all
264
+ .filter((r): r is CloseRow => r.kind === "close")
265
+ .filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
266
+ .slice(-3);
267
+
268
+ console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
269
+ if (specRows.length) {
270
+ console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
271
+ }
272
+ if (specDecisions.length) {
273
+ console.log(
274
+ `\n## decisions\n` +
275
+ specDecisions
276
+ .map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
277
+ .join("\n"),
278
+ );
279
+ }
280
+ if (specCloses.length) {
281
+ console.log(
282
+ `\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
283
+ );
284
+ }
285
+ if (!specRows.length && !specDecisions.length && !specCloses.length) {
286
+ console.log("(no entries for this spec)");
287
+ }
288
+ } else if (target) {
289
+ // id = a brief for that task
290
+ const claims = claimsOf(all);
291
+ const claimInfo = claims.has(arg)
292
+ ? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
293
+ : "";
294
+
295
+ const spec = target.spec;
296
+ const open = spec
297
+ ? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
298
+ : [];
299
+ const specDecisions = spec
300
+ ? all
301
+ .filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
302
+ .slice(-5)
303
+ : [];
304
+ const specNotes = spec
305
+ ? all
306
+ .filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
307
+ .slice(-5)
308
+ : [];
309
+ const specCloses = spec
310
+ ? all
311
+ .filter((r): r is CloseRow => r.kind === "close")
312
+ .filter((r) => byId.get(r.ref)?.spec === spec)
313
+ .slice(-3)
314
+ : [];
315
+
316
+ console.log(
317
+ `# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
318
+ );
319
+ if (open.length) {
320
+ console.log(
321
+ `\n## open (same spec)\n` +
322
+ open.map((r) => fmtRow(r, claims)).join("\n"),
323
+ );
324
+ }
325
+ if (specDecisions.length || specNotes.length) {
326
+ console.log(`\n## decisions & notes (spec)\n`);
327
+ for (const r of [...specDecisions, ...specNotes]) {
328
+ console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
329
+ }
330
+ }
331
+ if (specCloses.length) {
332
+ console.log(
333
+ `\n## closes (spec)\n` +
334
+ specCloses.map((c) => fmtClose(c, byId)).join("\n"),
335
+ );
336
+ }
337
+ } else if (arg) {
338
+ console.error(`no id "${arg}" in the log`);
339
+ process.exit(1);
340
+ }
341
+
342
+ // --- next up section (chunk 3) ---
343
+
344
+ // A suggestion may carry a fully-runnable command. `manual` marks a command
345
+ // that is a template with a placeholder (bug close) — show it, never auto-run it.
346
+ type Suggestion = { text: string; run?: string; manual?: boolean };
347
+ const suggestions: Suggestion[] = [];
348
+ const GROUP_CAP = 3; // SPEC §3: at most 3 groups, at most 3 lines each
349
+
350
+ const truncate = (s: string, n = 80) =>
351
+ s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
352
+
353
+ // group 1 — plans: priority: high first, then the first unchecked chunk
354
+ const planLines: Suggestion[] = [];
355
+ if (existsSync(planDir)) {
356
+ const files = readdirSync(planDir).filter((f) => f.endsWith(".md"));
357
+ for (const file of files) {
358
+ if (planLines.length >= GROUP_CAP) break;
359
+ const fullPath = join(planDir, file);
360
+ // Skip the plan we already showed
361
+ if (planFile && fullPath === planFile) continue;
362
+ if (hasHighPriority(fullPath)) {
363
+ const title = readPlanTitle(fullPath);
364
+ planLines.push({
365
+ text: `${title || file} (${file})`,
366
+ run: `fapony mem kickoff ${file}`,
367
+ });
368
+ }
369
+ }
370
+ }
371
+ // Only the first unchecked chunk (SPEC §3) — the body of the plan is the plan's job.
372
+ // There is no per-chunk command, so this line is context, not a runnable item.
373
+ if (planLines.length < GROUP_CAP && planCheckboxes.length) {
374
+ const planName = planFile ? basename(planFile) : "plan";
375
+ planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
376
+ }
377
+ suggestions.push(...planLines);
378
+
379
+ // group 2 — open bugs, oldest first. openRows excludes tombstoned refs; the
380
+ // old bug filter forgot that and offered already-closed bugs as work.
381
+ const claims = claimsOf(all);
382
+ const openBugs = openRows(all)
383
+ .filter((r) => r.kind === "bug" && !claims.has(r.id))
384
+ .slice(0, GROUP_CAP);
385
+ for (const b of openBugs) {
386
+ suggestions.push({
387
+ text: `bug #${b.id} — ${truncate(b.text)}`,
388
+ run: `fapony mem close ${b.id} "<msg>"`,
389
+ manual: true,
390
+ });
391
+ }
392
+
393
+ // group 3 — the files of the last 3 rows that carried files[]
394
+ const recentFiles = all
395
+ .filter(
396
+ (r): r is WorkRow =>
397
+ "id" in r && "files" in r && !!(r as WorkRow).files?.length,
398
+ )
399
+ .slice(-3)
400
+ .flatMap((r) => r.files ?? []);
401
+ const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
402
+ if (uniqueRecent.length) {
403
+ suggestions.push({
404
+ text: `แตะล่าสุด: ${uniqueRecent.join(", ")}`,
405
+ run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
406
+ });
407
+ }
408
+
409
+ // Print and optionally execute. Only a suggestion with a complete command is
410
+ // runnable — a plan item is context, a bug's close is a template needing a message.
411
+ if (suggestions.length) {
412
+ console.log(`\n## next up`);
413
+ for (let i = 0; i < suggestions.length; i++) {
414
+ console.log(` [${i + 1}] ${suggestions[i].text}`);
415
+ if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
416
+ }
417
+
418
+ if (pickIdx !== null) {
419
+ if (pickIdx > suggestions.length) {
420
+ console.error(
421
+ `--pick ${pickIdx}: only ${suggestions.length} suggestion(s) available`,
422
+ );
423
+ process.exit(1);
424
+ }
425
+ const picked = suggestions[pickIdx - 1];
426
+ if (!picked.run) {
427
+ console.error(
428
+ `--pick ${pickIdx}: "${picked.text}" is context, not a command — nothing to run`,
429
+ );
430
+ process.exit(1);
431
+ }
432
+ if (picked.manual) {
433
+ console.error(
434
+ `--pick ${pickIdx}: the command needs your own message — run it yourself:\n ${picked.run}`,
435
+ );
436
+ process.exit(1);
437
+ }
438
+ const cmd = picked.run;
439
+ console.log(`\n> running: ${cmd}`);
440
+ const { execSync } = require("node:child_process");
441
+ try {
442
+ execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
443
+ } catch {
444
+ process.exit(1);
445
+ }
446
+ }
447
+ }
448
+ };
@@ -0,0 +1,56 @@
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
+ }
@@ -205,10 +205,17 @@ export const cmdHook = async () => {
205
205
  ? ((j as { tool_input?: { file_path?: string } }).tool_input?.file_path ??
206
206
  "")
207
207
  : "";
208
- const { app } = await import("../store.js");
209
- const rel = f.startsWith(root) ? f.slice(root.length + 1) : f;
210
- if (new RegExp(`^apps/${app}/(plan/(done/)?)?PLAN.*\\.md$`).test(rel)) {
211
- put({ kind: "synced", spec: rel });
212
- console.log(`synced ${rel}`);
208
+ const { planDir, doneDir } = await import("../store.js");
209
+ const relF = f.startsWith(root) ? f.slice(root.length + 1) : f;
210
+ // match PLAN*.md under this project's plan/ or done/
211
+ const planRe = new RegExp(
212
+ `^${planDir.replace(`${root}/`, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/.*PLAN.*\\.md$`,
213
+ );
214
+ const doneRe = new RegExp(
215
+ `^${doneDir.replace(`${root}/`, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/.*PLAN.*\\.md$`,
216
+ );
217
+ if (planRe.test(relF) || doneRe.test(relF)) {
218
+ put({ kind: "synced", spec: relF });
219
+ console.log(`synced ${relF}`);
213
220
  }
214
221
  };
@@ -0,0 +1,144 @@
1
+ // src/mem/index.ts — CLI dispatch for `fapony mem <sub>`
2
+ //
3
+ // Moved from templates/mem/mem.ts (2026-09-19) as part of PLAN-agent-one-call chunk 1.
4
+ // Now an export function called by fapony.ts, not a standalone script.
5
+
6
+ import { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
7
+ import {
8
+ cmdDone,
9
+ cmdFind,
10
+ cmdKickoff,
11
+ cmdNow,
12
+ cmdStale,
13
+ } from "./commands/read.js";
14
+ import { cmdRotate } from "./commands/rotate.js";
15
+ import { cmdWhere } from "./commands/where.js";
16
+ import {
17
+ cmdAdd,
18
+ cmdClaim,
19
+ cmdClose,
20
+ cmdHook,
21
+ cmdRelease,
22
+ cmdSynced,
23
+ } from "./commands/write.js";
24
+
25
+ const MEM_HELP = `usage: fapony mem [--mem-dir <path>] <sub> [args]
26
+
27
+ subcommands:
28
+ where show the resolved mem dir and which step won
29
+ kickoff [<plan.md>] [--pick <n>] open a session + a next-up list
30
+ add <kind> "<text>" --files f1,f2 [spec.md]
31
+ close <id> "<msg>" close a bug
32
+ find "<text>" substring-search every row
33
+ now | done | stale views
34
+ claim <id> | release <id> | synced bookkeeping
35
+ plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
36
+ plan-check <plan.md> validate a plan's frontmatter/sections
37
+ rotate archive the live log when it grows
38
+ hook Stop-hook payload reader (installed, not run by hand)
39
+
40
+ --mem-dir <path> wins over config and the walk-up (global to every sub)
41
+ example: fapony mem add note "decided X because Y" --files src/a.ts`;
42
+
43
+ const HELP: Record<string, string> = {
44
+ where: `usage: fapony mem where [--from <dir>]
45
+ show the .fapony/.memory dir the resolver picks and which step won — writes nothing
46
+ --from <dir> resolve as if cwd were here
47
+ example: fapony mem where`,
48
+ kickoff: `usage: fapony mem kickoff [<plan.md>] [--pick <n>]
49
+ open a session and print a "next up" list (priority plans, unchecked chunks, open bugs)
50
+ <plan.md> plan whose first unchecked chunk is offered as a context line
51
+ --pick <n> run suggestion [n] non-interactively; refuses context/template items
52
+ example: fapony mem kickoff .fapony/plan/PLAN-x.md`,
53
+ add: `usage: fapony mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [spec.md]
54
+ append one row to the mem log
55
+ --files f1,f2 repo-relative paths this row is about (required)
56
+ --stdin read <text> from stdin (avoids shell metachar)
57
+ example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts`,
58
+ close: `usage: fapony mem close <id> "<what was done | commit>"
59
+ tombstone a bug so it stops showing as open work
60
+ --stdin read the message from stdin
61
+ example: fapony mem close mt14 "fixed in a2c6beb"`,
62
+ find: `usage: fapony mem find "<text>"
63
+ substring search over every row's text/spec/ref — all kinds, no default filter
64
+ example: fapony mem find "usage-web"`,
65
+ now: `usage: fapony mem now
66
+ next + bug + hold still open (decision/note are search-only — use find)
67
+ example: fapony mem now`,
68
+ done: `usage: fapony mem done
69
+ closed rows with their tombstone message
70
+ example: fapony mem done`,
71
+ stale: `usage: fapony mem stale
72
+ decisions that never made it into their spec
73
+ example: fapony mem stale`,
74
+ claim: `usage: fapony mem claim <id>
75
+ claim an open next|bug before working it
76
+ example: fapony mem claim mt14`,
77
+ release: `usage: fapony mem release <id> [--stdin]
78
+ give back a claimed row without closing it
79
+ example: fapony mem release mt14`,
80
+ synced: `usage: fapony mem synced [<spec.md>]
81
+ mark a spec as carried into the log
82
+ example: fapony mem synced .fapony/spec/SPEC-x.md`,
83
+ "plan-sweep": `usage: fapony mem plan-sweep [<plan.md>] [--apply]
84
+ find shipped plans (or move one) and fix the links that point at it
85
+ --apply perform the git mv + link fixes (default = dry run)
86
+ example: fapony mem plan-sweep .fapony/plan/PLAN-x.md --apply`,
87
+ "plan-check": `usage: fapony mem plan-check <plan.md>
88
+ validate a plan's frontmatter and required sections
89
+ example: fapony mem plan-check .fapony/plan/PLAN-x.md`,
90
+ rotate: `usage: fapony mem rotate
91
+ archive the live log to log.YYYY-MM-DD.jsonl once it crosses the threshold
92
+ example: fapony mem rotate`,
93
+ hook: `usage: fapony mem hook
94
+ reads the Stop-hook payload on stdin — installed by fapony, not run by hand
95
+ example: (installed hook only)`,
96
+ };
97
+
98
+ export async function cmdMem(a: string[], memDir?: string): Promise<void> {
99
+ const [cmd, ...rest] = a;
100
+
101
+ // SPEC §2: every subcommand answers --help with signature + flags + one runnable
102
+ // example, because the reader is an agent that gets one call to get it right.
103
+ if (cmd === "-h" || cmd === "--help") {
104
+ console.log(MEM_HELP);
105
+ return;
106
+ }
107
+ if (rest.includes("-h") || rest.includes("--help")) {
108
+ console.log(HELP[cmd] ?? MEM_HELP);
109
+ return;
110
+ }
111
+
112
+ if (cmd === "add") {
113
+ await cmdAdd(rest);
114
+ } else if (cmd === "close") {
115
+ await cmdClose(rest);
116
+ } else if (cmd === "claim") {
117
+ cmdClaim(rest);
118
+ } else if (cmd === "release") {
119
+ await cmdRelease(rest);
120
+ } else if (cmd === "synced") {
121
+ cmdSynced(rest);
122
+ } else if (cmd === "hook") {
123
+ await cmdHook();
124
+ } else if (cmd === "done") {
125
+ cmdDone();
126
+ } else if (cmd === "stale") {
127
+ cmdStale();
128
+ } else if (cmd === "find") {
129
+ cmdFind(rest);
130
+ } else if (cmd === "kickoff") {
131
+ cmdKickoff(rest);
132
+ } else if (cmd === "where") {
133
+ cmdWhere(rest, memDir);
134
+ } else if (cmd === "plan-sweep") {
135
+ cmdPlanSweep(rest);
136
+ } else if (cmd === "plan-check") {
137
+ cmdPlanCheck(rest);
138
+ } else if (cmd === "rotate") {
139
+ cmdRotate(rest);
140
+ } else {
141
+ // mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
142
+ cmdNow();
143
+ }
144
+ }