fapony 0.3.3 → 0.3.5

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 (87) hide show
  1. package/README.md +7 -1
  2. package/fapony.ts +3 -116
  3. package/package.json +5 -6
  4. package/skill/move-to-done/SKILL.md +3 -2
  5. package/src/adapters/cli.ts +128 -0
  6. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  7. package/src/adapters/hooks/context-data.ts +102 -0
  8. package/src/adapters/hooks/edit-hint.ts +195 -0
  9. package/src/adapters/hooks/git-autonomy.ts +178 -0
  10. package/src/adapters/hooks/index.ts +80 -0
  11. package/src/adapters/hooks/mv-guard.ts +52 -0
  12. package/src/adapters/hooks/read-hint.ts +383 -0
  13. package/src/adapters/hooks/session-start.ts +101 -0
  14. package/src/adapters/hooks/stop.ts +434 -0
  15. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  20. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  21. package/src/adapters/mcp/types.ts +18 -0
  22. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  23. package/src/analyze.ts +1 -1
  24. package/src/commands.ts +180 -0
  25. package/src/conventions-seed.ts +1 -1
  26. package/src/core/config.ts +199 -0
  27. package/src/core/debt-format.ts +107 -0
  28. package/src/core/debt-types.ts +79 -0
  29. package/src/core/defaults.ts +8 -0
  30. package/src/core/enums.ts +34 -0
  31. package/src/core/format.ts +33 -0
  32. package/src/core/hint-log.ts +68 -0
  33. package/src/core/hook-helpers.ts +31 -0
  34. package/src/core/mem-log.ts +357 -0
  35. package/src/core/parse.ts +71 -0
  36. package/src/core/pricing.ts +217 -0
  37. package/src/core/safety.ts +18 -0
  38. package/src/core/types.ts +142 -0
  39. package/src/core/util.ts +105 -0
  40. package/src/db/store.ts +2 -2
  41. package/src/debt/cli.ts +1 -1
  42. package/src/debt/format.ts +2 -107
  43. package/src/debt/load.ts +1 -1
  44. package/src/debt/promotion.ts +1 -1
  45. package/src/debt/types.ts +14 -79
  46. package/src/digest/collect.ts +4 -3
  47. package/src/gate.ts +2 -2
  48. package/src/gates.ts +1 -1
  49. package/src/hook.ts +70 -1424
  50. package/src/init-mem.ts +1 -1
  51. package/src/init.ts +1 -1
  52. package/src/install/claude.ts +17 -0
  53. package/src/install/opencode.ts +114 -0
  54. package/src/install.ts +12 -5
  55. package/src/lint-baseline.ts +1 -2
  56. package/src/map.ts +29 -8
  57. package/src/mem/commands/plan.ts +197 -42
  58. package/src/mem/commands/read.ts +53 -12
  59. package/src/mem/index.ts +60 -1
  60. package/src/mem/store.ts +5 -1
  61. package/src/memory.ts +26 -388
  62. package/src/parse.ts +9 -71
  63. package/src/price/fetch.ts +4 -16
  64. package/src/price/resolve.ts +12 -213
  65. package/src/report/cli.ts +3 -3
  66. package/src/safety.ts +2 -18
  67. package/src/seed/plan-seed.ts +126 -32
  68. package/src/seed/primitives.ts +1 -7
  69. package/src/session/types.ts +14 -128
  70. package/src/setup.ts +1 -1
  71. package/src/stats/data.ts +3 -3
  72. package/src/telemetry.ts +2 -2
  73. package/src/update.ts +1 -1
  74. package/src/usage/cache.ts +1 -2
  75. package/src/usage/cli.ts +1 -1
  76. package/src/usage/scan.ts +2 -1
  77. package/src/util.ts +10 -93
  78. package/src/web/html.ts +2 -33
  79. package/src/db/defaults.ts +0 -34
  80. package/src/db/getters.ts +0 -35
  81. package/src/db/index.ts +0 -7
  82. package/src/db/load.ts +0 -57
  83. package/src/db/types.ts +0 -77
  84. package/src/mcp/types.ts +0 -54
  85. package/src/test.ts +0 -2
  86. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  87. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
@@ -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,40 +292,124 @@ 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
 
297
325
  // plan-check — list active PLANs + detect shipped-not-moved + broken links
326
+ // + verify the commits ticked chunks cite (chunk 8, PLAN-seed-and-surface)
298
327
  // exit 0 = clean, 1 = issues found
