fapony 0.1.1 → 0.1.2

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.
@@ -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(
@@ -60,43 +60,43 @@ type LogRow = WorkRow | CloseRow | ClaimRow | ReleaseRow | SyncedRow;
60
60
  const root = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"])
61
61
  .stdout.toString()
62
62
  .trim();
63
- // ponytail: worktree ชื่อ wt-<app> = monorepo scope (apps/<app>/.fapony/.memory).
64
- // fapony template: repo เดี่ยว (ไม่มี apps/) → fallback ไป .fapony/.memory ที่ root ตรงๆ
65
- // ไม่ต้อง config/flag ทั้งสองแบบ
63
+ // ponytail: worktree named wt-<app> = monorepo scope (apps/<app>/.fapony/.memory).
64
+ // fapony template: single repo (no apps/) → fallback straight to .fapony/.memory at root
65
+ // no config/flag needed for either shape
66
66
  const app = process.env.MEM_APP ?? basename(root).replace(/^wt-/, "");
67
- // โฟลเดอร์รวม app หาจาก {apps,packages,services}/<app> ตัวแรกที่มีจริงลำดับคงที่
68
- // apps → packages → services ตัวแรกที่เจอชนะ (ลำดับคือสัญญา ไม่ใช่บังเอิญ)
69
- // guard `unknown app` ข้างล่างยังผูกกับ apps/ เหมือนเดิมไม่ขยายในรอบนี้
67
+ // app container folder found from {apps,packages,services}/<app>, first that exists fixed order
68
+ // apps → packages → services, first hit wins (the order is the contract, not an accident)
69
+ // the `unknown app` guard below is still tied to apps/ as before not widened this round
70
70
  const appBase: string | undefined = ["apps", "packages", "services"]
71
71
  .map((d) => `${root}/${d}/${app}`)
72
72
  .find((p) => existsSync(p));
73
73
  const monorepo = appBase !== undefined;
74
74
 
75
- // สำเนาที่ `fapony init` วางไว้ อยู่ใน <project>/.fapony/.memory/ — log กับ plan ของมัน
76
- // ต้องอิงโฟลเดอร์ตัวเอง ไม่ใช่ git root: เคสที่พังจริงคือ apps/<x>/.fapony/.memory/ ใน monorepo
77
- // ซึ่ง heuristic ด้านล่างจะชี้ไป apps/<ชื่อ worktree>/.fapony/.memory = เขียน log ปนโปรเจกต์อื่น
78
- // แต่สำเนากลางที่ย้ายเข้า .fapony/.memory ที่ root ของ monorepo เอง (โค้ดชุดเดียว, log แยกราย
79
- // app — เช่น vela) ต้อง "ไม่" ถือเป็น scaffolded แม้ path จะแมตช์เหมือนกัน เพราะยังต้องเดา app
80
- // จาก monorepo อยู่ ตัวแยกคือ "เดา app ได้ไหม" (`monorepo`) ไม่ใช่ "มีโฟลเดอร์รวม app ไหม":
81
- // แค่มี apps/ อยู่ที่ root ไม่ได้แปลว่าสำเนานี้เป็นสำเนากลาง — `fapony init <monorepo root>` ก็วาง
82
- // .fapony/.memory ที่ root เหมือนกัน แล้วมันต้องอิงโฟลเดอร์ตัวเอง ไม่งั้นตายที่ guard ข้างล่าง
83
- // ตั้งแต่คำสั่งแรกทั้งที่ plan ของมันอยู่ข้าง นั่นเอง
75
+ // The copy `fapony init` places lives in <project>/.fapony/.memory/ — its log and plan
76
+ // must key off their own folder, not the git root: the real broken case is apps/<x>/.fapony/.memory/ in a monorepo,
77
+ // where the heuristic below would point at apps/<worktree name>/.fapony/.memory = writing a log mixed into another project.
78
+ // But the central copy that was moved into .fapony/.memory at the monorepo root itself (single code copy, logs split per
79
+ // app — e.g. vela) must "not" count as scaffolded even though the path matches, because it still has to guess the app
80
+ // from the monorepo — the discriminator is "can the app be guessed" (`monorepo`), not "is there an app container folder":
81
+ // just having apps/ at the root does not make this copy the central one — `fapony init <monorepo root>` also places
82
+ // .fapony/.memory at the root, and it must key off its own folder, or it dies at the guard below
83
+ // from the very first command even though its plan sits right next to it.
84
84
  const centralAtMonorepoRoot =
85
85
  import.meta.dir === `${root}/.fapony/.memory` && monorepo;
86
86
  const scaffolded =
87
87
  import.meta.dir.includes("/.fapony/.memory") && !centralAtMonorepoRoot;
88
88
 
89
- // มี apps/ แต่ไม่มี apps/<app> = เดา app ผิด (worktree ชื่อไม่ตรง / typo ใน MEM_APP) — ตายตรงนี้
90
- // ดีกว่า fallback เงียบ ไปเขียน log ที่ root ซึ่งจะกลายเป็น log กำพร้าที่ไม่มีใครอ่าน
91
- // (สำเนาที่ scaffold ไว้ใต้ apps/<x>/.fapony/.memory/ อิงโฟลเดอร์ตัวเอง ไม่ต้องเดา จึงไม่เข้าเงื่อนไขนี้)
89
+ // apps/ exists but apps/<app> does not = guessed the app wrong (worktree name mismatch / typo in MEM_APP) — die here
90
+ // rather than fall back silently and write a log at the root that becomes an orphan nobody reads
91
+ // (a copy scaffolded under apps/<x>/.fapony/.memory/ keys off its own folder, needs no guess, so it does not hit this)
92
92
  if (!scaffolded && !monorepo && existsSync(`${root}/apps`)) {
93
93
  console.error(
94
94
  `unknown app (guessed "${app}" from ${basename(root)}) — pass MEM_APP=<app>`,
95
95
  );
96
96
  process.exit(1);
97
97
  }
98
- // default ใหม่: log อยู่ใต้ .fapony/.memory — fallback ไป .memory/ เดิมเมื่อมี log เก่าอยู่จริง
99
- // เช็ค log.jsonl ไม่ใช่ dir: โฟลเดอร์ว่างที่ใครเผลอ mkdir ทิ้งไว้ต้องไม่ล็อก repo ไว้กับ layout เก่า
98
+ // new default: log lives under .fapony/.memory — fall back to the old .memory/ only when an old log actually exists
99
+ // check for log.jsonl, not the dir: an empty folder someone accidentally mkdir'd must not lock the repo to the old layout
100
100
  const newDir = appBase
101
101
  ? `${appBase}/.fapony/.memory`
102
102
  : `${root}/.fapony/.memory`;
@@ -106,12 +106,12 @@ const dir = scaffolded
106
106
  : existsSync(`${legacyDir}/log.jsonl`)
107
107
  ? legacyDir
108
108
  : newDir;
109
- // ใครเขียนแถวนี้ — client ตั้ง MEM_AGENT ทับได้ ("claude-code", "opencode")
109
+ // who wrote this row the client can override with MEM_AGENT ("claude-code", "opencode")
110
110
  const agent = process.env.MEM_AGENT || process.env.USER || "unknown";
111
111
 
112
- // ชื่อไฟล์ = *คน* ไม่ใช่ client ตั้งใจให้ต่างจาก agent ข้างบน: ถ้าใช้ MEM_AGENT ตั้งชื่อไฟล์
113
- // สองคนที่เปิด Claude Code จะกลับไปเขียน log.claude-code.jsonl ใบเดียวกัน = ชนเหมือนเดิม
114
- // git user.name มีอยู่แล้วทุกเครื่องที่ commit ได้ จึงไม่ต้องตั้ง env และไม่ต้องเพิ่ม config
112
+ // filename = *person*, not client, deliberately different from agent above: naming files by MEM_AGENT
113
+ // would put two people running Claude Code back into the same log.claude-code.jsonl = same collision as before
114
+ // git user.name exists on every machine that can commit, so no env to set and no config to add
115
115
  const person = (
116
116
  Bun.spawnSync(["git", "config", "user.name"]).stdout.toString().trim() ||
117
117
  process.env.USER ||
@@ -121,12 +121,12 @@ const person = (
121
121
  .replace(/[^a-z0-9._-]+/g, "-")
122
122
  .replace(/^-+|-+$/g, "");
123
123
 
124
- // เขียนไฟล์ของตัวเอง อ่านของทุกคนสองคนไม่เคยแตะไฟล์เดียวกัน = merge conflict
125
- // เป็นศูนย์โดยโครงสร้าง ไม่ต้องพึ่ง merge=union หรือให้ GitHub ทำตัวดีตอน merge PR
124
+ // write your own file, read everyone's two people never touch the same file = merge conflicts
125
+ // are structurally zero, no need for merge=union or GitHub behaving itself on PR merge
126
126
  const LOG = `${dir}/log.${person || "unknown"}.jsonl`;
127
127
 
128
- // log.jsonl = ของเดิมก่อนแยกไฟล์ (ยังอ่านตลอดไป ไม่ต้อง migrate)
129
- // ข้าม log.YYYY-MM-DD.jsonl ที่ rotate สร้าง ไม่งั้น rotate จะไม่ลดอะไรเลยเพราะอ่านกลับเข้ามา
128
+ // log.jsonl = the pre-split original (still read forever, no migration needed)
129
+ // skip the log.YYYY-MM-DD.jsonl that rotate creates, or rotate reduces nothing because they get read back in
130
130
  const isLogFile = (f: string): boolean =>
131
131
  f === "log.jsonl" ||
132
132
  (/^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f) &&
@@ -140,17 +140,17 @@ const logFiles = (): string[] =>
140
140
  .map((f) => join(dir, f))
141
141
  : [];
142
142
 
143
- // โฟลเดอร์ที่ plan/ กับ done/ ของโปรเจกต์นี้อยู่ใต้มันจุดเดียวที่ประกอบ path เหล่านี้
144
- // (ก่อนหน้านี้ commands/plan.ts hardcode `apps/<app>/plan` 10 จุด = ตายสนิทกับ repo เดี่ยว)
143
+ // the folder this project's plan/ and done/ live under the single place these paths are assembled
144
+ // (previously commands/plan.ts hardcoded `apps/<app>/plan` in 10 places = dead on arrival for a single repo)
145
145
  const planBase = scaffolded
146
146
  ? dirname(import.meta.dir) // <project>/.fapony
147
147
  : (appBase ?? root);
148
148
 
149
- // fapony.config.json คือ *ข้อตกลง* ว่า plan อยู่ไหน ส่วน planBase ข้างบนเป็นแค่การเดา
150
- // มีไฟล์เมื่อไหร่ต้องชนะการเดาเสมอ (vela ประกาศ `apps/vela/plan` ไว้ตรง บังเอิญตรงกับที่เดาได้
151
- // แต่ repo ที่วาง plan ไว้ที่อื่นจะพังเงียบ ถ้าไม่อ่าน)
152
- // อ่านที่ระดับโปรเจกต์เท่านั้น: สำเนาที่ scaffold ใน apps/<x>/.fapony/ ต้องไม่หยิบ config ของ
153
- // monorepo ที่ root มาใช้ เพราะนั่นเป็น path ของอีกโปรเจกต์หนึ่ง
149
+ // fapony.config.json is the *agreement* on where plan lives; planBase above is only a guess
150
+ // whenever the file exists it must beat the guess (vela declares `apps/vela/plan` outright, which happens to match the guess
151
+ // but a repo that puts plan elsewhere breaks silently if this is not read)
152
+ // read only at the project level: a copy scaffolded in apps/<x>/.fapony/ must not pick up the root
153
+ // monorepo's config, because that is another project's path
154
154
  const configDir = scaffolded ? dirname(planBase) : root;
155
155
 
156
156
  const configPaths = ((): Record<string, string> => {
@@ -158,7 +158,7 @@ const configPaths = ((): Record<string, string> => {
158
158
  const raw = readFileSync(`${configDir}/fapony.config.json`, "utf8");
159
159
  return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
160
160
  } catch {
161
- // ไม่มีไฟล์ / JSON เสีย ใช้ค่าที่เดาได้ ไม่ใช่ error: memory ต้องทำงานได้โดยไม่มี fapony
161
+ // no file / broken JSON → use the guessed values, not an error: memory must work without fapony
162
162
  return {};
163
163
  }
164
164
  })();
@@ -168,27 +168,27 @@ const fromConfig = (key: string): string | null =>
168
168
  ? join(configDir, configPaths[key])
169
169
  : null;
170
170
 
171
- // app ที่ย้าย plan เข้า .fapony/ แล้วให้ใช้ของใหม่ ที่ยังไม่ย้ายใช้ของเดิมโมโนเรโปจึงย้ายทีละ app ได้
172
- // โดยไม่ต้องแตะ config (config มี planDir ค่าเดียว ประกาศเมื่อไหร่ app อื่นก็ชี้ผิดตามไปด้วย)
171
+ // an app that moved plan into .fapony/ uses the new location; one that has not uses the old so a monorepo migrates app by app
172
+ // without touching config (config has a single planDir, so declaring it points the other apps wrong too)
173
173
  const base = existsSync(`${planBase}/.fapony`)
174
174
  ? `${planBase}/.fapony`
175
175
  : planBase;
176
176
 
177
177
  const planDir = fromConfig("planDir") ?? `${base}/plan`;
178
178
 
179
- // done/ อยู่ข้าง plan/ (ย้ายแล้วลึกเท่าเดิม ลิงก์ relative ในไฟล์รอด) — repo ที่ยัง layout เก่า
180
- // เก็บ plan/done/ ไว้ ก็ใช้ของเดิมต่อ ไม่ต้องย้ายก่อนถึงจะ sweep ได้
179
+ // done/ sits beside plan/ (same depth after the move, relative links in files survive) — a repo still on the old layout
180
+ // with plan/done/ keeps using it, no need to move before sweeping
181
181
  const doneDir =
182
182
  fromConfig("doneDir") ??
183
183
  (!existsSync(`${base}/done`) && existsSync(`${planDir}/done`)
184
184
  ? `${planDir}/done`
185
185
  : `${base}/done`);
186
186
 
187
- // path ที่เอาไว้โชว์/บันทึกลง logอิง repo root เสมอ (`apps/vela/plan`, `.fapony/plan`)
187
+ // path used for display/logging always relative to repo root (`apps/vela/plan`, `.fapony/plan`)
188
188
  const rel = (p: string) => relative(root, p) || ".";
189
189
 
190
- // คำสั่งที่บอกให้ผู้ใช้พิมพ์ ต้องเป็น path ของ mem.ts ตัวที่กำลังรันอยู่จริง ไม่ใช่ค่าคงที่
191
- // สำเนาที่ `fapony init` วางไว้อยู่ที่ .fapony/.memory/ ไม่ใช่ .memory/ ที่ help text เดิม hardcode
190
+ // the command we tell the user to type must be the path of the mem.ts actually running, not a constant
191
+ // the copy `fapony init` places lives in .fapony/.memory/, not the .memory/ the old help text hardcoded
192
192
  const memCmd = `bun ${rel(dir)}/mem.ts`;
193
193
 
194
194
  const KINDS: WorkKind[] = ["next", "bug", "decision", "note", "hold"];
@@ -205,7 +205,7 @@ const rows = (): LogRow[] =>
205
205
  try {
206
206
  return [JSON.parse(l) as LogRow];
207
207
  } catch {
208
- // ponytail: 1 บรรทัดพัง (escape เสีย) ไม่ควรทำให้ทั้ง log อ่านไม่ได้ข้ามแล้วเตือน
208
+ // ponytail: one broken line (bad escape) must not make the whole log unreadableskip it and warn
209
209
  console.error(
210
210
  `[mem] skipped ${basename(f)} line ${i + 1} (bad JSON)`,
211
211
  );
@@ -213,7 +213,7 @@ const rows = (): LogRow[] =>
213
213
  }
214
214
  }),
215
215
  )
216
- // หลายไฟล์ต่อกันแล้วลำดับเวลาสลับทุก selector อ่านจากบนลงล่างโดยถือว่าเรียงตาม ts
216
+ // concatenating several files scrambles the time order every selector reads top-down assuming ts order
217
217
  .sort((a, b) => a.ts.localeCompare(b.ts));
218
218
 
219
219
  function put(r: Omit<WorkRow, "ts" | "agent">): void;
@@ -236,9 +236,9 @@ function put(
236
236
  );
237
237
  }
