fapony 0.2.1 → 0.3.3

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 (54) hide show
  1. package/README.md +95 -72
  2. package/fapony.ts +12 -5
  3. package/package.json +5 -4
  4. package/skill/define-convention/SKILL.md +77 -0
  5. package/skill/lookup-before-edit/SKILL.md +48 -0
  6. package/skill/move-to-done/SKILL.md +19 -30
  7. package/skill/review-pony/SKILL.md +38 -61
  8. package/src/analyze.ts +1 -1
  9. package/src/debt/cli.ts +193 -0
  10. package/src/debt/format.ts +107 -0
  11. package/src/debt/index.ts +19 -0
  12. package/src/debt/load.ts +92 -0
  13. package/src/debt/promotion.ts +152 -0
  14. package/src/debt/scan.ts +214 -0
  15. package/src/debt/types.ts +79 -0
  16. package/src/detect.ts +92 -0
  17. package/src/gate.ts +3 -3
  18. package/src/hook.ts +349 -100
  19. package/src/init-mem.ts +57 -71
  20. package/src/init.ts +12 -16
  21. package/src/install/antigravity.ts +112 -0
  22. package/src/install/claude.ts +16 -123
  23. package/src/install/codex.ts +58 -19
  24. package/src/install/detect.ts +17 -7
  25. package/src/install/opencode.ts +131 -6
  26. package/src/install.ts +12 -3
  27. package/src/lint-baseline.ts +2 -2
  28. package/src/mcp/primitives.ts +1 -1
  29. package/src/mcp/tools/index.ts +13 -102
  30. package/src/mcp/tools/mem.ts +71 -0
  31. package/src/mcp/transport.ts +8 -94
  32. package/src/mcp/worktree.ts +1 -1
  33. package/src/mem/commands/read.ts +231 -141
  34. package/src/mem/index.ts +4 -13
  35. package/src/memory.ts +17 -8
  36. package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
  37. package/src/seed/primitives.ts +66 -0
  38. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  39. package/src/session/helpers.ts +1 -1
  40. package/src/session/registry.ts +3 -6
  41. package/src/setup.ts +1 -1
  42. package/src/stats/data.ts +1 -1
  43. package/src/telemetry.ts +1 -1
  44. package/src/util.ts +61 -0
  45. package/templates/SPEC.md +8 -1
  46. package/images/logo.png +0 -0
  47. package/images/logo.webp +0 -0
  48. package/images/logo@400.webp +0 -0
  49. package/images/sample.webp +0 -0
  50. package/images/summary.webp +0 -0
  51. package/src/debt.ts +0 -811
  52. package/src/math.ts +0 -13
  53. package/src/mcp/tools/usage.ts +0 -211
  54. package/src/mcp/tools/verdict.ts +0 -161
@@ -1,18 +1,8 @@
1
1
  // src/mcp/transport.ts — JSON-RPC dispatch + stdio entry point
2
2
 
3
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { join } from "node:path";
6
3
  import { createInterface } from "node:readline";
7
- import { mergeBytesByTool, type UsageDetail } from "../session/index.js";
8
4
  import { getServerSha } from "./primitives.js";
9
- import {
10
- TOOLS,
11
- toolMemAdd,
12
- toolMemFind,
13
- toolPassiveUsage,
14
- toolVerdictSubmit,
15
- } from "./tools/index.js";
5
+ import { TOOLS, toolMemAdd, toolMemClose, toolMemFind } from "./tools/index.js";
16
6
  import { errorResult, type ToolResult } from "./types.js";
17
7
 
18
8
  // --- Server instructions ---
@@ -27,90 +17,19 @@ import { errorResult, type ToolResult } from "./types.js";
27
17
  // Both habits degrade silently — an agent that ignores them still gets
28
18
  // correct answers from every tool, just a thinner history.
29
19
 
30
- const SERVER_INSTRUCTIONS = `fapony is a ledger of how work in this project turned out: which model, on which shape of task, produced work that held up. One habit feeds it.
20
+ const SERVER_INSTRUCTIONS = `fapony records decisions, bugs, and notes about this project so the next session (or the next agent) knows what happened and what to watch out for.
31
21
 
