fapony 0.3.0 → 0.3.4

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 (96) hide show
  1. package/README.md +87 -65
  2. package/fapony.ts +3 -116
  3. package/package.json +6 -5
  4. package/skill/move-to-done/SKILL.md +22 -32
  5. package/skill/review-pony/SKILL.md +27 -58
  6. package/src/adapters/cli.ts +123 -0
  7. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  8. package/src/adapters/hooks/context-data.ts +102 -0
  9. package/src/adapters/hooks/edit-hint.ts +195 -0
  10. package/src/adapters/hooks/git-autonomy.ts +178 -0
  11. package/src/adapters/hooks/index.ts +79 -0
  12. package/src/adapters/hooks/mv-guard.ts +52 -0
  13. package/src/adapters/hooks/read-hint.ts +383 -0
  14. package/src/adapters/hooks/session-start.ts +101 -0
  15. package/src/adapters/hooks/stop.ts +302 -0
  16. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
  18. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  20. package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
  21. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  22. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  23. package/src/{mcp → adapters/mcp}/transport.ts +4 -39
  24. package/src/adapters/mcp/types.ts +18 -0
  25. package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
  26. package/src/analyze.ts +2 -2
  27. package/src/conventions-seed.ts +1 -1
  28. package/src/core/config.ts +199 -0
  29. package/src/core/debt-format.ts +107 -0
  30. package/src/core/debt-types.ts +79 -0
  31. package/src/core/defaults.ts +8 -0
  32. package/src/core/enums.ts +34 -0
  33. package/src/core/format.ts +33 -0
  34. package/src/core/hint-log.ts +68 -0
  35. package/src/core/hook-helpers.ts +31 -0
  36. package/src/core/mem-log.ts +357 -0
  37. package/src/core/parse.ts +71 -0
  38. package/src/core/pricing.ts +217 -0
  39. package/src/core/safety.ts +18 -0
  40. package/src/core/types.ts +142 -0
  41. package/src/core/util.ts +105 -0
  42. package/src/db/store.ts +2 -2
  43. package/src/debt/cli.ts +1 -1
  44. package/src/debt/format.ts +2 -107
  45. package/src/debt/load.ts +1 -1
  46. package/src/debt/promotion.ts +1 -1
  47. package/src/debt/types.ts +14 -79
  48. package/src/digest/collect.ts +4 -3
  49. package/src/gate.ts +5 -5
  50. package/src/gates.ts +1 -1
  51. package/src/hook.ts +69 -1337
  52. package/src/init-mem.ts +58 -72
  53. package/src/init.ts +13 -17
  54. package/src/install/antigravity.ts +112 -0
  55. package/src/install/claude.ts +19 -123
  56. package/src/install/detect.ts +17 -7
  57. package/src/install/opencode.ts +167 -26
  58. package/src/install.ts +23 -7
  59. package/src/lint-baseline.ts +1 -2
  60. package/src/map.ts +29 -8
  61. package/src/mem/commands/plan.ts +70 -42
  62. package/src/mem/commands/read.ts +219 -146
  63. package/src/mem/index.ts +4 -13
  64. package/src/mem/store.ts +5 -1
  65. package/src/memory.ts +24 -387
  66. package/src/parse.ts +9 -71
  67. package/src/price/fetch.ts +4 -16
  68. package/src/price/resolve.ts +12 -213
  69. package/src/report/cli.ts +3 -3
  70. package/src/safety.ts +2 -18
  71. package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
  72. package/src/seed/primitives.ts +60 -0
  73. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  74. package/src/session/types.ts +14 -128
  75. package/src/setup.ts +1 -1
  76. package/src/stats/data.ts +4 -4
  77. package/src/telemetry.ts +3 -3
  78. package/src/usage/cache.ts +1 -2
  79. package/src/usage/cli.ts +1 -1
  80. package/src/usage/scan.ts +2 -1
  81. package/src/util.ts +10 -32
  82. package/src/web/html.ts +2 -33
  83. package/templates/SPEC.md +8 -1
  84. package/images/logo.png +0 -0
  85. package/images/logo.webp +0 -0
  86. package/images/logo@400.webp +0 -0
  87. package/images/sample.webp +0 -0
  88. package/images/summary.webp +0 -0
  89. package/src/db/defaults.ts +0 -34
  90. package/src/db/getters.ts +0 -35
  91. package/src/db/index.ts +0 -7
  92. package/src/db/load.ts +0 -57
  93. package/src/db/types.ts +0 -77
  94. package/src/math.ts +0 -13
  95. package/src/mcp/tools/verdict.ts +0 -161
  96. package/src/mcp/types.ts +0 -54
