fapony 0.1.1 → 0.1.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 (44) hide show
  1. package/README.md +22 -36
  2. package/images/logo.png +0 -0
  3. package/images/logo.webp +0 -0
  4. package/images/logo@400.webp +0 -0
  5. package/images/sample.webp +0 -0
  6. package/images/summary.webp +0 -0
  7. package/package.json +10 -8
  8. package/skill/plan-with-pony/SKILL.md +1 -1
  9. package/src/analyze.ts +9 -8
  10. package/src/conventions-seed.ts +10 -10
  11. package/src/db/index.ts +1 -1
  12. package/src/db/store.ts +4 -0
  13. package/src/debt.ts +26 -22
  14. package/src/digest/collect.ts +7 -7
  15. package/src/digest/html.ts +2 -2
  16. package/src/digest/text.ts +1 -1
  17. package/src/gate.ts +3 -3
  18. package/src/hook.ts +119 -21
  19. package/src/init-mem.ts +3 -3
  20. package/src/install/opencode.ts +95 -8
  21. package/src/install/types.ts +1 -1
  22. package/src/install.ts +5 -1
  23. package/src/lint-baseline.ts +9 -9
  24. package/src/mcp/tools/mem.ts +1 -1
  25. package/src/mcp/tools/stats.ts +2 -2
  26. package/src/mcp/tools/usage.ts +3 -3
  27. package/src/mcp/tools/verdict.ts +29 -16
  28. package/src/mcp/transport.ts +1 -1
  29. package/src/plan-seed.ts +23 -21
  30. package/src/price/fetch.ts +19 -19
  31. package/src/price/resolve.ts +24 -24
  32. package/src/review-seed.ts +1 -1
  33. package/src/stats/data.ts +8 -7
  34. package/src/stats/format.ts +6 -5
  35. package/src/usage/render.ts +6 -5
  36. package/templates/PLAN.md +1 -1
  37. package/templates/mem/commands/plan.ts +44 -44
  38. package/templates/mem/commands/read.ts +5 -5
  39. package/templates/mem/commands/rotate.ts +6 -6
  40. package/templates/mem/commands/selftest.ts +14 -14
  41. package/templates/mem/commands/write.ts +13 -13
  42. package/templates/mem/mem.ts +5 -5
  43. package/templates/mem/selectors.ts +18 -18
  44. package/templates/mem/store.ts +50 -50
@@ -1,9 +1,9 @@
1
- // commands/plan.ts — plan-sweep: หา PLAN-*.md ที่ header บอก shipped แล้วแต่ยังไม่ย้ายเข้า done/
2
- // เหตุผล: ย้ายมือ = ต้องไล่แก้ relative link เอง (ในไฟล์ + ไฟล์อื่นที่ลิงก์มา) → ข้ามขั้นตอนบ่อย
3
- // ไม่มี arg = report เฉยๆ (ปลอดภัย โชว์ทุก kickoff/stale run ได้)
4
- // <file.md> = เช็คไฟล์เดียวว่าพร้อมย้ายไหม
5
- // <file.md> --apply = git mv + แก้ markdown link ในไฟล์เอง + แก้ inbound link จากไฟล์อื่นใน plan/
6
- // + แจ้ง warning plain-text mention (detect-only, ไม่ auto-fix)
1
+ // commands/plan.ts — plan-sweep: find PLAN-*.md whose header says shipped but not yet moved into done/
2
+ // rationale: moving by hand = chasing relative links yourself (in the file + files that link to it) → the step gets skipped often
3
+ // no arg = report only (safe, shows every kickoff/stale run)
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)
7
7
 