32
- When a unit of work is finished, call verdict_submit to grade it — pass-excellent..pass when it holds, fail when the first attempt was wrong, uncertain when you could not verify it (never guess pass). This is a grade on the work, not a confession: grade routinely, including work that went right the first time, because a model's record is only as good as the number of graded units behind it.
22
+ When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session.
33
23
 
34
- worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the verdict where nothing reads it, and nothing errors to say so. Write the note standalone — what the work was and how it held up — it is read months later with no access to this conversation. Never leave a run non-terminal; an open run absorbs later unrelated verdicts for that worktree.
24
+ worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the row where nothing reads it, and nothing errors to say so. Write the note standalone — it is read months later with no access to this conversation.
35
25
 
36
26
  Skip it and every tool still answers correctly, on a thinner history.`;
37
27
 
38
- // --- Statusline cache ---
39
- //
40
- // Written after every MCP tool call. The Claude Code statusline script reads
41
- // this file (< 1ms, no spawn, no db). Format: single line of text.
42
- // Only fapony_usage with detail:true produces meaningful data (bytes_by_tool,
43
- // aggregated across all clients — the bytes live on the claude_code
44
- // sub-object, never top-level); other tools write a minimal "fapony" marker.
45
-
46
- const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
47
-
48
- function writeStatuslineCache(toolResult: ToolResult): void {
49
- try {
50
- // Extract bytes_by_tool from fapony_usage detail JSON response.
51
- let line = "fapony";
52
- if (
53
- toolResult &&
54
- typeof toolResult === "object" &&
55
- "content" in toolResult &&
56
- Array.isArray(toolResult.content)
57
- ) {
58
- for (const c of toolResult.content) {
59
- if (
60
- c &&
61
- typeof c === "object" &&
62
- c.type === "text" &&
63
- typeof c.text === "string"
64
- ) {
65
- // Try to extract bytes_by_tool from JSON text response.
66
- // Aggregated across all clients: the bytes live on the Claude Code
67
- // sub-object (claude_code.detail), never on the top-level detail,
68
- // so reading top-level alone would always miss.
69
- try {
70
- const parsed = JSON.parse(c.text) as {
71
- detail?: UsageDetail | null;
72
- zcode?: { detail?: UsageDetail | null } | null;
73
- claude_code?: { detail?: UsageDetail | null } | null;
74
- codex?: { detail?: UsageDetail | null } | null;
75
- };
76
- const bbt = mergeBytesByTool(
77
- parsed?.detail,
78
- parsed?.zcode?.detail,
79
- parsed?.claude_code?.detail,
80
- parsed?.codex?.detail,
81
- );
82
- const entries = Object.entries(bbt).sort((a, b) => b[1] - a[1]);
83
- const total = entries.reduce((s, e) => s + e[1], 0);
84
- if (total > 0) {
85
- // Format: "84.2k" for total, or "Read 42k · Grep 31k" for top tools.
86
- const fmt = (n: number) =>
87
- n >= 1024 ? `${(n / 1024).toFixed(1)}k` : `${Math.round(n)}`;
88
- if (entries.length <= 3) {
89
- line = `fapony ${entries.map((e) => `${e[0]} ${fmt(e[1])}`).join(" · ")}`;
90
- } else {
91
- line = `fapony ${fmt(total)}`;
92
- }
93
- }
94
- } catch {
95
- // Not JSON — that's fine, use default "fapony" marker.
96
- }
97
- break;
98
- }
99
- }
100
- }
101
- const dir = join(homedir(), ".config", "fapony");
102
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
103
- writeFileSync(STATUSLINE_PATH, line, "utf-8");
104
- } catch {
105
- // Cache write is best-effort — never block MCP on it.
106
- }
107
- }
108
-
109
28
  // --- MCP protocol constants ---
110
29
 
111
30
  const MCP_PROTOCOL_VERSION = "2025-03-26";
112
31
  const SERVER_NAME = "fapony-handcheck";
113
- const SERVER_VERSION = "0.2.1";
32
+ const SERVER_VERSION = "0.3.0";
114
33
 
115
34
  // --- JSON-RPC dispatch ---
116
35
 
@@ -149,23 +68,18 @@ function dispatchToolCall(params: {
149
68
  const args = params.arguments ?? {};
150
69
  let result: ToolResult;
151
70
  switch (params.name) {
152
- case "verdict_submit":
153
- result = toolVerdictSubmit(args);
154
- break;
155
- case "fapony_usage":
156
- result = toolPassiveUsage(args);
157
- break;
158
71
  case "mem_find":
159
72
  result = toolMemFind(args);
160
73
  break;
161
74
  case "mem_add":
162
75
  result = toolMemAdd(args);
163
76
  break;
77
+ case "mem_close":
78
+ result = toolMemClose(args);
79
+ break;
164
80
  default:
165
81
  return errorResult(`unknown tool: ${params.name}`);
166
82
  }
167
- // Write statusline cache after every tool call — best-effort, never blocks.
168
- writeStatuslineCache(result);
169
83
  return result;
170
84
  }
171
85
 
@@ -8,7 +8,7 @@ const SENTINEL_MCP_EXTERNAL = "mcp-external";
8
8
  * Resolve a worktree argument to an absolute path.
9
9
  * - Absolute paths (contain "/") pass through unchanged
10
10
  * - Keys are looked up in config.worktrees
11
- * - "mcp-external" sentinel passes through (set by verdict_submit itself)
11
+ * - "mcp-external" sentinel passes through (used when no worktree is available)
12
12
  * - Throws if key not found in config
13
13
  */
14
14
  export function resolveWorktreeArg(value: string): string {
@@ -2,13 +2,29 @@
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
- import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
5
+ import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
6
+ import { doneLines, fmtClose, fmtRow } from "../render.js";
6
7
  import { claimsOf, openRows, staleReport } from "../selectors.js";
7
8
  import type { CloseRow, WorkRow } from "../store.js";
8
- import { allRows, app, memCmd, planDir, rows } from "../store.js";
9
+ import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
9
10
  import { planSweepCmd, shippedNotMoved } from "./plan.js";
10
11
  import { THRESHOLD } from "./rotate.js";
11
12
 
13
+ /** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
14
+ function getBranchDiffFiles(cwd: string): Set<string> {
15
+ try {
16
+ const p = Bun.spawnSync(["git", "diff", "--name-only", "dev...HEAD"], {
17
+ cwd,
18
+ stdout: "pipe",
19
+ stderr: "pipe",
20
+ });
21
+ if (p.exitCode !== 0) return new Set();
22
+ return new Set(p.stdout.toString().trim().split("\n").filter(Boolean));
23
+ } catch {
24
+ return new Set();
25
+ }
26
+ }
27
+
12
28
  const rotateLine = (n: number) =>
13
29
  n >= THRESHOLD
14
30
  ? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
@@ -23,26 +39,20 @@ const planSweepLine = () => {
23
39
  : "";
24
40
  };
25
41
 
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);
42
+ // Row-list caps: no row-list section exceeds 10 (same budget as ## recent,
43
+ // which takes doneLines(all, 10)). Per-row text is cut at a word boundary —
44
+ // a kickoff line points at the row (mem find has the full text), it must not
45
+ // reprint it.
46
+ const BUGS_LIMIT = 10;
47
+ const BRANCH_LIMIT = 10;
48
+ const RECENT_OPEN_LIMIT = 10;
49
+ const RECENT_OPEN_TEXT = 160;
50
+
51
+ const shortText = (text: string, max = RECENT_OPEN_TEXT): string => {
52
+ if (text.length <= max) return text;
53
+ const cut = text.slice(0, max);
54
+ const lastSpace = cut.lastIndexOf(" ");
55
+ return `${lastSpace > max / 2 ? cut.slice(0, lastSpace) : cut}…`;
46
56
  };
47
57
 
48
58
  export const cmdDone = () => {
@@ -234,23 +244,194 @@ export const cmdKickoff = (a: string[]) => {
234
244
  }
235
245
  }
236
246
 
237
- // --- main output (unchanged behavior) ---
247
+ // --- suggestions (generated early so the no-args path can print next up
248
+ // before the recency tier, keeping it inside the 4KB cap) ---
249
+
250
+ type Suggestion = { text: string; run?: string; manual?: boolean };
251
+ const suggestions: Suggestion[] = [];
252
+ const GROUP_CAP = 3;
253
+
254
+ const truncate = (s: string, n = 80) =>
255
+ s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
256
+
257
+ // group 1 — plans: priority: high first, then the first unchecked chunk
258
+ const planLines: Suggestion[] = [];
259
+ if (existsSync(planDir)) {
260
+ const files = readdirSync(planDir).filter((f) => f.endsWith(".md"));
261
+ for (const file of files) {
262
+ if (planLines.length >= GROUP_CAP) break;
263
+ const fullPath = join(planDir, file);
264
+ if (planFile && fullPath === planFile) continue;
265
+ if (hasHighPriority(fullPath)) {
266
+ const title = readPlanTitle(fullPath);
267
+ planLines.push({
268
+ text: `${title || file} (${file})`,
269
+ run: `fapony mem kickoff ${file}`,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ if (planLines.length < GROUP_CAP && planCheckboxes.length) {
275
+ const planName = planFile ? basename(planFile) : "plan";
276
+ planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
277
+ }
278
+ suggestions.push(...planLines);
279
+
280
+ // group 2 — open bugs, oldest first
281
+ const suggClaims = claimsOf(all);
282
+ const openBugs = openRows(all)
283
+ .filter((r) => r.kind === "bug" && !suggClaims.has(r.id))
284
+ .slice(0, GROUP_CAP);
285
+ for (const b of openBugs) {
286
+ suggestions.push({
287
+ text: `bug #${b.id} — ${truncate(b.text)}`,
288
+ run: `fapony mem close ${b.id} "<msg>"`,
289
+ manual: true,
290
+ });
291
+ }
292
+
293
+ // group 3 — the files of the last 3 rows that carried files[]
294
+ const recentFiles = all
295
+ .filter(
296
+ (r): r is WorkRow =>
297
+ "id" in r && "files" in r && !!(r as WorkRow).files?.length,
298
+ )
299
+ .slice(-3)
300
+ .flatMap((r) => r.files ?? []);
301
+ const uniqueRecent = [...new Set(recentFiles)].slice(0, GROUP_CAP);
302
+ if (uniqueRecent.length) {
303
+ suggestions.push({
304
+ text: `แตะล่าสุด: ${uniqueRecent.join(", ")}`,
305
+ run: `fapony review-seed --files ${uniqueRecent.join(",")}`,
306
+ });
307
+ }
308
+
309
+ // group 4 — the lint baseline
310
+ try {
311
+ if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
312
+ suggestions.push({
313
+ text: "lint baseline ยังไม่ capture — แดงที่มีอยู่ก่อนจะถูกนับเป็นของคุณ",
314
+ run: "fapony lint-baseline --capture",
315
+ });
316
+ }
317
+ } catch {
318
+ // state dir unreadable — the baseline line is a convenience, never a gate
319
+ }
320
+
321
+ /** Print ## next up and handle --pick. */
322
+ const printNextUp = () => {
323
+ if (!suggestions.length) return;
324
+ console.log(`\n## next up`);
325
+ for (let i = 0; i < suggestions.length; i++) {
326
+ console.log(` [${i + 1}] ${suggestions[i].text}`);
327
+ if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
328
+ }
329
+ if (pickIdx !== null) {
330
+ if (pickIdx > suggestions.length) {
331
+ console.error(
332
+ `--pick ${pickIdx}: only ${suggestions.length} suggestion(s) available`,
333
+ );
334
+ process.exit(1);
335
+ }
336
+ const picked = suggestions[pickIdx - 1];
337
+ if (!picked.run) {
338
+ console.error(
339
+ `--pick ${pickIdx}: "${picked.text}" is context, not a command — nothing to run`,
340
+ );
341
+ process.exit(1);
342
+ }
343
+ if (picked.manual) {
344
+ console.error(
345
+ `--pick ${pickIdx}: the command needs your own message — run it yourself:\n ${picked.run}`,
346
+ );
347
+ process.exit(1);
348
+ }
349
+ const cmd = picked.run;
350
+ console.log(`\n> running: ${cmd}`);
351
+ const { execSync } = require("node:child_process");
352
+ try {
353
+ execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
354
+ } catch {
355
+ process.exit(1);
356
+ }
357
+ }
358
+ };
359
+
360
+ // --- main output ---
361
+
362
+ // Deferred output for the no-args path — recent closes, plan sweep, rotate
363
+ let kickoffExtras: (() => void) | null = null;
238
364
 
