fapony 0.7.0 → 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.
Files changed (64) hide show
  1. package/README.md +144 -156
  2. package/package.json +5 -6
  3. package/skill/define-convention/SKILL.md +3 -3
  4. package/skill/lookup-before-edit/SKILL.md +2 -1
  5. package/skill/move-to-done/SKILL.md +18 -22
  6. package/skill/plan-with-pony/SKILL.md +4 -4
  7. package/skill/review-pony/SKILL.md +10 -12
  8. package/src/adapters/cli.ts +11 -52
  9. package/src/adapters/hooks/index.ts +0 -67
  10. package/src/adapters/hooks/mv-guard.ts +2 -2
  11. package/src/analyze/discover.ts +1 -1
  12. package/src/analyze/index.ts +0 -1
  13. package/src/commands.ts +8 -39
  14. package/src/core/config.ts +0 -8
  15. package/src/core/fapony-dir.ts +71 -0
  16. package/src/core/hook-helpers.ts +2 -11
  17. package/src/debt/load.ts +3 -5
  18. package/src/debt/promotion.ts +2 -2
  19. package/src/debt/scan.ts +1 -1
  20. package/src/digest/collect.ts +6 -7
  21. package/src/fael.ts +139 -0
  22. package/src/hook.ts +1 -81
  23. package/src/init.ts +30 -90
  24. package/src/install/antigravity.ts +8 -99
  25. package/src/install/claude.ts +66 -200
  26. package/src/install/codex.ts +6 -170
  27. package/src/install/detect.ts +4 -14
  28. package/src/install/opencode.ts +50 -486
  29. package/src/install/types.ts +1 -39
  30. package/src/install/zcode.ts +5 -91
  31. package/src/install.ts +9 -35
  32. package/src/memory.ts +10 -69
  33. package/src/plan/index.ts +34 -0
  34. package/src/plan/next.ts +181 -0
  35. package/src/plan/store.ts +41 -0
  36. package/src/{mem/commands/plan.ts → plan/sweep.ts} +66 -46
  37. package/src/seed/plan-seed.ts +10 -9
  38. package/src/setup.ts +4 -22
  39. package/src/update.ts +1 -1
  40. package/templates/PLAN.md +1 -1
  41. package/src/adapters/hooks/bug-markers.ts +0 -60
  42. package/src/adapters/hooks/context-data.ts +0 -211
  43. package/src/adapters/hooks/edit-hint.ts +0 -215
  44. package/src/adapters/hooks/read-hint.ts +0 -400
  45. package/src/adapters/hooks/session-start.ts +0 -112
  46. package/src/adapters/hooks/stop.ts +0 -768
  47. package/src/adapters/mcp/tools/index.ts +0 -149
  48. package/src/adapters/mcp/tools/mem.ts +0 -258
  49. package/src/adapters/mcp/transport.ts +0 -147
  50. package/src/analyze/cache.ts +0 -162
  51. package/src/core/mem-log.ts +0 -394
  52. package/src/init-mem.ts +0 -143
  53. package/src/install/cursor.ts +0 -167
  54. package/src/install/utils.ts +0 -29
  55. package/src/mem/commands/read.ts +0 -792
  56. package/src/mem/commands/rotate.ts +0 -59
  57. package/src/mem/commands/where.ts +0 -56
  58. package/src/mem/commands/write.ts +0 -284
  59. package/src/mem/engine.ts +0 -329
  60. package/src/mem/index.ts +0 -201
  61. package/src/mem/key-registry.ts +0 -116
  62. package/src/mem/render.ts +0 -66
  63. package/src/mem/selectors.ts +0 -227
  64. package/src/mem/store.ts +0 -356