@@ -2,8 +2,9 @@
2
2
  // rationale: moving by hand = chasing relative links yourself (in the file + files that link to it) → the step gets skipped often
3
3
  // no arg = report only (safe, shows every kickoff/stale run)
4
4
  // <file.md> = check a single file, is it ready to move
5
- // <file.md> --apply = git mv + fix markdown links inside the file + fix inbound links from other files in plan/
6
- // + warn about plain-text mentions (detect-only, no auto-fix)
5
+ // <file.md> --apply = git mv + fix markdown links inside the file + fix inbound links from every .md under .fapony/
6
+ // (plan/ + done/ + spec/) + warn about plain-text mentions (detect-only, no auto-fix)
7
+ // + warn about tracked files outside .fapony/ that mention the filename (detect-only)
7
8
 
8
9
  import {
9
10
  existsSync,
@@ -12,10 +13,20 @@ import {
12
13
  readFileSync,
13
14
  writeFileSync,
14
15
  } from "node:fs";
15
- import { dirname, join, relative, resolve } from "node:path";
16
+ import { basename, dirname, join, relative, resolve } from "node:path";
16
17
 
17
18
  import { openRows } from "../selectors.js";
18
- import { doneDir, memCmd, nextId, planDir, put, rel, rows } from "../store.js";
19
+ import {
20
+ doneDir,
21
+ memCmd,
22
+ nextId,
23
+ planBase,
24
+ planDir,
25
+ put,
26
+ rel,
27
+ root,
28
+ rows,
29
+ } from "../store.js";
19
30
 
20
31
  const SHIPPED = /^>\s*✅/m;
21
32
  const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -52,6 +63,11 @@ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
63
  // the file moved dir (same content) — an old markdown link meant the old path relative to oldDir, must re-relativize via newDir
53
64
  // resolve from newDir (where the file is now) — if the target also moved to the same dir → plain filename,
54
65
  // if the target stayed in oldDir → ../target (both correct)
66
+ //
67
+ // Runs on EVERY move, including the standard sibling layout (plan/ → done/ beside
68
+ // it): same depth is not the same directory, so a sibling link like
69
+ // [other](PLAN-other.md) breaks unless rewritten to ../plan/PLAN-other.md.
70
+ // Skipping this pass left exactly that dangling (mtjn3ldk).
55
71
  // pass 1 only — fix only [text](target) markdown links, do not touch plain text
56
72
  export const rewriteMovedFileLinks = (
57
73
  file: string,
@@ -65,11 +81,17 @@ export const rewriteMovedFileLinks = (
65
81
  const [target, anchor] = t.split("#");
66
82
  if (!target || /^(https?:|mailto:|\/)/.test(target)) return m;
67
83
  // resolve from where the file IS now (newDir); if target doesn't exist
68
- // there, fall back to oldDir (target stayed in original location)
84
+ // there, fall back to oldDir (target stayed in original location).
85
+ // If it exists in neither, the link is already broken — leave it for
86
+ // plan-check instead of fabricating a new broken path.
69
87
  const inNew = resolve(newDir, target);
70
- const abs = existsSync(inNew) ? inNew : resolve(oldDir, target);
88
+ const inOld = resolve(oldDir, target);
89
+ const abs = existsSync(inNew) ? inNew : existsSync(inOld) ? inOld : null;
90
+ if (!abs) return m;
91
+ const next = `](${relative(newDir, abs) || "."}${anchor ? `#${anchor}` : ""})`;
92
+ if (next === m) return m;
71
93
  n++;
72
- return `](${relative(newDir, abs) || "."}${anchor ? `#${anchor}` : ""})`;
94
+ return next;
73
95
  });
74
96
 
75
97
  if (n) writeFileSync(file, out);
@@ -172,11 +194,20 @@ export const cmdPlanSweep = (a: string[]) => {
172
194
  return;
173
195
  }
174
196
 
175
- const src = join(dir, target);
176
- if (!existsSync(src)) {
177
- console.error(`${rel(src)} not found`);
197
+ // target arrives as a bare name (PLAN-x.md, the SKILL form) or a repo-relative
198
+ // path (.fapony/plan/PLAN-x.md, the example form) — both must resolve, so try
199
+ // planDir first, then the repo root, then the bare basename as a last resort.
200
+ const src = [
201
+ join(dir, target),
202
+ join(root, target),
203
+ join(dir, basename(target)),
204
+ ].find((p) => existsSync(p));
205
+ if (!src) {
206
+ console.error(`${target} not found (looked in ${rel(dir)}/ and repo root)`);
178
207
  process.exit(1);
179
208
  }
209
+ const name = basename(src);
210
+ const srcDir = dirname(src);
180
211
  const shipped = hasShippedHeader(src);
181
212
  if (!apply) {
182
213
  console.log(
@@ -191,21 +222,21 @@ export const cmdPlanSweep = (a: string[]) => {
191
222
  // but then run --apply directly and skip everything → risky when an agent ships automatically with no human check, so hard block
192
223
  if (!shipped && !process.env.MEM_FORCE) {
193
224
  console.error(
194
- `${target}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
225
+ `${name}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
195
226
  );
196
227
  process.exit(1);
197
228
  }
198
- const openSpec = `${rel(dir)}/${target}`;
229
+ const openSpec = rel(src);
199
230
  const openN = openRows(rows()).filter((r) => r.spec === openSpec);
200
231
  if (openN.length && !process.env.MEM_FORCE) {
201
232
  console.error(
202
- `${target}: still has ${openN.length} open row(s) (next/bug/hold/decision/note) — close them or move the spec first (MEM_FORCE=1 to override):\n` +
233
+ `${name}: still has ${openN.length} open row(s) (next/bug/hold/decision/note) — close them or move the spec first (MEM_FORCE=1 to override):\n` +
203
234
  openN.map((r) => ` [${r.id}] ${r.kind} ${r.text}`).join("\n"),
204
235
  );
205
236
  process.exit(1);
206
237
  }
207
238
 
208
- const dst = join(doneDir, target);
239
+ const dst = join(doneDir, name);
209
240
  if (existsSync(dst)) {
210
241
  console.error(`${rel(dst)} already exists`);
211
242
  process.exit(1);
@@ -221,14 +252,17 @@ export const cmdPlanSweep = (a: string[]) => {
221
252
  process.exit(1);
222
253
  }
223
254
 
224
- // done/ is a sibling of plan/ = same depth, links in the file still resolve, no need to touch
225
- // the old layout (plan/done/) is one level deeper, so it does need re-relativizing
226
- const nested = dirname(doneDir) !== dirname(dir);
227
- const ownLinks = nested ? rewriteMovedFileLinks(dst, dir, doneDir) : 0;
255
+ // own links always need re-relativizing — the file changed directory even in
256
+ // the sibling layout (plan/ → done/ beside it), so sibling links dangle
257
+ // unless rewritten (see rewriteMovedFileLinks).
258
+ const ownLinks = rewriteMovedFileLinks(dst, srcDir, doneDir);
228
259
 
260
+ // inbound: every .md under .fapony/ can link here — other active plans,
261
+ // shipped plans in done/ (they reference each other), and specs. Scanning
262
+ // plan/ only left done/+spec/ links dangling (mtl15q4y).
229
263
  let inbound = 0;
230
264
  let inboundFiles = 0;
231
- for (const f of mdFiles(dir)) {
265
+ for (const f of mdFiles(planBase)) {
232
266
  if (f === dst) continue;
233
267
  const n = rewriteMarkdownLinks(f, src, dst);
234
268
  if (n) {
@@ -238,13 +272,9 @@ export const cmdPlanSweep = (a: string[]) => {
238
272
  }
239
273
 
240
274
  console.log(`moved ${rel(src)} → ${rel(dst)}`);
275
+ console.log(`links rewritten inside the file: ${ownLinks}`);
241
276
  console.log(
242
- nested
243
- ? `links rewritten inside the file: ${ownLinks}`
244
- : `links rewritten inside the file: 0 (same depth, existing links still resolve)`,
245
- );
246
- console.log(
247
- `inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(dir)}/** only)`,
277
+ `inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(planBase)}/**)`,
248
278
  );
249
279
 
250
280
  // log decision — record ship event (reuse existing kind, no new schema)
@@ -252,7 +282,7 @@ export const cmdPlanSweep = (a: string[]) => {
252
282
  put({
253
283
  id: nextId(rows()),
254
284
  kind: "decision",
255
- text: `${target} shipped → ${rel(dst)}`,
285
+ text: `${name} shipped → ${rel(dst)}`,
256
286
  spec: doneSpec,
257
287
  });
258
288
  // ponytail: this decision *is* the move itself, nothing to write back into the spec — do not mark synced
@@ -262,35 +292,33 @@ export const cmdPlanSweep = (a: string[]) => {
262
292
  // plain-text mention detection (detect-only, no auto-fix)
263
293
  let plainTextTotal = 0;
264
294
  const plainTextFiles: string[] = [];
265
- for (const f of mdFiles(dir)) {
266
- const n = countPlainTextMentions(f, target);
295
+ for (const f of mdFiles(planBase)) {
296
+ if (f === dst) continue;
297
+ const n = countPlainTextMentions(f, name);
267
298
  if (n) {
268
299
  plainTextTotal += n;
269
- plainTextFiles.push(f.replace(`${dir}/`, ""));
300
+ plainTextFiles.push(f.replace(`${planBase}/`, ""));
270
301
  }
271
302
  }
272
303
  if (plainTextTotal > 0) {
273
304
  console.log(
274
- `⚠ ${plainTextTotal} plain-text mention(s) in ${plainTextFiles.length} file(s) under ${rel(dir)}/ — grep and update the paths yourself:\n${plainTextFiles.join("\n")}`,
305
+ `⚠ ${plainTextTotal} plain-text mention(s) in ${plainTextFiles.length} file(s) under ${rel(planBase)}/ — grep and update the paths yourself:\n${plainTextFiles.join("\n")}`,
275
306
  );
276
307
  }
277
308
 
278
- // files outside plan/ that mention the target — detect-only
279
- const grep = Bun.spawnSync([
280
- "git",
281
- "grep",
282
- "-l",
283
- target,
284
- "--",
285
- // the "files outside plan/" scope = the folder plan/ lives under (apps/vela, .fapony, …)
286
- rel(dirname(planDir)),
287
- `:!${rel(dir)}`,
288
- ])
309
+ // files outside .fapony/ that mention the filename — detect-only.
310
+ // Everything under planBase/ was auto-fixed above, so what remains is repo
311
+ // docs and prose (README, docs/, CLAUDE.md) with hand-written paths.
312
+ // cwd: root — the pathspecs are root-relative no matter where fapony runs from.
313
+ const grep = Bun.spawnSync(
314
+ ["git", "grep", "-l", name, "--", ".", `:!${rel(planBase)}`],
315
+ { cwd: root },
316
+ )
289
317
  .stdout.toString()
290
318
  .trim();
291
319
  if (grep)
292
320
  console.log(
293
- `⚠ files outside ${rel(dir)}/ still mention "${target}" — check them yourself (not auto-fixed):\n${grep}`,
321
+ `⚠ files outside ${rel(planBase)}/ still mention "${name}" — check them yourself (not auto-fixed):\n${grep}`,
294
322
  );
295
323
  };
296
324
 
@@ -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
  };