239
365
  if (!arg && !planFile) {
240
- // no args = now + a "recent" section = the last 10 closes
366
+ // no args = ranked open rows + next up + recent closes
241
367
  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);
368
+
369
+ const open = openRows(all);
370
+ const claims = claimsOf(all);
371
+ const diffFiles = getBranchDiffFiles(root);
372
+
373
+ // Tier 1: unclaimed bugs (always first, no matter how old) — capped: a
374
+ // backlog of unclosable bugs must not push the rest of the ranking out.
375
+ const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
376
+ if (bugs.length) {
377
+ console.log(`\n## bugs`);
378
+ for (const r of bugs.slice(0, BUGS_LIMIT)) console.log(fmtRow(r));
379
+ if (bugs.length > BUGS_LIMIT)
380
+ console.log(
381
+ `… +${bugs.length - BUGS_LIMIT} more unclosed bugs — \`fapony mem find <word>\` for the rest`,
382
+ );
383
+ }
384
+
385
+ // next up BEFORE the branch/recency tiers so no row-list section can
386
+ // push it out — order is by actionability, never by "this tier is short".
387
+ printNextUp();
388
+
389
+ // Tier 2: rows whose files[] overlap with git diff --name-only dev...HEAD
390
+ // — capped like every other row-list section (≤10 + overflow, pointer
391
+ // lines via shortText): the branch diff is unbounded and mem clusters on
392
+ // files every real branch touches, so uncapped this reprints the log.
393
+ const bugIds = new Set(bugs.map((r) => r.id));
394
+ const diffMatched = open.filter((r) => {
395
+ if (bugIds.has(r.id)) return false;
396
+ if (diffFiles.size === 0) return false;
397
+ return (r.files ?? []).some((f) => diffFiles.has(f));
398
+ });
399
+ if (diffMatched.length) {
400
+ console.log(`\n## on this branch`);
401
+ for (const r of diffMatched.slice(0, BRANCH_LIMIT))
402
+ console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
403
+ if (diffMatched.length > BRANCH_LIMIT)
404
+ console.log(
405
+ `… +${diffMatched.length - BRANCH_LIMIT} more — \`fapony mem find <word>\` for the rest`,
406
+ );
407
+ }
408
+
409
+ // Tier 3: rest by recency (newest first) — tie-breaker only, capped like
410
+ // ## recent (doneLines takes 10): decision/note can never close, so an
411
+ // uncapped tier reprints the whole log. Lines are pointers, not prose —
412
+ // cut at a word boundary and point at mem find for the rest.
413
+ const shown = new Set([...bugIds, ...diffMatched.map((r) => r.id)]);
414
+ const rest = open
415
+ .filter((r) => !shown.has(r.id))
416
+ .sort((a, b) => b.ts.localeCompare(a.ts));
417
+ if (rest.length) {
418
+ console.log(`\n## recent open`);
419
+ for (const r of rest.slice(0, RECENT_OPEN_LIMIT))
420
+ console.log(fmtRow({ ...r, text: shortText(r.text) }, claims));
421
+ if (rest.length > RECENT_OPEN_LIMIT)
422
+ console.log(
423
+ `… +${rest.length - RECENT_OPEN_LIMIT} more — \`fapony mem find <word>\` for the rest`,
424
+ );
425
+ }
426
+
427
+ // recent closes, plan sweep, rotate — after next up
428
+ kickoffExtras = () => {
429
+ console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
430
+ const sweep = planSweepLine();
431
+ if (sweep) console.log(sweep);
432
+ const rotate = rotateLine(all.length);
433
+ if (rotate) console.log(rotate);
434
+ };
254
435
  } else if (resolvedSpec) {
255
436
  // spec.md = a brief for that spec
256
437
  const open = openRows(all);
@@ -334,115 +515,24 @@ export const cmdKickoff = (a: string[]) => {
334
515
  specCloses.map((c) => fmtClose(c, byId)).join("\n"),
335
516
  );
336
517
  }
