fapony 0.3.0 → 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.
package/src/install.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // ZCode gets skill/<name>/ symlinked into ~/.agents/skills.
13
13
 
14
14
  import { createInterface } from "node:readline";
15
+ import { cmdInstallAntigravity } from "./install/antigravity.js";
15
16
  import { cmdInstallClaude } from "./install/claude.js";
16
17
  import { cmdInstallCodex } from "./install/codex.js";
17
18
  import { cmdInstallCursor } from "./install/cursor.js";
@@ -22,6 +23,7 @@ import { cmdInstallZcode } from "./install/zcode.js";
22
23
  import { ask } from "./setup.js";
23
24
  import { isAffirmative } from "./util.js";
24
25
 
26
+ export { cmdInstallAntigravity } from "./install/antigravity.js";
25
27
  export {
26
28
  claudeAddArgs,
27
29
  claudeGetArgs,
@@ -58,6 +60,10 @@ export async function cmdInstall(
58
60
  const installAll = args.includes("--all");
59
61
 
60
62
  // --- explicit platform: original behavior, unchanged ---
63
+ if (platform === "antigravity") {
64
+ cmdInstallAntigravity(dryRun, deps);
65
+ return;
66
+ }
61
67
  if (platform === "claude") {
62
68
  cmdInstallClaude(dryRun, deps);
63
69
  return;
@@ -80,10 +86,10 @@ export async function cmdInstall(
80
86
  }
81
87
  if (platform !== undefined) {
82
88
  console.error(
83
- `usage: fapony install --platform opencode|claude|cursor|zcode|codex [--dry-run]`,
89
+ `usage: fapony install --platform antigravity|opencode|claude|cursor|zcode|codex [--dry-run]`,
84
90
  );
85
91
  console.error(
86
- ` supported platforms: opencode, claude, cursor, zcode, codex`,
92
+ ` supported platforms: antigravity, opencode, claude, cursor, zcode, codex`,
87
93
  );
88
94
  (deps.exit ?? defaultExit)(1);
89
95
  return;
@@ -107,7 +113,7 @@ export async function cmdInstall(
107
113
  if (found.length === 0) {
108
114
  console.error();
109
115
  console.error(
110
- ` no MCP client found (looked for claude on PATH; config files for cursor, opencode, zcode, codex).`,
116
+ ` no MCP client found (looked for claude on PATH; config files for antigravity, cursor, opencode, zcode, codex).`,
111
117
  );
112
118
  console.error(
113
119
  ` open the app once, then re-run — or force with: fapony install --platform <name>`,
@@ -162,6 +168,9 @@ function installPlatform(
162
168
  deps: InstallDeps,
163
169
  ): void {
164
170
  switch (platform) {
171
+ case "antigravity":
172
+ cmdInstallAntigravity(dryRun, deps);
173
+ break;
165
174
  case "claude":
166
175
  cmdInstallClaude(dryRun, deps);
167
176
  break;
@@ -157,7 +157,7 @@ export interface VerificationReport {
157
157
  evidence: EvidenceItem[];
158
158
  /** Evidence summary (computed from evidence array). */
159
159
  evidence_summary: EvidenceSummary;
160
- /** Verdict grade + note (from gate/verdict_submit, or null if not yet). */
160
+ /** Verdict grade + note (from the frozen gate/verdict ledger, or null if never graded). */
161
161
  verdict: {
162
162
  grade: string;
163
163
  note: string;
@@ -6,85 +6,11 @@
6
6
  // saves more than it costs (see CLAUDE.md "จ่าย token อย่างฉลาด"). Keep
7
7
  // descriptions imperative — say what to send, not why it matters.
8
8
 
9
- import { VERDICT_GRADES } from "../../parse.js";
10
- import { REASON_CODES, REGIME_CODES } from "../types.js";
11
-
12
9
  export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
13
- export { toolVerdictSubmit } from "./verdict.js";
14
10
 
15
11
  // --- Tool definitions ---
16
12
 
17
13
  export const TOOLS = [
18
- {
19
- name: "verdict_submit",
20
- description:
21
- "Grade a finished unit of work — every unit that ends, including work " +
22
- "that went right the first time. Submit 'fail' the moment a first " +
23
- "attempt turns out wrong, then a pass-family verdict once the fix is " +
24
- "verified. Never leave a run open: one stuck at running/fixing absorbs " +
25
- "later unrelated verdicts for that worktree. Without run_id, binds to " +
26
- "the latest open run for the same worktree+plan, else creates one.",
27
- inputSchema: {
28
- type: "object" as const,
29
- properties: {
30
- run_id: {
31
- type: "number",
32
- description: "Existing run to attach to. Omitted = bind or create.",
33
- },
34
- verdict: {
35
- type: "string",
36
- enum: [...VERDICT_GRADES],
37
- description: "Verdict grade",
38
- },
39
- reason_code: {
40
- type: "string",
41
- enum: [...REASON_CODES],
42
- description:
43
- "Failure reason. Clean passes take 'none', never 'other'.",
44
- },
45
- regime: {
46
- type: "string",
47
- enum: [...REGIME_CODES],
48
- description:
49
- "Task shape: code=feature/refactor, fix=debugging a defect, " +
50
- "review=reviewing a diff, plan=producing a plan or spec, " +
51
- "inquiry=questions without editing files, test=writing tests",
52
- },
53
- note: {
54
- type: "string",
55
- description:
56
- "Standalone note — read months later with no access to this " +
57
- "conversation. Required when reason_code = 'other'.",
58
- },
59
- worktree: {
60
- type: "string",
61
- description:
62
- "Absolute repo path (git rev-parse --show-toplevel). Every query " +
63
- "scopes by it, so a bare name or an omission files the verdict " +
64
- "where nothing reads it. Neither case errors.",
65
- },
66
- plan: {
67
- type: "string",
68
- description: "Plan file path, for a new run",
69
- },
70
- session_id: {
71
- type: "string",
72
- description:
73
- "Client session id (Claude Code/Codex: the transcript .jsonl " +
74
- "path; OpenCode/ZCode: the session id) — this is what attributes " +
75
- "the verdict to a model. Cannot find it → omit, never invent one.",
76
- },
77
- files: {
78
- type: "array",
79
- items: { type: "string" },
80
- description:
81
- "Repo-relative paths this unit touched. Optional, but always " +
82
- "send them: nothing else records where the work landed.",
83
- },
84
- },
85
- required: ["verdict", "reason_code", "regime"],
86
- },
87
- },
88
14
  {
89
15
  name: "mem_find",
90
16
  description:
@@ -1,17 +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
4
  import { getServerSha } from "./primitives.js";
8
- import {
9
- TOOLS,
10
- toolMemAdd,
11
- toolMemClose,
12
- toolMemFind,
13
- toolVerdictSubmit,
14
- } from "./tools/index.js";
5
+ import { TOOLS, toolMemAdd, toolMemClose, toolMemFind } from "./tools/index.js";
15
6
  import { errorResult, type ToolResult } from "./types.js";
16
7
 
17
8
  // --- Server instructions ---
@@ -26,35 +17,14 @@ import { errorResult, type ToolResult } from "./types.js";
26
17
  // Both habits degrade silently — an agent that ignores them still gets
27
18
  // correct answers from every tool, just a thinner history.
28
19
 
29
- 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.
30
21
 
31
- 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.
32
23
 
33
- 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.
34
25
 
35
26
  Skip it and every tool still answers correctly, on a thinner history.`;
36
27
 
37
- // --- Statusline cache ---
38
- //
39
- // Written after every MCP tool call. The Claude Code statusline script reads
40
- // this file (< 1ms, no spawn, no db). Format: single line of text.
41
- // No remaining tool produces bytes_by_tool detail, so every call writes the
42
- // minimal "fapony" marker — cross-client spend lives on the CLI now
43
- // (`fapony usage-scan` + `fapony usage-web`), which the statusline cannot
44
- // spawn (it must stay < 1ms).
45
-
46
- const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
47
-
48
- function writeStatuslineCache(): void {
49
- try {
50
- const dir = join(homedir(), ".config", "fapony");
51
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
52
- writeFileSync(STATUSLINE_PATH, "fapony", "utf-8");
53
- } catch {
54
- // Cache write is best-effort — never block MCP on it.
55
- }
56
- }
57
-
58
28
  // --- MCP protocol constants ---
59
29
 
60
30
  const MCP_PROTOCOL_VERSION = "2025-03-26";
@@ -98,9 +68,6 @@ function dispatchToolCall(params: {
98
68
  const args = params.arguments ?? {};
99
69
  let result: ToolResult;
100
70
  switch (params.name) {
101
- case "verdict_submit":
102
- result = toolVerdictSubmit(args);
103
- break;
104
71
  case "mem_find":
105
72
  result = toolMemFind(args);
106
73
  break;
@@ -113,8 +80,6 @@ function dispatchToolCall(params: {
113
80
  default:
114
81
  return errorResult(`unknown tool: ${params.name}`);
115
82
  }
116
- // Write statusline cache after every tool call — best-effort, never blocks.
117
- writeStatuslineCache();
118
83
  return result;
119
84
  }
120
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 {
@@ -3,13 +3,28 @@
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
5
  import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
6
- import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
6
+ import { doneLines, fmtClose, fmtRow } from "../render.js";
7
7
  import { claimsOf, openRows, staleReport } from "../selectors.js";
8
8
  import type { CloseRow, WorkRow } from "../store.js";
9
9
  import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
10
10
  import { planSweepCmd, shippedNotMoved } from "./plan.js";
11
11
  import { THRESHOLD } from "./rotate.js";
12
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
+
13
28
  const rotateLine = (n: number) =>
14
29
  n >= THRESHOLD
15
30
  ? `\n## 🗜 log ${n} rows (≥ ${THRESHOLD}) — run: ${memCmd} rotate --apply`
@@ -24,26 +39,20 @@ const planSweepLine = () => {
24
39
  : "";
25
40
  };
26
41
 
27
- export const cmdNow = () => {
28
- // mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
29
- const all = rows();
30
- console.log(`# ${app} — ${all.length} entries`);
31
- printOpenRows(all, { showHold: true });
32
- const decisionN = openRows(all).filter((r) => r.kind === "decision").length;
33
- const noteN = openRows(all).filter((r) => r.kind === "note").length;
34
- console.log(
35
- `\n## decision ${decisionN} · note ${noteN} — search: ${memCmd} find <word>`,
36
- );
37
- const stale = staleReport(all);
38
- if (stale.length)
39
- console.log(
40
- `\n## ⚠ stale (${stale.length})\n` +
41
- stale.map((l) => `- ${l}`).join("\n"),
42
- );
43
- const sweep = planSweepLine();
44
- if (sweep) console.log(sweep);
45
- const rotate = rotateLine(all.length);
46
- 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}…`;
47
56
  };
48
57
 
49
58
  export const cmdDone = () => {
@@ -235,118 +244,12 @@ export const cmdKickoff = (a: string[]) => {
235
244
  }
236
245
  }
237
246
 
238
- // --- main output (unchanged behavior) ---
239
-
240
- if (!arg && !planFile) {
241
- // no args = now + a "recent" section = the last 10 closes
242
- console.log(`# ${app} — ${all.length} entries`);
243
- printOpenRows(all, { showHold: true });
244
- console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
245
- const stale = staleReport(all);
246
- if (stale.length)
247
- console.log(
248
- `\n## ⚠ stale (${stale.length})\n` +
249
- stale.map((l) => `- ${l}`).join("\n"),
250
- );
251
- const sweep = planSweepLine();
252
- if (sweep) console.log(sweep);
253
- const rotate = rotateLine(all.length);
254
- if (rotate) console.log(rotate);
255
- } else if (resolvedSpec) {
256
- // spec.md = a brief for that spec
257
- const open = openRows(all);
258
- const specRows = open.filter((r) => r.spec === resolvedSpec);
259
- const specDecisions = all
260
- .filter(
261
- (r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
262
- )
263
- .slice(-5);
264
- const specCloses = all
265
- .filter((r): r is CloseRow => r.kind === "close")
266
- .filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
267
- .slice(-3);
268
-
269
- console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
270
- if (specRows.length) {
271
- console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
272
- }
273
- if (specDecisions.length) {
274
- console.log(
275
- `\n## decisions\n` +
276
- specDecisions
277
- .map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
278
- .join("\n"),
279
- );
280
- }
281
- if (specCloses.length) {
282
- console.log(
283
- `\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
284
- );
285
- }
286
- if (!specRows.length && !specDecisions.length && !specCloses.length) {
287
- console.log("(no entries for this spec)");
288
- }
289
- } else if (target) {
290
- // id = a brief for that task
291
- const claims = claimsOf(all);
292
- const claimInfo = claims.has(arg)
293
- ? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
294
- : "";
295
-
296
- const spec = target.spec;
297
- const open = spec
298
- ? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
299
- : [];
300
- const specDecisions = spec
301
- ? all
302
- .filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
303
- .slice(-5)
304
- : [];
305
- const specNotes = spec
306
- ? all
307
- .filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
308
- .slice(-5)
309
- : [];
310
- const specCloses = spec
311
- ? all
312
- .filter((r): r is CloseRow => r.kind === "close")
313
- .filter((r) => byId.get(r.ref)?.spec === spec)
314
- .slice(-3)
315
- : [];
316
-
317
- console.log(
318
- `# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
319
- );
320
- if (open.length) {
321
- console.log(
322
- `\n## open (same spec)\n` +
323
- open.map((r) => fmtRow(r, claims)).join("\n"),
324
- );
325
- }
326
- if (specDecisions.length || specNotes.length) {
327
- console.log(`\n## decisions & notes (spec)\n`);
328
- for (const r of [...specDecisions, ...specNotes]) {
329
- console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
330
- }
331
- }
332
- if (specCloses.length) {
333
- console.log(
334
- `\n## closes (spec)\n` +
335
- specCloses.map((c) => fmtClose(c, byId)).join("\n"),
336
- );
337
- }
338
- } else if (arg) {
339
- console.error(`no id "${arg}" in the log`);
340
- process.exit(1);
341
- }
342
-
343
- // --- next up section (chunk 3) ---
247
+ // --- suggestions (generated early so the no-args path can print next up
248
+ // before the recency tier, keeping it inside the 4KB cap) ---
344
249
 
345
- // A suggestion may carry a fully-runnable command. `manual` marks a command
346
- // that is a template with a placeholder (bug close) — show it, never auto-run it.
347
250
  type Suggestion = { text: string; run?: string; manual?: boolean };
348
251
  const suggestions: Suggestion[] = [];
349
- const GROUP_CAP = 3; // SPEC §3: at most 3 groups, at most 3 lines each
252
+ const GROUP_CAP = 3;
350
253
 
351
254
  const truncate = (s: string, n = 80) =>
352
255
  s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s;
@@ -358,7 +261,6 @@ export const cmdKickoff = (a: string[]) => {
358
261
  for (const file of files) {
359
262
  if (planLines.length >= GROUP_CAP) break;
360
263
  const fullPath = join(planDir, file);
361
- // Skip the plan we already showed
362
264
  if (planFile && fullPath === planFile) continue;
363
265
  if (hasHighPriority(fullPath)) {
364
266
  const title = readPlanTitle(fullPath);
@@ -369,19 +271,16 @@ export const cmdKickoff = (a: string[]) => {
369
271
  }
370
272
  }
371
273
  }
372
- // Only the first unchecked chunk (SPEC §3) — the body of the plan is the plan's job.
373
- // There is no per-chunk command, so this line is context, not a runnable item.
374
274
  if (planLines.length < GROUP_CAP && planCheckboxes.length) {
375
275
  const planName = planFile ? basename(planFile) : "plan";
376
276
  planLines.push({ text: `${planCheckboxes[0]} (${planName})` });
377
277
  }
378
278
  suggestions.push(...planLines);
379
279
 
380
- // group 2 — open bugs, oldest first. openRows excludes tombstoned refs; the
381
- // old bug filter forgot that and offered already-closed bugs as work.
382
- const claims = claimsOf(all);
280
+ // group 2 — open bugs, oldest first
281
+ const suggClaims = claimsOf(all);
383
282
  const openBugs = openRows(all)
384
- .filter((r) => r.kind === "bug" && !claims.has(r.id))
283
+ .filter((r) => r.kind === "bug" && !suggClaims.has(r.id))
385
284
  .slice(0, GROUP_CAP);
386
285
  for (const b of openBugs) {
387
286
  suggestions.push({
@@ -407,11 +306,7 @@ export const cmdKickoff = (a: string[]) => {
407
306
  });
408
307
  }
409
308
 
410
- // group 4 — the lint baseline. "Is this red mine or was it already red" is
411
- // already answered by `lint-baseline`, but only if it was captured before
412
- // the work started — and nobody remembers at session open, which is what
413
- // kickoff is. Only offered when the repo declared a lint command; without
414
- // one the capture would just fail.
309
+ // group 4 — the lint baseline
415
310
  try {
416
311
  if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
417
312
  suggestions.push({
@@ -423,15 +318,14 @@ export const cmdKickoff = (a: string[]) => {
423
318
  // state dir unreadable — the baseline line is a convenience, never a gate
424
319
  }
425
320
 
426
- // Print and optionally execute. Only a suggestion with a complete command is
427
- // runnable — a plan item is context, a bug's close is a template needing a message.
428
- if (suggestions.length) {
321
+ /** Print ## next up and handle --pick. */
322
+ const printNextUp = () => {
323
+ if (!suggestions.length) return;
429
324
  console.log(`\n## next up`);
430
325
  for (let i = 0; i < suggestions.length; i++) {
431
326
  console.log(` [${i + 1}] ${suggestions[i].text}`);
432
327
  if (suggestions[i].run) console.log(` → ${suggestions[i].run}`);
433
328
  }
434
-
435
329
  if (pickIdx !== null) {
436
330
  if (pickIdx > suggestions.length) {
437
331
  console.error(
@@ -461,5 +355,184 @@ export const cmdKickoff = (a: string[]) => {
461
355
  process.exit(1);
462
356
  }
463
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;
364
+
365
+ if (!arg && !planFile) {
366
+ // no args = ranked open rows + next up + recent closes
367
+ console.log(`# ${app} — ${all.length} entries`);
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
+ };
435
+ } else if (resolvedSpec) {
436
+ // spec.md = a brief for that spec
437
+ const open = openRows(all);
438
+ const specRows = open.filter((r) => r.spec === resolvedSpec);
439
+ const specDecisions = all
440
+ .filter(
441
+ (r): r is WorkRow => r.kind === "decision" && r.spec === resolvedSpec,
442
+ )
443
+ .slice(-5);
444
+ const specCloses = all
445
+ .filter((r): r is CloseRow => r.kind === "close")
446
+ .filter((r) => byId.get(r.ref)?.spec === resolvedSpec)
447
+ .slice(-3);
448
+
449
+ console.log(`# ${resolvedSpec} — ${specRows.length} open rows`);
450
+ if (specRows.length) {
451
+ console.log(`\n## open\n${specRows.map((r) => fmtRow(r)).join("\n")}`);
452
+ }
453
+ if (specDecisions.length) {
454
+ console.log(
455
+ `\n## decisions\n` +
456
+ specDecisions
457
+ .map((r) => `- ${r.ts.slice(0, 10)} ${r.text}`)
458
+ .join("\n"),
459
+ );
460
+ }
461
+ if (specCloses.length) {
462
+ console.log(
463
+ `\n## closes\n${specCloses.map((c) => fmtClose(c, byId)).join("\n")}`,
464
+ );
465
+ }
466
+ if (!specRows.length && !specDecisions.length && !specCloses.length) {
467
+ console.log("(no entries for this spec)");
468
+ }
469
+ } else if (target) {
470
+ // id = a brief for that task
471
+ const claims = claimsOf(all);
472
+ const claimInfo = claims.has(arg)
473
+ ? `\nClaimed by: ${claims.get(arg)?.agent} (${claims.get(arg)?.ts.slice(0, 10)})`
474
+ : "";
475
+
476
+ const spec = target.spec;
477
+ const open = spec
478
+ ? openRows(all).filter((r) => r.spec === spec && r.id !== arg)
479
+ : [];
480
+ const specDecisions = spec
481
+ ? all
482
+ .filter((r): r is WorkRow => r.kind === "decision" && r.spec === spec)
483
+ .slice(-5)
484
+ : [];
485
+ const specNotes = spec
486
+ ? all
487
+ .filter((r): r is WorkRow => r.kind === "note" && r.spec === spec)
488
+ .slice(-5)
489
+ : [];
490
+ const specCloses = spec
491
+ ? all
492
+ .filter((r): r is CloseRow => r.kind === "close")
493
+ .filter((r) => byId.get(r.ref)?.spec === spec)
494
+ .slice(-3)
495
+ : [];
496
+
497
+ console.log(
498
+ `# [${target.id}] ${target.kind} — ${target.text}${target.spec ? ` → ${target.spec}` : ""}${claimInfo}`,
499
+ );
500
+ if (open.length) {
501
+ console.log(
502
+ `\n## open (same spec)\n` +
503
+ open.map((r) => fmtRow(r, claims)).join("\n"),
504
+ );
505
+ }
506
+ if (specDecisions.length || specNotes.length) {
507
+ console.log(`\n## decisions & notes (spec)\n`);
508
+ for (const r of [...specDecisions, ...specNotes]) {
509
+ console.log(`- ${r.ts.slice(0, 10)} ${r.kind} ${r.text}`);
510
+ }
511
+ }
512
+ if (specCloses.length) {
513
+ console.log(
514
+ `\n## closes (spec)\n` +
515
+ specCloses.map((c) => fmtClose(c, byId)).join("\n"),
516
+ );
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
+ }
528
+ } else if (arg) {
529
+ console.error(`no id "${arg}" in the log`);
530
+ process.exit(1);
464
531
  }
532
+
533
+ // For non-no-args paths, print next up at the end
534
+ if (arg || planFile) printNextUp();
535
+
536
+ // Deferred output from no-args path (recent closes, plan sweep, rotate)
537
+ kickoffExtras?.();
465
538
  };