@@ -1,792 +0,0 @@
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 { collectSourceFiles } from "../../analyze/index.js";
6
- import { parseSince } from "../../core/since.js";
7
- import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
8
- import { CLI_FIND_EXCLUDE, engineFind } from "../engine.js";
9
- import { loadKeyRegistry } from "../key-registry.js";
10
- import { doneLines, fmtClose, fmtRow } from "../render.js";
11
- import {
12
- claimsOf,
13
- evictionReport,
14
- openKeys,
15
- openRows,
16
- staleReport,
17
- } from "../selectors.js";
18
- import type { CloseRow, LogRow, WorkRow } from "../store.js";
19
- import { allRows, app, LOG, memCmd, planDir, root, rows } from "../store.js";
20
- import { checkTickedLine, planSweepCmd, shippedNotMoved } from "./plan.js";
21
- import { THRESHOLD } from "./rotate.js";
22
-
23
- /**
24
- * One ⚠ line when the mem log is gitignored — the log's promise is that a
25
- * clone carries every decision; an ignored log keeps them on this machine
26
- * only, and nothing else says so. Ignoring may be deliberate (a public repo),
27
- * so this warns, never blocks.
28
- */
29
- function ignoredLogLine(): string | null {
30
- try {
31
- const p = Bun.spawnSync(["git", "check-ignore", "-q", LOG], { cwd: root });
32
- if (p.exitCode !== 0) return null;
33
- } catch {
34
- return null;
35
- }
36
- return `⚠ mem log is gitignored (${basename(LOG)}) — decisions and plan ticks stay on this machine; a clone gets none of them`;
37
- }
38
-
39
- /** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
40
- function getBranchDiffFiles(cwd: string): Set<string> {
41
- try {
42
- const p = Bun.spawnSync(["git", "diff", "--name-only", "dev...HEAD"], {
43
- cwd,
44
- stdout: "pipe",
45
- stderr: "pipe",
46
- });
47
- if (p.exitCode !== 0) return new Set();
48
- return new Set(p.stdout.toString().trim().split("\n").filter(Boolean));
49
- } catch {
50
- return new Set();
51
- }
52
- }
53
-
54
- const rotateLine = (n: number) =>
55
- n >= THRESHOLD
56
- ? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
57
- : "";
58
-
59
- const planSweepLine = () => {
60
- const pending = shippedNotMoved();
61
- return pending.length
62
- ? `\n## 📦 shipped but not archived into done/ (${pending.length})\n` +
63
- pending.map((n) => `- ${n}`).join("\n") +
64
- `\n(move: ${planSweepCmd} <file.md> --apply)`
65
- : "";
66
- };
67
-
68
- // Row-list caps: no row-list section exceeds 10 (same budget as ## recent,
69
- // which takes doneLines(all, 10)). Per-row text is cut at a word boundary —
70
- // a kickoff line points at the row (mem find has the full text), it must not
71
- // reprint it.
72
- const BUGS_LIMIT = 10;
73
- const BRANCH_LIMIT = 10;
74
- const RECENT_OPEN_LIMIT = 10;
75
- const RECENT_OPEN_TEXT = 160;
76
- const OPEN_KEYS_LIMIT = 8;
77
-
78
- // Important-index line (PLAN-mem-keys chunk 3): one line under the kickoff
79
- // header listing keys that still have open rows — silent when none. Placed
80
- // before every tier so the 4KB session-start cap can never drop it (only
81
- // ## recent is cut first). Counts are open rows per key, not total rows.
82
- const openKeysLine = (all: LogRow[]): string => {
83
- const keys = openKeys(all);
84
- if (!keys.length) return "";
85
- const shown = keys.slice(0, OPEN_KEYS_LIMIT);
86
- const body = shown.map(({ key, open }) => `${key}(${open})`).join(", ");
87
- const more =
88
- keys.length > OPEN_KEYS_LIMIT ? ` +${keys.length - OPEN_KEYS_LIMIT}` : "";
89
- return `open keys: ${body}${more} — ${memCmd} find --key <key>`;
90
- };
91
-
92
- // Registry line (PLAN-mem-core chunk 4): one line naming the allowed domains
93
- // so the agent keys the row right the first time — silent with no registry.
94
- const keyDomainsLine = (): string => {
95
- const reg = loadKeyRegistry(root || process.cwd());
96
- if (!reg.path || !reg.domains.length) return "";
97
- return `key domains: ${reg.domains.join(", ")} — use --key domain:sub`;
98
- };
99
-
100
- const shortText = (text: string, max = RECENT_OPEN_TEXT): string => {
101
- if (text.length <= max) return text;
102
- const cut = text.slice(0, max);
103
- const lastSpace = cut.lastIndexOf(" ");
104
- return `${lastSpace > max / 2 ? cut.slice(0, lastSpace) : cut}…`;
105
- };
106
-
107
- export const cmdDone = () => {
108
- const all = rows();
109
- for (const l of doneLines(all)) console.log(l);
110
- };
111
-
112
- export const cmdStale = () => {
113
- const all = rows();
114
- for (const l of staleReport(all)) console.log(l);
115
- // Eviction (PLAN-mem-core chunk 5): rows whose files[] are all gone from
116
- // disk, with unique-basename moves followed. Local only — existsSync +
117
- // collectSourceFiles, never git. The tree walk runs only when some row
118
- // actually names a missing file.
119
- if (!root) return;
120
- const exists = (f: string) => existsSync(join(root, f));
121
- if (
122
- !all.some(
123
- (r) =>
124
- "files" in r && ((r as WorkRow).files ?? []).some((f) => !exists(f)),
125
- )
126
- )
127
- return;
128
- for (const l of evictionReport(all, exists, collectSourceFiles(root)))
129
- console.log(l);
130
- };
131
-
132
- export const cmdFind = (a: string[]) => {
133
- // mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k] [--open]
134
- // Query logic lives in the shared engine (../engine.ts) — this wrapper owns
135
- // only argv parsing + the single-line print. MCP memFind calls the same
136
- // engine with no kind default (contract); CLI keeps its legacy default of
137
- // hiding bookkeeping rows unless --kind names them (PLAN-unify-mem-engine).
138
- const flagVal = (flag: string, i: number): string | undefined => {
139
- const t = a[i];
140
- const eq = `${flag}=`;
141
- if (t.startsWith(eq)) return t.slice(eq.length);
142
- return a[i + 1];
143
- };
144
- const consumed = (flag: string, i: number): number => {
145
- if (a[i].startsWith(`${flag}=`)) return 1;
146
- return 2;
147
- };
148
-
149
- // Accumulators, not reassignments: a repeated list flag must grow the set,
150
- // never last-win (PLAN-comma-x chunk 2). Exported as string[] | undefined
151
- // after the loop so every downstream check reads the same shape.
152
- const kindAcc: string[] = [];
153
- const filesAcc: string[] = [];
154
- let sinceRaw: string | undefined;
155
- let limit: number | undefined;
156
- let open = false;
157
- let key: string | undefined;
158
- const positional: string[] = [];
159
-
160
- for (let i = 0; i < a.length; ) {
161
- const t = a[i];
162
- if (t === "--kind" || t.startsWith("--kind=")) {
163
- const v = flagVal("--kind", i);
164
- if (!v || v.startsWith("--")) {
165
- console.error(
166
- `--kind needs a value — usage: ${memCmd} find "<text>" --kind bug,decision`,
167
- );
168
- process.exit(1);
169
- }
170
- kindAcc.push(
171
- ...v
172
- .split(",")
173
- .map((s) => s.trim())
174
- .filter(Boolean),
175
- );
176
- i += consumed("--kind", i);
177
- } else if (t === "--files" || t.startsWith("--files=")) {
178
- const v = flagVal("--files", i);
179
- if (!v || v.startsWith("--")) {
180
- console.error(
181
- `--files needs a value — usage: ${memCmd} find --files path/to/file.ts[,more]`,
182
- );
183
- process.exit(1);
184
- }
185
- filesAcc.push(
186
- ...v
187
- .split(",")
188
- .map((s) => s.trim().replace(/^\.\//, ""))
189
- .filter(Boolean),
190
- );
191
- i += consumed("--files", i);
192
- } else if (t === "--key" || t.startsWith("--key=")) {
193
- const v = flagVal("--key", i);
194
- if (!v || v.startsWith("--")) {
195
- console.error(
196
- `--key needs a value — usage: ${memCmd} find --key fix-stop-dedupe`,
197
- );
198
- process.exit(1);
199
- }
200
- // No KEY_RE validation on the read side — a wrong-pattern key never
201
- // exists in the log, so the engine answers with knownKeys instead.
202
- key = v;
203
- i += consumed("--key", i);
204
- } else if (t === "--since" || t.startsWith("--since=")) {
205
- const v = flagVal("--since", i);
206
- if (!v || v.startsWith("--")) {
207
- console.error(`--since needs a value — use <N>d or YYYY-MM-DD`);
208
- process.exit(1);
209
- }
210
- sinceRaw = v;
211
- i += consumed("--since", i);
212
- } else if (t === "--limit" || t.startsWith("--limit=")) {
213
- const v = flagVal("--limit", i);
214
- const n = Number(v);
215
- if (!v || v.startsWith("--") || !Number.isInteger(n) || n < 0) {
216
- console.error(
217
- `--limit needs a non-negative integer — got "${v ?? ""}"`,
218
- );
219
- process.exit(1);
220
- }
221
- limit = n;
222
- i += consumed("--limit", i);
223
- } else if (t === "--open") {
224
- open = true;
225
- i += 1;
226
- } else {
227
- positional.push(t);
228
- i += 1;
229
- }
230
- }
231
-
232
- const kind = kindAcc.length ? kindAcc : undefined;
233
- const files = filesAcc.length ? filesAcc : undefined;
234
- const q = positional.join(" ").trim();
235
- if (
236
- !q &&
237
- !kind?.length &&
238
- !files?.length &&
239
- !key &&
240
- !sinceRaw &&
241
- limit === undefined &&
242
- !open
243
- ) {
244
- console.error(
245
- `usage: ${memCmd} find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n] [--key k] [--open]`,
246
- );
247
- process.exit(1);
248
- }
249
-
250
- let sinceIso: string | undefined;
251
- if (sinceRaw) {
252
- try {
253
- sinceIso = parseSince(sinceRaw).iso;
254
- } catch (e) {
255
- console.error(e instanceof Error ? e.message : String(e));
256
- process.exit(1);
257
- }
258
- }
259
-
260
- // allRows, not rows: find is recall — rotated history counts
261
- const result = engineFind(allRows(), {
262
- text: q || undefined,
263
- files,
264
- kind,
265
- excludeKind: kind?.length ? undefined : CLI_FIND_EXCLUDE,
266
- sinceIso,
267
- key,
268
- limit,
269
- open: open || undefined,
270
- });
271
- // Legacy order: oldest first (engine returns newest first — same set, CLI print order unchanged)
272
- const hits = [...result.rows].reverse();
273
- for (const r of hits) {
274
- if ("id" in r) {
275
- // #key only when present — rows without one keep the old shape
276
- const keyTag = r.key ? ` #${r.key}` : "";
277
- console.log(
278
- `- [${r.id}] ${r.ts.slice(0, 10)} ${r.kind}${keyTag} ${r.text ?? ""}${r.spec ? ` → ${r.spec}` : ""}`,
279
- );
280
- } else {
281
- const label = "ref" in r && r.ref ? `${r.ref}(${r.kind})` : r.kind;
282
- const body = ("text" in r && r.text) || ("spec" in r && r.spec) || "";
283
- console.log(`- [${label}] ${r.ts.slice(0, 10)} ${r.kind} ${body}`);
284
- }
285
- }
286
- if (!hits.length) {
287
- // A wrong key answers with the real ones (SPEC fail example) — a silent
288
- // empty would read as "this problem never happened" (rule 9).
289
- if (key && result.knownKeys) {
290
- console.log(
291
- `no key ${key}; known keys: ${
292
- result.knownKeys.length ? result.knownKeys.join(", ") : "(none)"
293
- }`,
294
- );
295
- } else {
296
- console.log("(no matches)");
297
- }
298
- }
299
- };
300
-
301
- /** Read a plan file and extract checked + unchecked items from the first ## section. */
302
- const readPlanSectionItems = (
303
- planPath: string,
304
- ): { checked: string[]; unchecked: string[] } => {
305
- try {
306
- const text = readFileSync(planPath, "utf8");
307
- // Skip frontmatter
308
- const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
309
- // Find the first ## section (TL;DR)
310
- const start = body.search(/^##\s+/m);
311
- if (start < 0) return { checked: [], unchecked: [] };
312
- const rest = body.slice(start);
313
- const next = rest.slice(3).search(/^##\s+/m);
314
- const block = next < 0 ? rest : rest.slice(0, next + 3);
315
- // Extract checkboxes — ticked and unticked
316
- const checked: string[] = [];
317
- const unchecked: string[] = [];
318
- for (const line of block.split("\n")) {
319
- let m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
320
- if (m) {
321
- unchecked.push(m[1].trim());
322
- continue;
323
- }
324
- m = /^\s*[-*]\s+\[[xX]\]\s+(.+)$/.exec(line);
325
- if (m) checked.push(m[1].trim());
326
- }
327
- return { checked, unchecked };
328
- } catch {
329
- return { checked: [], unchecked: [] };
330
- }
331
- };
332
-
333
- // Chunk 9 (PLAN-seed-and-surface): one line under the next-chunk display
334
- // saying whether the last ticked chunk actually closed. Verified shas stay
335
- // silent; only the newest ticked chunk is ever mentioned — one line per
336
- // kickoff, never a list (else vela's 64/72 sha-less chunks become noise).
337
- const closureHint = (checked: string[]): string | null => {
338
- const last = checked[checked.length - 1];
339
- if (!last) return null;
340
- const { missing, diverged, cited } = checkTickedLine(last, root);
341
- const label = /chunk\s+([^\s—–-]+)/i.exec(last)?.[1] ?? "latest";
342
- if (missing.length)
343
- return `⚠ chunk ${label} is ticked but ${missing[0]} is not in git — nothing proves it closed`;
344
- if (diverged.length)
345
- return `⚠ chunk ${label} is ticked but ${diverged[0]} is not on HEAD (rebased away?)`;
346
- if (!cited)
347
- return `⚠ chunk ${label} is ticked but cites no commit — nothing to verify it closed`;
348
- return null;
349
- };
350
-
351
- /** Check if a plan has `priority: high` in its frontmatter. */
352
- const hasHighPriority = (planPath: string): boolean => {
353
- try {
354
- const head = readFileSync(planPath, "utf8").slice(0, 1024);
355
- const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head);
356
- if (!m) return false;
357
- return /^\s*priority\s*:\s*high\s*$/m.test(m[1]);
358
- } catch {
359
- return false;
360
- }
361
- };
362
-
363
- /** Read the title from a plan file (first # heading). */
364
- const readPlanTitle = (planPath: string): string => {
365
- try {
366
- const text = readFileSync(planPath, "utf8");
367
- const m = /^#\s+(.+)$/m.exec(text);
368
- return m ? m[1].trim() : "";
369
- } catch {
370
- return "";
371
- }
372
- };
373
-
374
- const normSpec = (s: string) => s.replace(/\\/g, "/").replace(/^\.\//, "");
375
-
376
- // kickoff's spec arg used to be compared to r.spec as a literal string, so
377
- // `.fapony/plan/PLAN-x.md` had to be retyped byte-for-byte — and a typo printed
378
- // "(no entries)" with nothing to notice. Resolve against the spec paths the log
379
- // actually holds: exact path first, then by filename (with or without .md), then by
380
- // any path suffix. Returns all candidates so the caller can tell unique from ambiguous.
381
- const resolveSpec = (target: string, specs: string[]): string[] => {
382
- const t = normSpec(target);
383
- const all = [...new Set(specs)];
384
- const exact = all.filter((s) => normSpec(s) === t);
385
- if (exact.length) return exact;
386
- const base = basename(t);
387
- const withMd = base.endsWith(".md") ? base : `${base}.md`;
388
- const byName = all.filter((s) => {
389
- const b = basename(normSpec(s));
390
- return b === base || b === withMd;
391
- });
392
- if (byName.length) return byName;
393
- return all.filter((s) => normSpec(s).endsWith(`/${t}`));
394
- };
395
-
396
- export const cmdKickoff = (a: string[]) => {
397
- // mem kickoff [id|spec.md] [--pick <n>]
398
- // the spec may be the full path or just its filename
399
- const all = rows();
400
-
401
- // Parse --pick flag first
402
- let pickIdx: number | null = null;
403
- const pickFlagIdx = a.indexOf("--pick");
404
- if (pickFlagIdx !== -1) {
405
- const val = a[pickFlagIdx + 1];
406
- pickIdx = parseInt(val ?? "", 10);
407
- if (Number.isNaN(pickIdx) || pickIdx < 1) {
408
- console.error("--pick requires a positive number");
409
- process.exit(1);
410
- }
411
- }
412
- // Filter out --pick and its value to get the main arguments
413
- const mainArgs =
414
- pickFlagIdx !== -1
415
- ? a.filter((_, i) => i !== pickFlagIdx && i !== pickFlagIdx + 1)
416
- : a;
417
- const arg = mainArgs[0] ?? "";
418
-
419
- // --- resolve spec/id for the main output ---
420
- const workAll = all.filter((r): r is WorkRow => "id" in r);
421
- const byId = new Map(workAll.map((r) => [r.id, r] as const));
422
- const specNames = [
423
- ...new Set(
424
- all.flatMap((r) => ("spec" in r && r.spec ? [r.spec as string] : [])),
425
- ),
426
- ].sort();
427
- const target = byId.get(arg);
428
- const matches = resolveSpec(arg, specNames);
429
- let resolvedSpec = matches.find((s) => normSpec(s) === normSpec(arg));
430
-
431
- // If no arg or arg didn't match an id/spec, check if it's a plan file
432
- let planFile: string | null = null;
433
- let planCheckboxes: string[] = [];
434
- let planChecked: string[] = [];
435
-
436
- if (arg && !resolvedSpec && !target) {
437
- if (matches.length === 1) {
438
- resolvedSpec = matches[0];
439
- } else if (matches.length > 1) {
440
- console.error(
441
- `"${arg}" could mean ${matches.length} specs — retype one in full:`,
442
- );
443
- for (const m of matches) console.error(` ${m}`);
444
- process.exit(1);
445
- } else if (arg.endsWith(".md")) {
446
- // Try to find it as a plan file
447
- const dir = planDir;
448
- const candidates = [join(dir, basename(arg)), join(dir, arg), arg];
449
- for (const c of candidates) {
450
- if (existsSync(c)) {
451
- planFile = c;
452
- break;
453
- }
454
- }
455
- if (!planFile) {
456
- console.error(`no entries for spec "${arg}"`);
457
- if (specNames.length) {
458
- console.error(`specs in the log:`);
459
- for (const s of specNames) console.error(` ${s}`);
460
- }
461
- process.exit(1);
462
- }
463
- }
464
- }
465
-
466
- // Read plan file if we found one (or if a .md arg matched a spec)
467
- if (planFile) {
468
- const items = readPlanSectionItems(planFile);
469
- planCheckboxes = items.unchecked;
470
- planChecked = items.checked;
471
- } else if (arg && resolvedSpec && arg.endsWith(".md")) {
472
- // The spec resolved from the log might be a plan file — try to read it
473
- const dir = planDir;
474
- const byName = join(dir, basename(arg));
475
- if (existsSync(byName)) {
476
- planFile = byName;
477
- const items = readPlanSectionItems(planFile);
478
- planCheckboxes = items.unchecked;
479
- planChecked = items.checked;
480
- }
481
- }
482
-
483
- // --- suggestions (generated early so the no-args path can print next up
484
- // before the recency tier, keeping it inside the 4KB cap) ---
485
-
486
- type Suggestion = { text: string; run?: string; manual?: boolean };
487
- const suggestions: Suggestion[] = [];
488
- const GROUP_CAP = 3;
489
-
490
- const truncate = (s: string, n = 80) =>
491
- s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
492
-
493
- // group 1 — plans: priority: high first, then the first unchecked chunk
494
- const planLines: Suggestion[] = [];
495
- if (existsSync(planDir)) {
496
- const files = readdirSync(planDir).filter((f) => f.endsWith(".md"));
497
- for (const file of files) {
498
- if (planLines.length >= GROUP_CAP) break;
499
- const fullPath = join(planDir, file);
500
- if (planFile && fullPath === planFile) continue;
501
- if (hasHighPriority(fullPath)) {
502
- const title = readPlanTitle(fullPath);
503
- planLines.push({
504
- text: `${title || file} (${file})`,
505
- run: `fapony mem kickoff ${file}`,
506
- });
507
- }
508
- }
509
- }
510
- if (planLines.length < GROUP_CAP && planCheckboxes.length) {
511
- const planName = planFile ? basename(planFile) : "plan";
512
- planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
513
- }
514
- suggestions.push(...planLines);
515
-
516
- // group 2 — open bugs, oldest first
517
- const suggClaims = claimsOf(all);
518
- const openBugs = openRows(all)
519
- .filter((r) => r.kind === "bug" && !suggClaims.has(r.id))
520
- .slice(0, GROUP_CAP);
521
- for (const b of openBugs) {
522
- suggestions.push({
523
- text: `bug #${b.id} — ${truncate(b.text)}`,
524
- run: `fapony mem close ${b.id} "<msg>"`,
525
- manual: true,
526
- });
527
- }
528
-
529
- // group 3 — the files of the last 3 rows that carried files[]
530
- const recentFiles = all
531
- .filter(
532
- (r): r is WorkRow =>
533
- "id" in r && "files" in r && !!(r as WorkRow).files?.length,
534
- )
535
- .slice(-3)
536
- .flatMap((r) => r.files ?? []);
537
- const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
538
- if (uniqueRecent.length) {
539
- suggestions.push({
540
- text: `Recently touched: ${uniqueRecent.join(", ")}`,
541
- run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
542
- });
543
- }
544
-
545
- // group 4 — the lint baseline
546
- try {
547
- if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
548
- suggestions.push({
549
- text: "lint baseline not captured yet — pre-existing red will count as yours",
550
- run: "fapony lint-baseline --capture",
551
- });
552
- }
553
- } catch {
554
- // state dir unreadable — the baseline line is a convenience, never a gate
555
- }
556
-
557
- /** Print ## next up and handle --pick. */
558
- const printNextUp = () => {
559
- if (!suggestions.length) return;
560
- console.log(`\n## next up`);
561
- for (let i = 0; i < suggestions.length; i++) {
562
- console.log(` [${i + 1}] ${suggestions[i].text}`);
563
- if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
564
- }
565
- if (pickIdx !== null) {
566
- if (pickIdx > suggestions.length) {
567
- console.error(
568
- `--pick ${pickIdx}: only ${suggestions.length} suggestion(s) available`,
569
- );
570
- process.exit(1);
571
- }
572
- const picked = suggestions[pickIdx - 1];
573
- if (!picked.run) {
574
- console.error(
575
- `--pick ${pickIdx}: "${picked.text}" is context, not a command — nothing to run`,
576
- );
577
- process.exit(1);
578
- }
579
- if (picked.manual) {
580
- console.error(
581
- `--pick ${pickIdx}: the command needs your own message — run it yourself:\n ${picked.run}`,
582
- );
583
- process.exit(1);
584
- }
585
- const cmd = picked.run;
586
- console.log(`\n> running: ${cmd}`);
587
- const { execSync } = require("node:child_process");
588
- try {
589
- execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
590
- } catch {
591
- process.exit(1);
592
- }
593
- }
594
- };
595
-
596
- // --- main output ---
597
-
598
- // Deferred output for the no-args path — recent closes, plan sweep, rotate
599
- let kickoffExtras: (() => void) | null = null;
600
-
601
- if (!arg && !planFile) {
602
- // no args = ranked open rows + next up + recent closes
603
- console.log(`# ${app} — ${all.length} entries`);
604
- const ignored = ignoredLogLine();
605
- if (ignored) console.log(ignored);
606
- const keys = openKeysLine(all);
607
- if (keys) console.log(keys);
608
- const domains = keyDomainsLine();
609
- if (domains) console.log(domains);
610
-
611
- const open = openRows(all);
612
- const claims = claimsOf(all);
613
- const diffFiles = getBranchDiffFiles(root);
614
-
615
- // Tier 1: unclaimed bugs (always first, no matter how old) — capped: a
616
- // backlog of unclosable bugs must not push the rest of the ranking out.
617
- const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
618
- if (bugs.length) {
619
- console.log(`\n## bugs`);
620
- for (const r of bugs.slice(0, BUGS_LIMIT)) console.log(fmtRow(r));
621
- if (bugs.length > BUGS_LIMIT)
622
- console.log(
623
- `… +${bugs.length - BUGS_LIMIT} more unclosed bugs — \`fapony mem find <word>\` for the rest`,
624
- );
625
- }
626
-
627
- // next up BEFORE the branch/recency tiers so no row-list section can
628
- // push it out — order is by actionability, never by "this tier is short".
629
- printNextUp();
630
-
631
- // Tier 2: rows whose files[] overlap with git diff --name-only dev...HEAD
632
- // — capped like every other row-list section (≤10 + overflow, pointer
633
- // lines via shortText): the branch diff is unbounded and mem clusters on
634
- // files every real branch touches, so uncapped this reprints the log.
635
- const bugIds = new Set(bugs.map((r) => r.id));
636
- const diffMatched = open.filter((r) => {
637
- if (bugIds.has(r.id)) return false;
638
- if (diffFiles.size === 0) return false;
639
- return (r.files ?? []).some((f) => diffFiles.has(f));
640
- });
641
- if (diffMatched.length) {
642
- console.log(`\n## on this branch`);
643
- for (const r of diffMatched.slice(0, BRANCH_LIMIT))
644
- console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
645
- if (diffMatched.length > BRANCH_LIMIT)
646
- console.log(
647
- `… +${diffMatched.length - BRANCH_LIMIT} more — \`fapony mem find <word>\` for the rest`,
648
- );
649
- }
650
-
651
- // Tier 3: rest by recency (newest first) — tie-breaker only, capped like
652
- // ## recent (doneLines takes 10): decision/note can never close, so an
653
- // uncapped tier reprints the whole log. Lines are pointers, not prose —
654
- // cut at a word boundary and point at mem find for the rest.
655
- const shown = new Set([...bugIds, ...diffMatched.map((r) => r.id)]);
656
- const rest = open
657
- .filter((r) => !shown.has(r.id))
658
- .sort((a, b) => b.ts.localeCompare(a.ts));
659
- if (rest.length) {
660
- console.log(`\n## recent open`);
661
- for (const r of rest.slice(0, RECENT_OPEN_LIMIT))
662
- console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
663
- if (rest.length > RECENT_OPEN_LIMIT)
664
- console.log(
665
- `… +${rest.length - RECENT_OPEN_LIMIT} more — \`fapony mem find <word>\` for the rest`,
666
- );
667
- }
668
-
669
- // recent closes, plan sweep, rotate — after next up
670
- kickoffExtras = () => {
671
- console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
672
- const sweep = planSweepLine();
673
- if (sweep) console.log(sweep);
674
- const rotate = rotateLine(all.length);
675
- if (rotate) console.log(rotate);
676
- };
677
- } else if (resolvedSpec) {
678
- // spec.md = a brief for that spec
679
- const open = openRows(all);
680
- const specRows = open.filter((r) => r.spec === resolvedSpec);
681
- const specDecisions = all
682
- .filter(
683
- (r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
684
- )
685
- .slice(-5);
686
- const specCloses = all
687
- .filter((r): r is CloseRow => r.kind === "close")
688
- .filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
689
- .slice(-3);
690
-
691
- console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
692
- if (specRows.length) {
693
- console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
694
- }
695
- if (specDecisions.length) {
696
- console.log(
697
- `\n## decisions\n` +
698
- specDecisions
699
- .map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
700
- .join("\n"),
701
- );
702
- }
703
- if (specCloses.length) {
704
- console.log(
705
- `\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
706
- );
707
- }
708
- if (!specRows.length && !specDecisions.length && !specCloses.length) {
709
- console.log("(no entries for this spec)");
710
- }
711
- // The spec is also a plan file on disk — say whether the last ticked
712
- // chunk actually closed (chunk 9). Silent when it verifies.
713
- if (planFile) {
714
- const hint = closureHint(planChecked);
715
- if (hint) console.log(`\n${hint}`);
716
- }
717
- } else if (target) {
718
- // id = a brief for that task
719
- const claims = claimsOf(all);
720
- const claimInfo = claims.has(arg)
721
- ? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
722
- : "";
723
-
724
- const spec = target.spec;
725
- const open = spec
726
- ? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
727
- : [];
728
- const specDecisions = spec
729
- ? all
730
- .filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
731
- .slice(-5)
732
- : [];
733
- const specNotes = spec
734
- ? all
735
- .filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
736
- .slice(-5)
737
- : [];
738
- const specCloses = spec
739
- ? all
740
- .filter((r): r is CloseRow => r.kind === "close")
741
- .filter((r) => byId.get(r.ref)?.spec === spec)
742
- .slice(-3)
743
- : [];
744
-
745
- console.log(
746
- `# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
747
- );
748
- if (open.length) {
749
- console.log(
750
- `\n## open (same spec)\n` +
751
- open.map((r) => fmtRow(r, claims)).join("\n"),
752
- );
753
- }
754
- if (specDecisions.length || specNotes.length) {
755
- console.log(`\n## decisions & notes (spec)\n`);
756
- for (const r of [...specDecisions, ...specNotes]) {
757
- console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
758
- }
759
- }
760
- if (specCloses.length) {
761
- console.log(
762
- `\n## closes (spec)\n` +
763
- specCloses.map((c) => fmtClose(c, byId)).join("\n"),
764
- );
765
- }
766
- } else if (planFile) {
767
- const title = readPlanTitle(planFile);
768
- console.log(`# ${title || basename(planFile)} — plan`);
769
- const ignored = ignoredLogLine();
770
- if (ignored) console.log(ignored);
771
- if (planCheckboxes.length) {
772
- console.log(
773
- `\n## unchecked\n${planCheckboxes.map((c) => `- [ ] ${c}`).join("\n")}`,
774
- );
775
- } else {
776
- console.log(`\n(all chunks checked — ready to ship or archive)`);
777
- }
778
- // Whether the last ticked chunk actually closed (chunk 9) — one line,
779
- // silent when its sha verifies.
780
- const hint = closureHint(planChecked);
781
- if (hint) console.log(hint);
782
- } else if (arg) {
783
- console.error(`no id "${arg}" in the log`);
784
- process.exit(1);
785
- }
786
-
787
- // For non-no-args paths, print next up at the end
788
- if (arg || planFile) printNextUp();
789
-
790
- // Deferred output from no-args path (recent closes, plan sweep, rotate)
791
- kickoffExtras?.();
792
- };