518
+ } else if (planFile) {
519
+ const title = readPlanTitle(planFile);
520
+ console.log(`# ${title || basename(planFile)} — plan`);
521
+ if (planCheckboxes.length) {
522
+ console.log(
523
+ `\n## unchecked\n${planCheckboxes.map((c) => `- [ ] ${c}`).join("\n")}`,
524
+ );
525
+ } else {
526
+ console.log(`\n(all chunks checked — ready to ship or archive)`);
527
+ }
337
528
  } else if (arg) {
338
529
  console.error(`no id "${arg}" in the log`);
339
530
  process.exit(1);
340
531
  }
341
532
 
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
- }
533
+ // For non-no-args paths, print next up at the end
534
+ if (arg || planFile) printNextUp();
408
535
 
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
- }
536
+ // Deferred output from no-args path (recent closes, plan sweep, rotate)
537
+ kickoffExtras?.();
448
538
  };
package/src/mem/index.ts CHANGED
@@ -4,13 +4,7 @@
4
4
  // Now an export function called by fapony.ts, not a standalone script.
5
5
 
6
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";
7
+ import { cmdDone, cmdFind, cmdKickoff, cmdStale } from "./commands/read.js";
14
8
  import { cmdRotate } from "./commands/rotate.js";
15
9
  import { cmdWhere } from "./commands/where.js";