238
238
 
239
- // ponytail: กัน id ชน — base36 + increment ต่อ retry + random suffix
240
- // ใช้ร่วมกันทุกที่ที่ต้อง generate WorkRow.id (cmdAdd, ship-log ใน cmdPlanSweep)
241
- // ห้าม copy loop นี้ไปวางที่ใหม่แก้ scheme ที่นี่ที่เดียว
239
+ // ponytail: prevent id collisions — base36 + increment per retry + random suffix
240
+ // shared everywhere a WorkRow.id must be generated (cmdAdd, ship-log in cmdPlanSweep)
241
+ // do not copy this loop elsewherechange the scheme here only
242
242
  function nextId(all: LogRow[]): string {
243
243
  const used = new Set(all.map((r) => ("id" in r ? r.id : "")));
244
244
  let base = Date.now();
@@ -250,8 +250,8 @@ function nextId(all: LogRow[]): string {
250
250
  return id;
251
251
  }
252
252
 
253
- // เขียนแถวดิบ (ts/agent เดิม ไม่ generate ใหม่) — ใช้ตอน rotate ย้าย row เก่าไปไฟล์ใหม่
254
- // ปกติเขียน log ต้องผ่าน put() เท่านั้น อันนี้ทางเดียวที่ยกเว้น
253
+ // write a raw row (original ts/agent, no regeneration) — used when rotate moves old rows to a new file
254
+ // normally writing to the log must go through put(); this is the only exception
255
255
  const appendRaw = (path: string, r: LogRow): void =>
256
256
  appendFileSync(path, `${JSON.stringify(r)}\n`);
257
257