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