16
10
  import {
@@ -30,7 +24,7 @@ subcommands:
30
24
  add <kind> "<text>" --files f1,f2 [spec.md]
31
25
  close <id> "<msg>" close a bug
32
26
  find "<text>" substring-search every row
33
- now | done | stale views
27
+ done | stale views
34
28
  claim <id> | release <id> | synced bookkeeping
35
29
  plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
36
30
  plan-check <plan.md> validate a plan's frontmatter/sections
@@ -62,9 +56,6 @@ example: fapony mem close mt14 "fixed in a2c6beb"`,
62
56
  find: `usage: fapony mem find "<text>"
63
57
  substring search over every row's text/spec/ref — all kinds, no default filter
64
58
  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
59
  done: `usage: fapony mem done
69
60
  closed rows with their tombstone message
70
61
  example: fapony mem done`,
@@ -138,7 +129,7 @@ export async function cmdMem(a: string[], memDir?: string): Promise<void> {
138
129
  } else if (cmd === "rotate") {
139
130
  cmdRotate(rest);
140
131
  } else {
141
- // mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
142
- cmdNow();
132
+ // bare `fapony mem` → kickoff (ranked session overview)
133
+ cmdKickoff(rest);
143
134
  }
144
135
  }
package/src/memory.ts CHANGED
@@ -218,7 +218,13 @@ function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
218
218
  export interface MemDirResult {
219
219
  dir: string | null;
220
220
  step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
221
- /** For step "ambiguous" — the sibling `.fapony/.memory/` dirs that hold logs. */
221
+ /**
222
+ * `.fapony/.memory/` dirs under the repo root that hold logs but are not in
223
+ * scope from `fromDir`. Set for "ambiguous" (two or more, the refusal) and
224
+ * also for "repo-root"/"none" (one) — a caller that reports "nothing
225
+ * recorded here" is lying when a single app-scoped log exists one level
226
+ * down, and the honest line needs the path to say where.
227
+ */
222
228
  candidates?: string[];
223
229
  }