328
+
329
+ // A ticked chunk cites the commit that closed it — a sha git cannot find on
330
+ // this HEAD means the "done" mark proves nothing. git is the judge, not the
331
+ // regex: a 7-hex word that is no object at all (deadbee) is a plain word,
332
+ // never an issue. A sha that IS an object but not a commit (blob/tree)
333
+ // counts as missing — "no such commit" is literally true for it.
334
+ // Standalone short shas only — lookarounds (not \b) so a 40-char sha never
335
+ // matches on its tail: git resolves leading prefixes, a trailing slice would
336
+ // false-positive as missing.
337
+ export const SHA_RE = /(?<![0-9a-f])[0-9a-f]{7,12}(?![0-9a-f])/g;
338
+
339
+ export const extractShas = (line: string): string[] => line.match(SHA_RE) ?? [];
340
+
341
+ const gitOk = (args: string[], cwd: string): boolean => {
342
+ try {
343
+ return (
344
+ Bun.spawnSync(["git", ...args], {
345
+ cwd,
346
+ stdout: "ignore",
347
+ stderr: "ignore",
348
+ }).exitCode === 0
349
+ );
350
+ } catch {
351
+ return false;
352
+ }
353
+ };
354
+
355
+ export const isCommitObject = (sha: string, cwd: string): boolean =>
356
+ gitOk(["cat-file", "-e", `${sha}^{commit}`], cwd);
357
+
358
+ const isAnyObject = (sha: string, cwd: string): boolean =>
359
+ gitOk(["cat-file", "-e", sha], cwd);
360
+
361
+ export const isAncestorOfHead = (sha: string, cwd: string): boolean =>
362
+ gitOk(["merge-base", "--is-ancestor", sha, "HEAD"], cwd);
363
+
364
+ // Per ticked line: which cited shas fail, plus how many shas the line cites
365
+ // at all (cited). A hex word git never heard of is a plain word (deadbee,
366
+ // the "feedbac" inside "feedback") — UNLESS it sits in a paren group with a
367
+ // sha git knows, in which case the author cited it as a commit: the fixture
368
+ // is (e898877 + e307fe6), where e898877 resolves to no object at all yet is
369
+ // unmistakably a citation, not prose.
370
+ export const checkTickedLine = (
371
+ line: string,
372
+ cwd: string,
373
+ ): { missing: string[]; diverged: string[]; cited: number } => {
374
+ const missing: string[] = [];
375
+ const diverged: string[] = [];
376
+ const status = new Map<string, "commit" | "object" | "word">();
377
+ for (const sha of extractShas(line)) {
378
+ if (!status.has(sha)) {
379
+ status.set(
380
+ sha,
381
+ isCommitObject(sha, cwd)
382
+ ? "commit"
383
+ : isAnyObject(sha, cwd)
384
+ ? "object"
385
+ : "word",
386
+ );
387
+ }
388
+ }
389
+ const known = new Set(
390
+ [...status].filter(([, s]) => s !== "word").map(([k]) => k),
391
+ );
392
+ const citedInGroup = new Set<string>();
393
+ for (const g of line.match(/\([^)]*\)/g) ?? []) {
394
+ const gs = extractShas(g);
395
+ if (gs.some((s) => known.has(s))) for (const s of gs) citedInGroup.add(s);
396
+ }
397
+ let cited = 0;
398
+ for (const [sha, st] of status) {
399
+ if (st === "commit") {
400
+ cited++;
401
+ if (!isAncestorOfHead(sha, cwd)) diverged.push(sha);
402
+ } else if (st === "object") {
403
+ cited++;
404
+ missing.push(sha);
405
+ } else if (citedInGroup.has(sha)) {
406
+ cited++;
407
+ missing.push(sha);
408
+ }
409
+ // else: a plain word that happens to be hex — skip
410
+ }
411
+ return { missing, diverged, cited };
412
+ };
299
413
  export const cmdPlanCheck = (a: string[]) => {
300
414
  const quiet = a.includes("--quiet");
301
415
  const dir = planDir;
@@ -347,6 +461,47 @@ export const cmdPlanCheck = (a: string[]) => {
347
461
  }
348
462
  }
349
463
 