8
8
  import {
9
9
  existsSync,
@@ -21,13 +21,13 @@ const SHIPPED = /^>\s*✅/m;
21
21
  const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
22
22
  const HELD = /^status:\s*(blocked|superseded)\b/m;
23
23
 
24
- // header ✅ shipped ไม่ได้อยู่บรรทัดแรกอีกแล้ว — plan format ปัจจุบันขึ้นต้นด้วย frontmatter
25
- // แล้วตามด้วย `# title` (ดู templates/PLAN.md) เช็กหัวไฟล์แทนที่จะเช็กบรรทัดแรกบรรทัดเดียว
24
+ // the ✅ shipped header is no longer on the first line — the current plan format starts with frontmatter
25
+ // then `# title` (see templates/PLAN.md); check the file's head rather than a single first line
26
26
  //
27
- // frontmatter ชนะ header ✅ เสมอ: plan ที่ ship ไปบาง chunk แล้วติดรอของข้างนอก (VPS, คนใช้,
28
- // การตัดสินใจ) เขียน `status: blocked` ไว้ = ตั้งใจให้อยู่ใน plan/ ต่อ ไม่ใช่ของที่ลืมย้าย
29
- // ถ้าไม่ดูตรงนี้ plan แบบนั้นจะขึ้น "shipped but never archived" ทุกครั้งไปตลอด แล้วคนก็เลิกอ่าน
30
- // รายการนี้ทั้งรายการ — ซึ่งเป็นอาการเดียวกับที่ทำให้ done/ ไม่เคยขยับตั้งแต่แรก
27
+ // frontmatter always beats the ✅ header: a plan that shipped some chunks and is waiting on externals (VPS, users,
28
+ // a decision) writes `status: blocked` = it is meant to stay in plan/, not forgotten to move
29
+ // if this is not checked, such plans show "shipped but never archived" forever, and people stop reading
30
+ // the whole list — the same symptom that kept done/ from ever moving in the first place
31
31
  export const hasShippedHeader = (file: string): boolean => {
32
32
  const head = readFileSync(file, "utf8").slice(0, 2048);
33
33
  if (!SHIPPED.test(head)) return false;
@@ -49,10 +49,10 @@ const mdFiles = (dir: string): string[] =>
49
49
 
50
50
  const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
51
 
52
- // ไฟล์ย้าย dir แล้ว (เนื้อหาเดิม) — markdown link เดิมหมายถึง path เดิมเมื่ออิง oldDir, ต้อง re-relativize ผ่าน newDir
53
- // resolve จาก newDir (ที่ไฟล์อยู่ตอนนี้) — ถ้า target ก็ย้ายมา same dir → ได้ชื่อไฟล์ล้วน,
54
- // ถ้า target ยังอยู่ oldDir → ได้ ../target (ถูกทั้งสองทาง)
55
- // pass 1 only — แก้เฉพาะ [text](target) markdown links, ไม่แตะ plain text
52
+ // the file moved dir (same content) — an old markdown link meant the old path relative to oldDir, must re-relativize via newDir
53
+ // resolve from newDir (where the file is now) — if the target also moved to the same dir → plain filename,
54
+ // if the target stayed in oldDir → ../target (both correct)
55
+ // pass 1 only — fix only [text](target) markdown links, do not touch plain text
56
56
  export const rewriteMovedFileLinks = (
57
57
  file: string,
58
58
  oldDir: string,
@@ -76,8 +76,8 @@ export const rewriteMovedFileLinks = (
76
76
  return n;
77
77
  };
78
78
 
79
- // ไฟล์อื่นที่ลิงก์ชี้มาที่ path เดิม (oldAbs) → แก้ให้ชี้ path ใหม่ (newAbs) แทน
80
- // pass 1 only — แก้เฉพาะ [text](target) markdown links, ไม่แตะ plain text
79
+ // other files whose links point at the old path (oldAbs) → repoint them at the new path (newAbs)
80
+ // pass 1 only — fix only [text](target) markdown links, do not touch plain text
81
81
  export const rewriteMarkdownLinks = (
82
82
  file: string,
83
83
  oldAbs: string,
@@ -99,18 +99,18 @@ export const rewriteMarkdownLinks = (
99
99
  return n;
100
100
  };
101
101
 
102
- // นับ plain-text mention ของ target filename ในไฟล์ (detect-only, ไม่เขียนไฟล์)
103
- // regex: ไม่ใช่ markdown link [text](url) — จับทั้งแบบมี/ไม่มี .md extension
104
- // ครอบคลุม: prose, backtick code span, code fence — ทุก context ที่ไม่ใช่ markdown link
102
+ // count plain-text mentions of the target filename in a file (detect-only, writes nothing)
103
+ // regex: not a markdown link [text](url) — catches both with/without the .md extension
104
+ // covers: prose, backtick code span, code fence — every context that is not a markdown link
105
105
  //
106
- // Fix (2026-09-02): ตัด markdown link ทั้งก้อน [text](target) ออกก่อน (ทั้ง display text และ
107
- // target — ไม่ใช่แค่ target) แล้วค่อยรัน plain-text regex บน string ที่เหลือ โดยไม่ต้องพึ่ง
108
- // lookbehind แล้ว — lookbehind เดิม `(?<![/\[(])` ตั้งใจกัน false-positive จาก markdown link
109
- // แต่ผลข้างเคียงคือ exclude ทุก mention ที่มี `/` นำหน้าไปด้วย (เช่น `done/PLAN-x.md` หรือ
110
- // `apps/vela/plan/PLAN-x.md`) — false-negative ตัวจริงที่พลาดใน 6e411042
111
- // Fix รอบแรก (ตัดแค่ `](target)`) พลาด — เหลือ `[display-text]` ไว้ไม่ได้ตัด ทำให้ link ที่ถูกต้อง
112
- // อยู่แล้วอย่าง `[PLAN-x.md](../done/PLAN-x.md)` (pattern จริงในไฟล์นี้ทั้งหมด) โดน count ซ้ำเป็น
113
- // plain-text mention — ต้องตัดทั้งก้อน [..](..) ไม่ใช่แค่ส่วน (..)
106
+ // Fix (2026-09-02): strip the whole markdown link [text](target) first (both display text and
107
+ // target — not just target), then run the plain-text regex on the remainder, no longer needing
108
+ // lookbehind — the old lookbehind `(?<![/\[(])` was meant to stop markdown-link false-positives
109
+ // but its side effect was excluding every mention with a leading `/` too (e.g. `done/PLAN-x.md` or
110
+ // `apps/vela/plan/PLAN-x.md`) — the real false-negative that slipped through in 6e411042
111
+ // The first fix (stripping only `](target)`) missed — it left `[display-text]` unwrapped, making an already
112
+ // valid link like `[PLAN-x.md](../done/PLAN-x.md)` (the actual pattern throughout this file) get double-counted as a
113
+ // plain-text mention — strip the whole [..](..) block, not just the (..) part
114
114
  export const countPlainTextMentions = (
115
115
  file: string,
116
116
  target: string,
@@ -128,7 +128,7 @@ export const countPlainTextMentions = (
128
128
  return count;
129
129
  };
130
130
 
131
- // ใช้ร่วมกับ dashboard (now/kickoff) — ไฟล์ plan/ ที่มี header shipped แต่ยังไม่ย้ายเข้า done/
131
+ // shared with the dashboard (now/kickoff) — plan/ files with a shipped header but not yet moved into done/
132
132
  export const shippedNotMoved = (): string[] => {
133
133
  const dir = planDir;
134
134
  if (!existsSync(dir)) return [];
@@ -187,8 +187,8 @@ export const cmdPlanSweep = (a: string[]) => {
187
187
  return;
188
188
  }
189
189
 
190
- // ponytail: --apply เดิม (ก่อนหน้านี้) ไม่บังคับ 2 เงื่อนไขนี้เลย — ผ่าน dry-run message ได้ก็จริง
191
- // แต่รัน --apply ตรง ๆ ข้ามได้หมด → เสี่ยงเวลา agent ship เองอัตโนมัติไม่มีคนเช็ค แก้เป็น hard block
190
+ // ponytail: the old --apply enforced neither of these two conditions — you could pass the dry-run message
191
+ // but then run --apply directly and skip everything → risky when an agent ships automatically with no human check, so hard block
192
192
  if (!shipped && !process.env.MEM_FORCE) {
193
193
  console.error(
194
194
  `${target}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
@@ -211,8 +211,8 @@ export const cmdPlanSweep = (a: string[]) => {
211
211
  process.exit(1);
212
212
  }
213
213
 
214
- // ponytail: ไฟล์ที่เพิ่งเขียนในรอบนี้อาจยังไม่ git add — `git mv` fail แบบเงียบ (exit 128, ไม่ throw)
215
- // แล้วโค้ดต่อไปพัง ENOENT ตอนอ่าน dst ที่ไม่มีจริง — stage ก่อนเสมอ (no-op ถ้า track อยู่แล้ว)
214
+ // ponytail: a file just written this round may not be git add'ed yet — `git mv` fails silently (exit 128, no throw)
215
+ // then the next code hits ENOENT reading a dst that does not exist — always stage first (no-op if already tracked)
216
216
  mkdirSync(doneDir, { recursive: true });
217
217
  Bun.spawnSync(["git", "add", src]);
218
218
  const mv = Bun.spawnSync(["git", "mv", src, dst]);
@@ -221,8 +221,8 @@ export const cmdPlanSweep = (a: string[]) => {
221
221
  process.exit(1);
222
222
  }
223
223
 
224
- // done/ เป็นพี่น้องกับ plan/ = ลึกเท่าเดิม ลิงก์ในไฟล์ยังชี้ถูกทุกเส้น ไม่ต้องแตะ
225
- // layout เก่า (plan/done/) ลึกขึ้น 1 ชั้น ถึงจะต้อง re-relativize
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
226
  const nested = dirname(doneDir) !== dirname(dir);
227
227
  const ownLinks = nested ? rewriteMovedFileLinks(dst, dir, doneDir) : 0;
228
228
 
@@ -247,7 +247,7 @@ export const cmdPlanSweep = (a: string[]) => {
247
247
  `inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(dir)}/** only)`,
248
248
  );
249
249
 
250
- // log decision — record ship event (reuse existing kind, ไม่ต้อง schema ใหม่)
250
+ // log decision — record ship event (reuse existing kind, no new schema)
251
251
  const doneSpec = `${rel(dst)}`;
252
252
  put({
253
253
  id: nextId(rows()),
@@ -255,11 +255,11 @@ export const cmdPlanSweep = (a: string[]) => {
255
255
  text: `${target} shipped → ${rel(dst)}`,
256
256
  spec: doneSpec,
257
257
  });
258
- // ponytail: decision นี้ *คือ* การย้ายเอง ไม่มีอะไรต้องเขียนกลับเข้า spec อีก — ไม่ mark synced
259
- // ทันที staleReport จะขึ้น "decision ยังไม่เข้า spec" ทุกครั้งที่ ship (เห็นจริงใน kickoff 2026-09-02)
258
+ // ponytail: this decision *is* the move itself, nothing to write back into the spec — do not mark synced
259
+ // immediately or staleReport shows "decision never made it into the spec" on every ship (seen in kickoff 2026-09-02)
260
260
  put({ kind: "synced", spec: doneSpec });
261
261
 
262
- // plain-text mention detection (detect-only, ไม่ auto-fix)
262
+ // plain-text mention detection (detect-only, no auto-fix)
263
263
  let plainTextTotal = 0;
264
264
  const plainTextFiles: string[] = [];
265
265
  for (const f of mdFiles(dir)) {
@@ -275,14 +275,14 @@ export const cmdPlanSweep = (a: string[]) => {
275
275
  );
276
276
  }
277
277
 
278
- // ไฟล์นอก plan/ ที่ mention target — detect-only
278
+ // files outside plan/ that mention the target — detect-only
279
279
  const grep = Bun.spawnSync([
280
280
  "git",
281
281
  "grep",
282
282
  "-l",
283
283
  target,
284
284
  "--",
285
- // ขอบเขต "ไฟล์นอก plan/" = โฟลเดอร์ที่ plan/ อยู่ใต้มัน (apps/vela, .fapony, …)
285
+ // the "files outside plan/" scope = the folder plan/ lives under (apps/vela, .fapony, …)
286
286
  rel(dirname(planDir)),
287
287
  `:!${rel(dir)}`,
288
288
  ])
@@ -324,8 +324,8 @@ export const cmdPlanCheck = (a: string[]) => {
324
324
  // only check active files (not done/) — done/ files are historical snapshots with external refs
325
325
  const linkRe = /\]\(([^)]+)\)/g;
326
326
  for (const f of active) {
327
- // ตัด fenced block + inline code ทิ้งก่อน (แทนที่ด้วย spaces เพื่อคง line offset) —
328
- // ตัวอย่าง link ใน code (เช่น spec ของเครื่องมือนี้เอง) ต้องไม่ถูกนับเป็น link จริง
327
+ // strip fenced blocks + inline code first (replace with spaces to preserve line offset) —
328
+ // example links in code (e.g. this tool's own spec) must not be counted as real links
329
329
  const src = readFileSync(f, "utf8")
330
330
  .replace(/```[\s\S]*?```/g, (b) => b.replace(/[^\n]/g, " "))
331
331
  .replace(/`[^`\n]*`/g, (b) => " ".repeat(b.length));
@@ -22,7 +22,7 @@ const planSweepLine = () => {
22
22
  };
23
23
 
24
24
  export const cmdNow = () => {
25
- // mem now (default) — next+bug+hold. decision/note ไม่ใช่งานค้าง → ค้นด้วย find แทน
25
+ // mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
26
26
  const all = rows();
27
27
  console.log(`# ${app} — ${all.length} entries`);
28
28
  printOpenRows(all, { showHold: true });
@@ -53,7 +53,7 @@ export const cmdStale = () => {
53
53
  };
54
54
 
55
55
  export const cmdFind = (a: string[]) => {
56
- // mem find <คำ> — grep text/spec ไม่สนตัวพิมพ์เล็กใหญ่, ล่าสุดก่อน, จำกัด 20 แถว
56
+ // mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
57
57
  const q = a.join(" ").toLowerCase();
58
58
  if (!q) {
59
59
  console.error(`usage: ${memCmd} find <word>`);
@@ -86,7 +86,7 @@ export const cmdKickoff = (a: string[]) => {
86
86
  const arg = a[0] ?? "";
87
87
 
88
88
  if (!arg) {
89
- // ไม่มี args = now + section "ล่าสุด" = closes 10 รายการล่าสุด
89
+ // no args = now + a "recent" section = the last 10 closes
90
90
  console.log(`# ${app} — ${all.length} entries`);
91
91
  printOpenRows(all, { showHold: true });
92
92
  console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
@@ -101,7 +101,7 @@ export const cmdKickoff = (a: string[]) => {
101
101
  const rotate = rotateLine(all.length);
102
102
  if (rotate) console.log(rotate);
103
103
  } else if (arg.endsWith(".md")) {
104
- // spec.md = brief ของ spec นั้น
104
+ // spec.md = a brief for that spec
105
105
  const workAll = all.filter((r): r is WorkRow => "id" in r);
106
106
  const byId = new Map(workAll.map((r) => [r.id, r] as const));
107
107
  const open = openRows(all);
@@ -135,7 +135,7 @@ export const cmdKickoff = (a: string[]) => {
135
135
  console.log("(no entries for this spec)");
136
136
  }
137
137
  } else {
138
- // id = brief ของงานนั้น
138
+ // id = a brief for that task
139
139
  const workAll = all.filter((r): r is WorkRow => "id" in r);
140
140
  const byId = new Map(workAll.map((r) => [r.id, r] as const));
141
141
  const target = byId.get(arg);
@@ -1,15 +1,15 @@
1
1
  // commands/rotate.ts — compact log.jsonl once it grows past a row-count threshold
2
- // เก็บ open work rows + active claim ไว้ ที่เหลือ (close/release/synced ของ ref ที่ปิดแล้ว)
3
- // git mv ไปไฟล์ archive แยก (ไม่ลบ) — ประวัติเก่ายังอยู่ ค้นย้อนหลังได้ผ่าน git log/git show
2
+ // keep open work rows + active claims; the rest (close/release/synced of already-closed refs)
3
+ // is git mv'd to a separate archive file (not deleted) — old history remains, searchable via git log/git show
4
4
 
5
5
  import { existsSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { rotateKeep } from "../selectors.js";
8
8
  import { appendRaw, dir, LOG, rows } from "../store.js";
9
9
 
10
- // ponytail: threshold = จำนวนแถวทั้งหมด ไม่ใช่แค่ open — ที่กลัวคือไฟล์บวม/grep ช้าตอนหลายคนใช้พร้อมกัน
11
- // (view อ่านไม่รู้เรื่องเป็นปัญหาคนละอันที่ CAP ใน write.ts จัดการอยู่แล้ว)
12
- // 3000 กะจาก solo 1 สัปดาห์ = ~2k แถว — ปรับได้ด้วย MEM_ROTATE_THRESHOLD ถ้า pace ต่างจากนี้มาก
10
+ // ponytail: threshold = total row count, not just open — the fear is a bloated file / slow grep when many people use it at once
11
+ // (the view being unreadable is a separate problem the CAP in write.ts already handles)
12
+ // 3000 estimated from one solo week = ~2k rows — tune with MEM_ROTATE_THRESHOLD if the pace differs a lot from this
13
13
  export const THRESHOLD = Number(process.env.MEM_ROTATE_THRESHOLD) || 3000;
14
14
 
15
15
  export const cmdRotate = (a: string[]) => {
@@ -40,7 +40,7 @@ export const cmdRotate = (a: string[]) => {
40
40
  process.exit(1);
41
41
  }
42
42
 
43
- // เหมือน plan-sweep: stage ก่อนกัน git mv fail เงียบถ้าไฟล์ยังไม่ track (exit 128)
43
+ // like plan-sweep: stage first to stop git mv failing silently if the file is untracked (exit 128)
44
44
  Bun.spawnSync(["git", "add", LOG]);
45
45
  const mv = Bun.spawnSync(["git", "mv", LOG, archived]);
46
46
  if (mv.exitCode !== 0) {
@@ -37,7 +37,7 @@ const runSelectorTests = () => {
37
37
  { id: "a2", kind: "next", text: "work B", ts: "", agent: "" },
38
38
  { kind: "close", ref: "a2", text: "done", ts: "", agent: "" },
39
39
  { kind: "claim", ref: "a1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
40
- // (1) claim เดียว → active
40
+ // (1) single claim → active
41
41
  { id: "a3", kind: "bug", text: "work C", ts: "", agent: "" },
42
42
  { kind: "claim", ref: "a3", ts: "2026-01-01T01:00:00Z", agent: "agent-1" },
43
43
  {
@@ -55,7 +55,7 @@ const runSelectorTests = () => {
55
55
  { id: "a5", kind: "next", text: "work E", ts: "", agent: "" },
56
56
  { kind: "claim", ref: "a5", ts: "2026-01-01T04:00:00Z", agent: "agent-1" },
57
57
  { kind: "claim", ref: "a5", ts: "2026-01-01T05:00:00Z", agent: "agent-3" },
58
- // (4) double claim → หลังชนะ (agent-3)
58
+ // (4) double claim → the later one wins (agent-3)
59
59
  ];
60
60
 
61
61
  const claims = claimsOf(t);
@@ -70,12 +70,12 @@ const runSelectorTests = () => {
70
70
  assert(!claims.has("a3"), "release did not void the claim");
71
71
  // (3) a4 close → inactive
72
72
  assert(!claims.has("a4"), "close void claim");
73
- // (4) a5 double claim → agent-3 ชนะ
73
+ // (4) a5 double claim → agent-3 wins
74
74
  assert(
75
75
  claims.has("a5") && claims.get("a5")?.agent === "agent-3",
76
76
  "double claim after the winner",
77
77
  );
78
- // a2 closed → ไม่ควรอยู่ใน open
78
+ // a2 closed → should not appear in open
79
79
  assert(!open.some((r) => r.id === "a2"), "tombstone broken");
80
80
  // open = a1, a3 (released but still open), a5
81
81
  assert(
@@ -91,12 +91,12 @@ const runRotateTests = () => {
91
91
  const t: LogRow[] = [
92
92
  { id: "r1", kind: "next", text: "keep me", ts: "", agent: "" },
93
93
  { kind: "claim", ref: "r1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
94
- // r1: open + claimed → work row + claim row ต้องเก็บทั้งคู่
94
+ // r1: open + claimed → both the work row and the claim row must be kept
95
95
 
96
96
  { id: "r2", kind: "bug", text: "closed already", ts: "", agent: "" },
97
97
  { kind: "claim", ref: "r2", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
98
98
  { kind: "close", ref: "r2", text: "shipped", ts: "", agent: "" },
99
- // r2: ปิดแล้ว → work row, claim row, close tombstone ทิ้งหมด
99
+ // r2: closed → work row, claim row, close tombstone all dropped
100
100
 
101
101
  {
102
102
  id: "r3",
@@ -112,7 +112,7 @@ const runRotateTests = () => {
112
112
  ts: "2026-01-02T00:00:00Z",
113
113
  agent: "",
114
114
  },
115
- // r3: spec synced *หลัง* decision นี้ → resolved แล้ว archive ได้
115
+ // r3: spec synced *after* this decision → resolved, can be archived
116
116
 
117
117
  {
118
118
  id: "r4",
@@ -122,10 +122,10 @@ const runRotateTests = () => {
122
122
  agent: "",
123
123
  spec: "PLAN-a.md",
124
124
  },
125
- // r4: decision ใหม่กว่า synced ล่าสุด → ยังไม่ resolved เก็บไว้
125
+ // r4: decision newer than the latest synced → not resolved yet, keep it
126
126
 
127
127
  { id: "r5", kind: "note", text: "note no spec", ts: "", agent: "" },
128
- // r5: note ไม่มี spec → ไม่มีทางรู้ resolved หรือยัง เก็บไว้เสมอ
128
+ // r5: a note with no spec → no way to know if resolved, always kept
129
129
  ];
130
130
 
131
131
  const kept = rotateKeep(t);
@@ -167,17 +167,17 @@ const runPlanSweepTests = () => {
167
167
  const tmpDir = mkdtempSync(join(tmpdir(), "mem-test-"));
168
168
  try {
169
169
  // === Case A: rewriteMarkdownLinks + countPlainTextMentions ===
170
- // สร้าง fixture: ไฟล์ที่ reference ไปที่ plan/PLAN-page-style.md หลายแบบ
170
+ // build fixture: a file that references plan/PLAN-page-style.md several ways
171
171
  const planDir = join(tmpDir, "plan");
172
172
  const doneDir = join(tmpDir, "plan", "done");
173
173
  mkdirSync(planDir, { recursive: true });
174
174
  mkdirSync(doneDir, { recursive: true });
175
175
 
176
- // "old" file — จำลอง apps/vela/plan/PLAN-page-style.md (touch only)
176
+ // "old" file — simulates apps/vela/plan/PLAN-page-style.md (touch only)
177
177
  const oldFile = join(planDir, "PLAN-page-style.md");
178
178
  writeFileSync(oldFile, "# PLAN-page-style\n");
179
179
 
180
- // inbound file — จำลอง apps/vela/plan/PLAN-people-style.md
180
+ // inbound file — simulates apps/vela/plan/PLAN-people-style.md
181
181
  const inbound = [
182
182
  "# PLAN-people-style — refs",
183
183
  "",
@@ -361,13 +361,13 @@ const runPlanCheckTests = () => {
361
361
  );
362
362
  writeFileSync(join(planDir, "PLAN-active.md"), "# active plan\n");
363
363
 
364
- // plan ที่เขียนด้วย format ปัจจุบัน: frontmatter + title มาก่อน header ✅ shipped
364
+ // a plan in the current format: frontmatter + title come before the ✅ shipped header
365
365
  writeFileSync(
366
366
  join(planDir, "PLAN-frontmatter.md"),
367
367
  "---\nkind: unit\n---\n\n# shipped with frontmatter\n\n> ✅ **shipped 2026-09-13** (abc1234)\n",
368
368
  );
369
369
 
370
- // ship ไปบาง chunk แล้วติดรอของข้างนอก — frontmatter บอกว่าตั้งใจให้อยู่ต่อ ห้ามนับว่าลืมย้าย
370
+ // shipped some chunks and waiting on externals — frontmatter says it is meant to stay, do not count as forgotten
371
371
  writeFileSync(
372
372
  join(planDir, "PLAN-blocked.md"),
373
373
  "---\nstatus: blocked\nblocked_by: VPS#2\n---\n\n# partly shipped\n\n> ✅ **chunk 1 shipped 2026-09-13** (abc1234)\n",
@@ -7,17 +7,17 @@ import { KINDS, memCmd, nextId, put, root, rows } from "../store.js";
7
7
  export const cmdAdd = async (a: string[]) => {
8
8
  // mem add <next|bug|decision|note|hold> "<text>" --files f1,f2 [path/to/SPEC.md]
9
9
  // mem add <kind> --stdin --files f1,f2 [spec.md] ← read text from stdin (avoids shell metachar issues)
10
- // ponytail: kind ผิด = แถวนั้นหายจาก view เงียบๆ — ตายตั้งแต่ตรงนี้ดีกว่า
10
+ // ponytail: a wrong kind = that row silently vanishes from the view — better to die right here
11
11
  if (!KINDS.includes(a[0] as WorkKind)) {
12
12
  console.error(
13
13
  `kind must be one of ${KINDS.join("|")} — got "${a[0] ?? ""}"`,
14
14
  );
15
15
  process.exit(1);
16
16
  }
17
- // hold บังคับ spec — ตรวจหลัง parse (spec อยู่ก่อน --files ได้)
18
- // PLAN-convention-debt chunk 3: files[] เป็น required — optional field ที่วัดแล้ว
19
- // fill rate = 0 (required+enum = 50/50) และ recall ของ log เก่าที่ไม่มี files[] จับได้แค่
20
- // 48.1% — แก้ที่ขาเขียน ไม่ใช่ขาอ่าน: แถวไหนไม่บอกไฟล์ = ค้นไม่เจอตอนแตะไฟล์นั้น
17
+ // hold requires a spec — checked after parse (the spec may come before --files)
18
+ // PLAN-convention-debt chunk 3: files[] is required — a measured optional field
19
+ // had fill rate 0 (required+enum = 50/50) and recall on old logs without files[] caught only
20
+ // 48.1% — fix the write side, not the read side: a row that does not name the file = unfindable when you touch that file
21
21
  const filesFlagIdx = a.indexOf("--files");
22
22
  const filesVal = filesFlagIdx >= 0 ? a[filesFlagIdx + 1] : undefined;
23
23
  if (!filesVal || filesVal.startsWith("--")) {
@@ -56,7 +56,7 @@ export const cmdAdd = async (a: string[]) => {
56
56
  process.exit(1);
57
57
  }
58
58
  } else {
59
- // ต้องมีข้อความ
59
+ // text is required
60
60
  if (!filtered.length || (filtered.length === 0 && !spec)) {
61
61
  console.error(
62
62
  `text is required — usage: ${memCmd} add <kind> "<text>" --files f1,f2 [spec.md]`,
@@ -67,7 +67,7 @@ export const cmdAdd = async (a: string[]) => {
67
67
  }
68
68
  // read once — cap check + id collision
69
69
  const all = rows();
70
- // ponytail: เพดาน open next/hold กันสะสมไม่มีที่สิ้นสุด — บังคับ triage ของเก่าก่อนเปิดใหม่
70
+ // ponytail: a cap on open next/hold stops endless accumulation — forces triage of the old before opening new
71
71
  const CAP = 15;
72
72
  const CAP_HOLD = 10;
73
73
  if (a[0] === "next" && !process.env.MEM_FORCE) {
@@ -88,14 +88,14 @@ export const cmdAdd = async (a: string[]) => {
88
88
  process.exit(1);
89
89
  }
90
90
  }
91
- // ponytail: กัน id ชน — logic รวมไว้ที่ nextId (store.ts)
91
+ // ponytail: prevent id collisions — logic centralized in nextId (store.ts)
92
92
  const id = nextId(all);
93
93
  put({ id, kind: a[0] as WorkKind, text, spec, files });
94
94
  console.log(id);
95
95
  };
96
96
 
97
97
  export const cmdClose = async (a: string[]) => {
98
- // mem close <id> "<ทำอะไร / commit>" — tombstone ทำให้ claim void เอง
98
+ // mem close <id> "<what was done / commit>" — the tombstone voids the claim by itself
99
99
  // mem close <id> --stdin ← read text from stdin
100
100
  if (!a[0]) {
101
101
  console.error(`id is required — usage: ${memCmd} close <id> "<text>"`);
@@ -117,7 +117,7 @@ export const cmdClose = async (a: string[]) => {
117
117
  };
118
118
 
119
119
  export const cmdClaim = (a: string[]) => {
120
- // mem claim <id> — ต้องมี id จริง, open, kind=next|bug, ไม่มี active claim ค้าง
120
+ // mem claim <id> — id must be real, open, kind=next|bug, with no active claim pending
121
121
  if (!a[0]) {
122
122
  console.error(`id is required — usage: ${memCmd} claim <id>`);
123
123
  process.exit(1);
@@ -153,7 +153,7 @@ export const cmdClaim = (a: string[]) => {
153
153
  };
154
154
 
155
155
  export const cmdRelease = async (a: string[]) => {
156
- // mem release <id> "<เหตุผล>?"
156
+ // mem release <id> "<reason>?"
157
157
  // mem release <id> --stdin ← read text from stdin
158
158
  if (!a[0]) {
159
159
  console.error(`id is required — usage: ${memCmd} release <id> [reason]`);
@@ -179,7 +179,7 @@ export const cmdRelease = async (a: string[]) => {
179
179
  };
180
180
 
181
181
  export const cmdSynced = (a: string[]) => {
182
- // mem synced [path.md ...] — ประกาศว่า spec ตรงกับ log แล้ว (ไม่ระบุ = ทุก spec ที่ log อ้างถึง)
182
+ // mem synced [path.md ...] — declare the spec now matches the log (none given = every spec the log mentions)
183
183
  const specs = a.length
184
184
  ? a
185
185
  : [
@@ -195,7 +195,7 @@ export const cmdSynced = (a: string[]) => {
195
195
  };
196
196
 
197
197
  export const cmdHook = async () => {
198
- // PostToolUse: stdin = {tool_input:{file_path}} → แก้ spec ของ app นี้เสร็จ = synced ให้เอง
198
+ // PostToolUse: stdin = {tool_input:{file_path}} → editing this app's spec is done = mark synced automatically
199
199
  const j = (await Bun.stdin.json().catch(() => null)) as Record<
200
200
  string,
201
201
  unknown
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- // append-only memory log. ห้ามแก้บรรทัดเก่า — ปิดงาน = close (tombstone), git = ประวัติ
4
- // log อยู่ข้างไฟล์นี้เสมอเมื่อถูกวางโดย `fapony init` (<project>/.fapony/.memory/log.jsonl);
5
- // สำเนากลางใน monorepo (โค้ดชุดเดียวที่ root) ยังแยก log ราย app: apps/<app>/.fapony/.memory/log.jsonl
6
- // (repo ที่ยังไม่ย้ายซึ่งมี log เก่าที่ apps/<app>/.memory/log.jsonl จะอ่านที่เดิมต่อ)
3
+ // append-only memory log. Never edit old lines — close a task = close (tombstone), git = history
4
+ // the log always sits beside this file when placed by `fapony init` (<project>/.fapony/.memory/log.jsonl);
5
+ // the central copy in a monorepo (single code copy at the root) still splits logs per app: apps/<app>/.fapony/.memory/log.jsonl
6
+ // (a repo not yet migrated with an old log at apps/<app>/.memory/log.jsonl keeps reading that location)
7
7
  //
8
8
  // CLI entry point — all logic lives in:
9
9
  // store.ts (types + config + rows/put)
@@ -63,6 +63,6 @@ if (cmd === "add") {
63
63
  } else if (cmd === "rotate") {
64
64
  cmdRotate(a);
65
65
  } else {
66
- // mem now (default) — next+bug+hold. decision/note ไม่ใช่งานค้าง → ค้นด้วย find แทน
66
+ // mem now (default) — next+bug+hold. decision/note is not pending work → search with find instead
67
67
  cmdNow();
68
68
  }
@@ -8,8 +8,8 @@ import type {
8
8
  WorkRow,
9
9
  } from "./store.js";
10
10
 
11
- // ponytail: reducer = filter + tombstone set. 50k บรรทัด = 9.6MB/26ms — ไฟล์ไม่ใช่คอขวด
12
- // เพดานจริง = แถวที่ยังเปิดเยอะจน view อ่านไม่รู้เรื่อง → ตอนนั้นค่อย rotate: git mv + append openRows(เก่า) ลงไฟล์ใหม่
11
+ // ponytail: reducer = filter + tombstone set. 50k lines = 9.6MB/26ms — the file is not the bottleneck
12
+ // the real ceiling = too many open rows for the view to make sense → rotate then: git mv + append openRows(old) into a new file
13
13
  export const openRows = (all: LogRow[]): WorkRow[] => {
14
14
  const dead = new Set(
15
15
  all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
@@ -25,8 +25,8 @@ export const openRows = (all: LogRow[]): WorkRow[] => {
25
25
  );
26
26
  };
27
27
 
28
- // C) claimsOf: คืน Map ref → latest claim row ที่ยัง active
29
- // active = แถวล่าสุดของ claim|release เป็น claim และ ref ไม่อยู่ใน dead (close)
28
+ // C) claimsOf: returns Map ref → latest claim row still active
29
+ // active = the latest claim|release row is a claim and ref is not in dead (close)
30
30
  export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
31
31
  const dead = new Set(
32
32
  all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
@@ -46,9 +46,9 @@ export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
46
46
  return active;
47
47
  };
48
48
 
49
- // decision ที่ใหม่กว่าทั้ง commit ล่าสุดของ spec และ synced marker = spec ยังไม่ได้อัปเดตตาม
50
- // + next/hold ที่มี spec ถูกแก้หลังสร้าง = อาจปิดไปแล้วแต่ไม่มีใคร close — เคสจริง: msbnndmt
51
- // + active claim ที่อายุเกิน 4 ชม. = agent อาจตายกลางงาน
49
+ // a decision newer than both the spec's latest commit and the synced marker = spec not updated to match
50
+ // + next/hold whose spec was edited after creation = may be done already but nobody closed it — real case: msbnndmt
51
+ // + an active claim older than 4h = the agent may have died mid-task
52
52
  export const staleReport = (all: LogRow[]): string[] => {
53
53
  const out: string[] = [];
54
54
  const mark: Record<string, number> = {};
@@ -67,7 +67,7 @@ export const staleReport = (all: LogRow[]): string[] => {
67
67
  }
68
68
  const gitDates = new Map<string, number>();
69
69
  for (const spec of specPaths) {
70
- // --follow: spec path ที่ถูก git mv (เช่น ย้ายเข้า plan/done/) ยังตามประวัติต่อได้
70
+ // --follow: a spec path that was git mv'd (e.g. moved into plan/done/) can still follow its history
71
71
  const git = Bun.spawnSync([
72
72
  "git",
73
73
  "log",
@@ -94,9 +94,9 @@ export const staleReport = (all: LogRow[]): string[] => {
94
94
  for (const r of openRows(all)) {
95
95
  if ((r.kind === "next" || r.kind === "hold") && r.spec) {
96
96
  const gitDate = gitDates.get(r.spec);
97
- // ponytail: grace 24 ชม. — "log next แล้วเขียน/commit plan ต่อในวันเดียวกัน" คือ workflow ปกติ
98
- // ไม่ใช่สัญญาณว่างานจบแล้วลืมปิด (เคสจริงที่ต้องจับอย่าง msbnndmt ห่างกันเป็นวัน) —
99
- // ไม่มี grace = SUSPECT ขึ้นทุกแผนที่เพิ่งเขียน แล้วทุกคนเรียนรู้ที่จะเลื่อนผ่าน stale ทั้งบล็อก
97
+ // ponytail: 24h grace — "log next then write/commit the plan the same day" is normal workflow
98
+ // not a signal that the work finished and was forgotten (the real case to catch, msbnndmt, is days apart) —
99
+ // without grace = SUSPECT fires on every freshly written plan, and everyone learns to skim past the whole stale block
100
100
  if (gitDate && gitDate > Date.parse(r.ts) + 86_400_000)
101
101
  out.push(
102
102
  `SUSPECT [${r.id}] ${r.spec} changed after this ${r.kind} (${r.ts.slice(0, 10)}) — check whether it is already done`,
@@ -115,13 +115,13 @@ export const staleReport = (all: LogRow[]): string[] => {
115
115
  return out;
116
116
  };
117
117
 
118
- // rotate: แถวที่ต้องแบกต่อในไฟล์ log ใหม่หลัง archive
119
- // - next/bug/hold ที่ยังเปิด + claim ที่ยัง active อยู่บนแถวนั้น (close/claim ของ ref ที่ปิดแล้ว = ทิ้งได้)
120
- // - decision/note ยังไม่มี "close" ของตัวเอง (ประวัติถาวรตามดีไซน์) แต่ resolved ได้ทางอ้อมผ่าน synced:
121
- // spec ถูก synced *หลัง* แถวนี้แล้ว = ข้อมูลเข้า spec แล้วจริง เก็บใน archive (git) พอ ไม่ต้องแบกในไฟล์ hot
122
- // (logic เดียวกับ staleReport's decision check — ไม่มี spec หรือยังไม่ synced ทันแถวนี้ = ยังถือว่า relevant, เก็บไว้)
123
- // ponytail: decision/note ที่ไม่มี spec ไม่มีทางรู้ว่า resolved แล้วหรือยัง — เก็บไว้ตลอด (เพดานจริงคือ
124
- // ต้อง attach spec ตั้งแต่ log ถ้าอยากให้ rotate ออกได้ในอนาคต ไม่ใช่ปัญหาของ rotate เอง)
118
+ // rotate: rows that must carry over into the new log file after archiving
119
+ // - open next/bug/hold + still-active claims on those rows (close/claim of already-closed refs = discardable)
120
+ // - decision/note has no "close" of its own (permanent history by design) but resolves indirectly via synced:
121
+ // spec was synced *after* this row = the info really made it into the spec, archive (git) is enough, no need to carry it in the hot file
122
+ // (same logic as staleReport's decision check — no spec or not yet synced by this row = still considered relevant, keep it)
123
+ // ponytail: a decision/note with no spec gives no way to know if it is resolved — keep it forever (the real ceiling is
124
+ // attaching a spec at log time if you want rotate to be able to drop it later, not rotate's own problem)
125
125
  export const rotateKeep = (all: LogRow[]): LogRow[] => {
126
126
  const open = openRows(all);
127
127
  const workOpen = open.filter(