224
230
 
@@ -320,20 +326,23 @@ function resolveMemDirFrom(
320
326
  // Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
321
327
  // has two or more app-scoped ones — refuse instead of silently creating a
322
328
  // third log at the root that no app-scoped reader will ever see.
323
- if (root) {
324
- const candidates = findMemDirsUnder(root);
325
- if (candidates.length >= 2) {
326
- return { dir: null, step: "ambiguous", candidates };
327
- }
329
+ // One candidate is not ambiguous, so resolution is unchanged — but it still
330
+ // travels back with the result: out-of-scope is not the same as absent.
331
+ const candidates = root ? findMemDirsUnder(root) : [];
332
+ if (candidates.length >= 2) {
333
+ return { dir: null, step: "ambiguous", candidates };
328
334
  }
335
+ const outOfScope = candidates.length ? { candidates } : {};
329
336
 
330
337
  // Step 4: <repo root>/.fapony/.memory/ — where a new log is created
331
338
  if (root) {
332
339
  const rootDir = join(root, DEFAULT_MEM_DIR);
333
- if (existsSync(rootDir)) return { dir: rootDir, step: "repo-root" };
340
+ if (existsSync(rootDir)) {
341
+ return { dir: rootDir, step: "repo-root", ...outOfScope };
342
+ }
334
343
  }
335
344
 
336
- return { dir: null, step: "none" };
345
+ return { dir: null, step: "none", ...outOfScope };
337
346
  }
338
347
 
339
348
  export function resolveMemDir(