464
+ // 4) Ticked-chunk sha check — a ticked chunk that cites a commit must cite
465
+ // one git finds on this HEAD. Scans plan/ AND done/: done/ files are the
466
+ // shipped record, and the known-stale shas all live there — active-only
467
+ // would see zero. No sha = no check (chunks that close with "defer" have
468
+ // no commit); the summary line reports the ratio instead of flagging.
469
+ let closed = 0;
470
+ let citing = 0;
471
+ let verified = 0;
472
+ const shaFiles = [
473
+ ...new Set([...active, ...(existsSync(doneDir) ? mdFiles(doneDir) : [])]),
474
+ ];
475
+ for (const f of shaFiles) {
476
+ const relPath = relative(planBase, f);
477
+ const lines = readFileSync(f, "utf8").split("\n");
478
+ lines.forEach((line, i) => {
479
+ if (!/^\s*-\s\[x\]/.test(line)) return;
480
+ closed++;
481
+ const { missing, diverged, cited } = checkTickedLine(line, root);
482
+ if (cited > 0) {
483
+ citing++;
484
+ if (!missing.length && !diverged.length) verified++;
485
+ }
486
+ for (const sha of missing) {
487
+ issues.push(
488
+ `${relPath}:${i + 1} — ticked chunk cites ${sha} but git has no such commit\n fix: correct the sha or leave the chunk unticked`,
489
+ );
490
+ }
491
+ for (const sha of diverged) {
492
+ issues.push(
493
+ `${relPath}:${i + 1} — ticked chunk cites ${sha} which is not an ancestor of HEAD (rebased away?)\n fix: point at the surviving commit or leave the chunk unticked`,
494
+ );
495
+ }
496
+ });
497
+ }
498
+
499
+ if (!quiet) {
500
+ console.log(
501
+ `closed chunks: ${closed} · citing a commit: ${citing} · verified: ${verified}`,
502
+ );
503
+ }
504
+
350
505
  if (issues.length === 0) {
351
506
  if (!quiet) console.log("✅ clean");
352
507
  process.exit(0);
@@ -7,7 +7,7 @@ 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
- import { planSweepCmd, shippedNotMoved } from "./plan.js";
10
+ import { checkTickedLine, planSweepCmd, shippedNotMoved } from "./plan.js";
11
11
  import { THRESHOLD } from "./rotate.js";
12
12
 
13
13
  /** Files changed on this branch vs dev — empty set when dev is missing or diff fails. */
@@ -93,30 +93,56 @@ export const cmdFind = (a: string[]) => {
93
93
  if (!hits.length) console.log("(no matches)");
94
94
  };
95
95
 
96
- /** Read a plan file and extract unchecked checkboxes from the TL;DR section. */
97
- const readPlanCheckboxes = (planPath: string): string[] => {
96
+ /** Read a plan file and extract checked + unchecked items from the first ## section. */
97
+ const readPlanSectionItems = (
98
+ planPath: string,
99
+ ): { checked: string[]; unchecked: string[] } => {
98
100
  try {
99
101
  const text = readFileSync(planPath, "utf8");
100
102
  // Skip frontmatter
101
103
  const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
102
104
  // Find the first ## section (TL;DR)
103
105
  const start = body.search(/^##\s+/m);
104
- if (start < 0) return [];
106
+ if (start < 0) return { checked: [], unchecked: [] };
105
107
  const rest = body.slice(start);
106
108
  const next = rest.slice(3).search(/^##\s+/m);
107
109
  const block = next < 0 ? rest : rest.slice(0, next + 3);
108
- // Extract unchecked checkboxes
109
- const items: string[] = [];
110
+ // Extract checkboxes — ticked and unticked
111
+ const checked: string[] = [];
112
+ const unchecked: string[] = [];
110
113
  for (const line of block.split("\n")) {
111
- const m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
112
- if (m) items.push(m[1].trim());
114
+ let m = /^\s*[-*]\s+\[\s\]\s+(.+)$/.exec(line);
115
+ if (m) {
116
+ unchecked.push(m[1].trim());
117
+ continue;
118
+ }
119
+ m = /^\s*[-*]\s+\[[xX]\]\s+(.+)$/.exec(line);
120
+ if (m) checked.push(m[1].trim());
113
121
  }
114
- return items;
122
+ return { checked, unchecked };
115
123
  } catch {
116
- return [];
124
+ return { checked: [], unchecked: [] };
117
125
  }
118
126
  };
119
127
 
128
+ // Chunk 9 (PLAN-seed-and-surface): one line under the next-chunk display
129
+ // saying whether the last ticked chunk actually closed. Verified shas stay
130
+ // silent; only the newest ticked chunk is ever mentioned — one line per
131
+ // kickoff, never a list (else vela's 64/72 sha-less chunks become noise).
132
+ const closureHint = (checked: string[]): string | null => {
133
+ const last = checked[checked.length - 1];
134
+ if (!last) return null;
135
+ const { missing, diverged, cited } = checkTickedLine(last, root);
136
+ const label = /chunk\s+([^\s—–-]+)/i.exec(last)?.[1] ?? "latest";
137
+ if (missing.length)
138
+ return `⚠ chunk ${label} is ticked but ${missing[0]} is not in git — nothing proves it closed`;
139
+ if (diverged.length)
140
+ return `⚠ chunk ${label} is ticked but ${diverged[0]} is not on HEAD (rebased away?)`;
141
+ if (!cited)
142
+ return `⚠ chunk ${label} is ticked but cites no commit — nothing to verify it closed`;
143
+ return null;
144
+ };
145
+
120
146
  /** Check if a plan has `priority: high` in its frontmatter. */
121
147
  const hasHighPriority = (planPath: string): boolean => {
122
148
  try {
@@ -200,6 +226,7 @@ export const cmdKickoff = (a: string[]) => {
200
226
  // If no arg or arg didn't match an id/spec, check if it's a plan file
201
227
  let planFile: string | null = null;
202
228
  let planCheckboxes: string[] = [];
229
+ let planChecked: string[] = [];
203
230
 
204
231
  if (arg && !resolvedSpec && !target) {
205
232
  if (matches.length === 1) {
@@ -233,14 +260,18 @@ export const cmdKickoff = (a: string[]) => {
233
260
 
234
261
  // Read plan file if we found one (or if a .md arg matched a spec)
235
262
  if (planFile) {
236
- planCheckboxes = readPlanCheckboxes(planFile);
263
+ const items = readPlanSectionItems(planFile);
264
+ planCheckboxes = items.unchecked;
265
+ planChecked = items.checked;
237
266
  } else if (arg && resolvedSpec && arg.endsWith(".md")) {
238
267
  // The spec resolved from the log might be a plan file — try to read it
239
268
  const dir = planDir;
240
269
  const byName = join(dir, basename(arg));
241
270
  if (existsSync(byName)) {
242
271
  planFile = byName;
243
- planCheckboxes = readPlanCheckboxes(planFile);
272
+ const items = readPlanSectionItems(planFile);
273
+ planCheckboxes = items.unchecked;
274
+ planChecked = items.checked;
244
275
  }
245
276
  }
246
277
 
@@ -466,6 +497,12 @@ export const cmdKickoff = (a: string[]) => {
466
497
  if (!specRows.length && !specDecisions.length && !specCloses.length) {
467
498
  console.log("(no entries for this spec)");
468
499
  }
500
+ // The spec is also a plan file on disk — say whether the last ticked
501
+ // chunk actually closed (chunk 9). Silent when it verifies.
502
+ if (planFile) {
503
+ const hint = closureHint(planChecked);
504
+ if (hint) console.log(`\n${hint}`);
505
+ }
469
506
  } else if (target) {
470
507
  // id = a brief for that task
471
508
  const claims = claimsOf(all);
@@ -525,6 +562,10 @@ export const cmdKickoff = (a: string[]) => {
525
562
  } else {
526
563
  console.log(`\n(all chunks checked — ready to ship or archive)`);
527
564
  }
565
+ // Whether the last ticked chunk actually closed (chunk 9) — one line,
566
+ // silent when its sha verifies.
567
+ const hint = closureHint(planChecked);
568
+ if (hint) console.log(hint);
528
569
  } else if (arg) {
529
570
  console.error(`no id "${arg}" in the log`);
530
571
  process.exit(1);
package/src/mem/index.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  // Moved from templates/mem/mem.ts (2026-09-19) as part of PLAN-agent-one-call chunk 1.
4
4
  // Now an export function called by fapony.ts, not a standalone script.
5
5
 
6
+ import { existsSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+ import { levenshtein } from "../commands.js";
6
9
  import { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
7
10
  import { cmdDone, cmdFind, cmdKickoff, cmdStale } from "./commands/read.js";
8
11
  import { cmdRotate } from "./commands/rotate.js";
@@ -15,6 +18,7 @@ import {
15
18
  cmdRelease,
16
19
  cmdSynced,
17
20
  } from "./commands/write.js";
21
+ import { doneDir, planDir } from "./store.js";
18
22
 
19
23
  const MEM_HELP = `usage: fapony mem [--mem-dir <path>] <sub> [args]
20
24
 
@@ -86,6 +90,42 @@ reads the Stop-hook payload on stdin — installed by fapony, not run by hand
86
90
  example: (installed hook only)`,
87
91
  };
88
92
 
93
+ // Subcommand names for top-level did-you-mean (src/commands.ts) — derived
94
+ // from HELP keys so the list cannot drift from the dispatch below.
95
+ export const MEM_SUBCOMMANDS: string[] = Object.keys(HELP);
96
+
97
+ // Chunk 7 (PLAN-seed-and-surface, bug muc85pml): `fapony mem now` used to fall
98
+ // through to kickoff and print an overview ("N entries") instead of an error —
99
+ // a typo that looks like success. Three rules, in order:
100
+ // 1. a known subcommand → dispatch (unchanged)
101
+ // 2. looks like a plan path (.md suffix, a slash, or a file with that name
102
+ // in planDir/doneDir) → kickoff — this is `fapony mem PLAN-x.md`, which
103
+ // must keep working without the `kickoff` word
104
+ // 3. anything else → error, exit 1 (a typo is never a kickoff)
105
+ //
106
+ // `exists` is injectable so tests hit this directly without a store on disk —
107
+ // production passes nothing and reads planDir/doneDir live.
108
+ export function classifyMemArg(
109
+ arg: string,
110
+ exists?: (name: string) => boolean,
111
+ ): "subcommand" | "plan" | "unknown" {
112
+ // hasOwn, not `in` or indexing — prototype props ("toString") are not
113
+ // subcommands.
114
+ if (Object.hasOwn(HELP, arg)) return "subcommand";
115
+ if (arg.endsWith(".md") || arg.includes("/")) return "plan";
116
+ const has =
117
+ exists ??
118
+ ((name: string): boolean => {
119
+ const base = basename(name);
120
+ const candidates = [name, base, `${name}.md`, `${base}.md`];
121
+ return candidates.some(
122
+ (c) => existsSync(join(planDir, c)) || existsSync(join(doneDir, c)),
123
+ );
124
+ });
125
+ if (has(arg)) return "plan";
126
+ return "unknown";
127
+ }
128
+
89
129
  export async function cmdMem(a: string[], memDir?: string): Promise<void> {
90
130
  const [cmd, ...rest] = a;
91
131
 
@@ -128,8 +168,27 @@ export async function cmdMem(a: string[], memDir?: string): Promise<void> {
128
168
  cmdPlanCheck(rest);
129
169
  } else if (cmd === "rotate") {
130
170
  cmdRotate(rest);
131
- } else {
171
+ } else if (cmd === undefined) {
132
172
  // bare `fapony mem` → kickoff (ranked session overview)
133
173
  cmdKickoff(rest);
174
+ } else if (classifyMemArg(cmd) === "plan") {
175
+ // `fapony mem PLAN-x.md` — the plan arg rides along (the old else-branch
176
+ // dropped it and printed the no-arg overview instead).
177
+ cmdKickoff([cmd, ...rest]);
178
+ } else {
179
+ console.error(`fapony mem: unknown subcommand "${cmd}"`);
180
+ const scored = MEM_SUBCOMMANDS.map((s) => ({
181
+ s,
182
+ d: levenshtein(cmd, s),
183
+ }))
184
+ .filter((x) => x.d <= 2)
185
+ .sort((x, y) => x.d - y.d || (x.s < y.s ? -1 : 1))
186
+ .slice(0, 3)
187
+ .map((x) => `"fapony mem ${x.s}"`);
188
+ if (scored.length > 0) {
189
+ console.error(`did you mean ${scored.join(" or ")}?`);
190
+ }
191
+ console.error(`subcommands: ${MEM_SUBCOMMANDS.join(" ")}`);
192
+ process.exit(1);
134
193
  }
135
194
  }
package/src/mem/store.ts CHANGED
@@ -15,7 +15,11 @@ import {
15
15
  } from "node:fs";
16
16
  import { hostname, networkInterfaces } from "node:os";
17
17
  import { basename, dirname, join, relative } from "node:path";
18
- import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "../db/index.js";
18
+ import {
19
+ CONFIG_FILENAME,
20
+ DEFAULT_MEM_DIR,
21
+ FAPONY_DIR,
22
+ } from "../core/config.js";
19
23
  import { whereMemDir } from "../memory.js";
20
24
 
21
25
  // --- types ---