fapony 0.1.0

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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bun
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 จะอ่านที่เดิมต่อ)
7
+ //
8
+ // CLI entry point — all logic lives in:
9
+ // store.ts (types + config + rows/put)
10
+ // selectors.ts (openRows / claimsOf / staleReport)
11
+ // render.ts (fmtRow / fmtClose / printOpenRows / doneLines)
12
+ // commands/write.ts (add / close / claim / release / synced / hook)
13
+ // commands/read.ts (now / done / stale / find / kickoff)
14
+ // commands/selftest.ts (test)
15
+
16
+ import { cmdPlanCheck, cmdPlanSweep } from "./commands/plan.js";
17
+ import {
18
+ cmdDone,
19
+ cmdFind,
20
+ cmdKickoff,
21
+ cmdNow,
22
+ cmdStale,
23
+ } from "./commands/read.js";
24
+ import { cmdRotate } from "./commands/rotate.js";
25
+ import { cmdTest } from "./commands/selftest.js";
26
+ import {
27
+ cmdAdd,
28
+ cmdClaim,
29
+ cmdClose,
30
+ cmdHook,
31
+ cmdRelease,
32
+ cmdSynced,
33
+ } from "./commands/write.js";
34
+
35
+ const [cmd, ...a] = process.argv.slice(2);
36
+
37
+ if (cmd === "add") {
38
+ await cmdAdd(a);
39
+ } else if (cmd === "close") {
40
+ await cmdClose(a);
41
+ } else if (cmd === "claim") {
42
+ cmdClaim(a);
43
+ } else if (cmd === "release") {
44
+ await cmdRelease(a);
45
+ } else if (cmd === "synced") {
46
+ cmdSynced(a);
47
+ } else if (cmd === "hook") {
48
+ await cmdHook();
49
+ } else if (cmd === "done") {
50
+ cmdDone();
51
+ } else if (cmd === "stale") {
52
+ cmdStale();
53
+ } else if (cmd === "find") {
54
+ cmdFind(a);
55
+ } else if (cmd === "test") {
56
+ cmdTest();
57
+ } else if (cmd === "kickoff") {
58
+ cmdKickoff(a);
59
+ } else if (cmd === "plan-sweep") {
60
+ cmdPlanSweep(a);
61
+ } else if (cmd === "plan-check") {
62
+ cmdPlanCheck(a);
63
+ } else if (cmd === "rotate") {
64
+ cmdRotate(a);
65
+ } else {
66
+ // mem now (default) — next+bug+hold. decision/note ไม่ใช่งานค้าง → ค้นด้วย find แทน
67
+ cmdNow();
68
+ }
@@ -0,0 +1,63 @@
1
+ // render.ts — formatting and display helpers for the memory log
2
+
3
+ import { claimsOf, openRows } from "./selectors.js";
4
+ import type { ClaimRow, CloseRow, LogRow, WorkRow } from "./store.js";
5
+
6
+ export const fmtRow = (r: WorkRow, claims?: Map<string, ClaimRow>) => {
7
+ let s = `- [${r.id}]`;
8
+ if (claims?.has(r.id)) s += ` (${claims.get(r.id)?.agent})`;
9
+ s += ` ${r.kind} ${r.text}`;
10
+ if (r.spec) s += ` → ${r.spec}`;
11
+ return s;
12
+ };
13
+
14
+ export const printOpenRows = (all: LogRow[], opts?: { showHold?: boolean }) => {
15
+ const open = openRows(all);
16
+ const claims = claimsOf(all);
17
+
18
+ // in-progress (claimed items)
19
+ const inProg = open.filter(
20
+ (r) => claims.has(r.id) && (r.kind === "next" || r.kind === "bug"),
21
+ );
22
+ if (inProg.length) {
23
+ console.log(
24
+ `\n## in-progress\n${inProg.map((r) => fmtRow(r, claims)).join("\n")}`,
25
+ );
26
+ }
27
+
28
+ // next (unclaimed only)
29
+ const nexts = open.filter((r) => r.kind === "next" && !claims.has(r.id));
30
+ if (nexts.length) {
31
+ console.log(`\n## next\n${nexts.map((r) => fmtRow(r)).join("\n")}`);
32
+ }
33
+
34
+ // bug (unclaimed only)
35
+ const bugs = open.filter((r) => r.kind === "bug" && !claims.has(r.id));
36
+ if (bugs.length) {
37
+ console.log(`\n## bug\n${bugs.map((r) => fmtRow(r)).join("\n")}`);
38
+ }
39
+
40
+ // hold — sorted by spec for per-plan visibility
41
+ if (opts?.showHold) {
42
+ const holds = open
43
+ .filter((r) => r.kind === "hold")
44
+ .sort((a, b) => (a.spec ?? "").localeCompare(b.spec ?? ""));
45
+ if (holds.length) {
46
+ console.log(`\n## hold\n${holds.map((r) => fmtRow(r)).join("\n")}`);
47
+ }
48
+ }
49
+ };
50
+
51
+ export const fmtClose = (c: CloseRow, byId: Map<string, WorkRow>) => {
52
+ const o = byId.get(c.ref);
53
+ const agentStr = c.agent && c.agent !== "unknown" ? ` (${c.agent})` : "";
54
+ return `- ${c.ts.slice(0, 10)}${agentStr} ${o && "text" in o ? o.text : c.ref} → ${c.text}`;
55
+ };
56
+
57
+ export const doneLines = (all: LogRow[], limit?: number) => {
58
+ const workAll = all.filter((r): r is WorkRow => "id" in r);
59
+ const byId = new Map(workAll.map((r) => [r.id, r] as const));
60
+ const closes = all.filter((r): r is CloseRow => r.kind === "close").reverse();
61
+ const slice = limit ? closes.slice(0, limit) : closes;
62
+ return slice.map((c) => fmtClose(c, byId));
63
+ };
@@ -0,0 +1,144 @@
1
+ // selectors.ts — derive open/claimed/stale views from the append-only log
2
+
3
+ import type {
4
+ ClaimRow,
5
+ CloseRow,
6
+ LogRow,
7
+ ReleaseRow,
8
+ WorkRow,
9
+ } from "./store.js";
10
+
11
+ // ponytail: reducer = filter + tombstone set. 50k บรรทัด = 9.6MB/26ms — ไฟล์ไม่ใช่คอขวด
12
+ // เพดานจริง = แถวที่ยังเปิดเยอะจน view อ่านไม่รู้เรื่อง → ตอนนั้นค่อย rotate: git mv + append openRows(เก่า) ลงไฟล์ใหม่
13
+ export const openRows = (all: LogRow[]): WorkRow[] => {
14
+ const dead = new Set(
15
+ all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
16
+ );
17
+ return all.filter(
18
+ (r): r is WorkRow =>
19
+ r.kind !== "close" &&
20
+ r.kind !== "claim" &&
21
+ r.kind !== "release" &&
22
+ r.kind !== "synced" &&
23
+ "id" in r &&
24
+ !dead.has(r.id),
25
+ );
26
+ };
27
+
28
+ // C) claimsOf: คืน Map ref → latest claim row ที่ยัง active
29
+ // active = แถวล่าสุดของ claim|release เป็น claim และ ref ไม่อยู่ใน dead (close)
30
+ export const claimsOf = (all: LogRow[]): Map<string, ClaimRow> => {
31
+ const dead = new Set(
32
+ all.filter((r): r is CloseRow => r.kind === "close").map((r) => r.ref),
33
+ );
34
+ const latest = new Map<string, ClaimRow | ReleaseRow>();
35
+ for (const r of all) {
36
+ if (r.kind === "claim" || r.kind === "release") {
37
+ latest.set(r.ref, r);
38
+ }
39
+ }
40
+ const active = new Map<string, ClaimRow>();
41
+ for (const [ref, r] of latest) {
42
+ if (r.kind === "claim" && !dead.has(ref)) {
43
+ active.set(ref, r);
44
+ }
45
+ }
46
+ return active;
47
+ };
48
+
49
+ // decision ที่ใหม่กว่าทั้ง commit ล่าสุดของ spec และ synced marker = spec ยังไม่ได้อัปเดตตาม
50
+ // + next/hold ที่มี spec ถูกแก้หลังสร้าง = อาจปิดไปแล้วแต่ไม่มีใคร close — เคสจริง: msbnndmt
51
+ // + active claim ที่อายุเกิน 4 ชม. = agent อาจตายกลางงาน
52
+ export const staleReport = (all: LogRow[]): string[] => {
53
+ const out: string[] = [];
54
+ const mark: Record<string, number> = {};
55
+ for (const r of all) {
56
+ if (r.kind === "synced") mark[r.spec] = Date.parse(r.ts);
57
+ }
58
+
59
+ // batch: collect unique spec paths, spawn one git per path
60
+ const specPaths = new Set<string>();
61
+ for (const r of all) {
62
+ if (r.kind === "decision" && r.spec) specPaths.add(r.spec);
63
+ }
64
+ for (const r of openRows(all)) {
65
+ if ((r.kind === "next" || r.kind === "hold") && r.spec)
66
+ specPaths.add(r.spec);
67
+ }
68
+ const gitDates = new Map<string, number>();
69
+ for (const spec of specPaths) {
70
+ // --follow: spec path ที่ถูก git mv (เช่น ย้ายเข้า plan/done/) ยังตามประวัติต่อได้
71
+ const git = Bun.spawnSync([
72
+ "git",
73
+ "log",
74
+ "-1",
75
+ "--follow",
76
+ "--format=%cI",
77
+ "--",
78
+ spec,
79
+ ])
80
+ .stdout.toString()
81
+ .trim();
82
+ if (git) gitDates.set(spec, Date.parse(git));
83
+ }
84
+
85
+ for (const r of all) {
86
+ if (r.kind === "decision" && r.spec) {
87
+ const base = Math.max(gitDates.get(r.spec) ?? 0, mark[r.spec] ?? 0);
88
+ if (base < Date.parse(r.ts))
89
+ out.push(
90
+ `STALE ${r.spec}: decision ${r.ts.slice(0, 10)} never made it into the spec — "${r.text}"`,
91
+ );
92
+ }
93
+ }
94
+ for (const r of openRows(all)) {
95
+ if ((r.kind === "next" || r.kind === "hold") && r.spec) {
96
+ const gitDate = gitDates.get(r.spec);
97
+ // ponytail: grace 24 ชม. — "log next แล้วเขียน/commit plan ต่อในวันเดียวกัน" คือ workflow ปกติ
98
+ // ไม่ใช่สัญญาณว่างานจบแล้วลืมปิด (เคสจริงที่ต้องจับอย่าง msbnndmt ห่างกันเป็นวัน) —
99
+ // ไม่มี grace = SUSPECT ขึ้นทุกแผนที่เพิ่งเขียน แล้วทุกคนเรียนรู้ที่จะเลื่อนผ่าน stale ทั้งบล็อก
100
+ if (gitDate && gitDate > Date.parse(r.ts) + 86_400_000)
101
+ out.push(
102
+ `SUSPECT [${r.id}] ${r.spec} changed after this ${r.kind} (${r.ts.slice(0, 10)}) — check whether it is already done`,
103
+ );
104
+ }
105
+ }
106
+ const claims = claimsOf(all);
107
+ for (const [ref, c] of claims) {
108
+ const ageH = (Date.now() - Date.parse(c.ts)) / 3_600_000;
109
+ if (ageH > 4) {
110
+ out.push(
111
+ `SUSPECT claim [${ref}] by ${c.agent} ${c.ts.slice(0, 10)} never closed/released — the agent may have died mid-task`,
112
+ );
113
+ }
114
+ }
115
+ return out;
116
+ };
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 เอง)
125
+ export const rotateKeep = (all: LogRow[]): LogRow[] => {
126
+ const open = openRows(all);
127
+ const workOpen = open.filter(
128
+ (r) => r.kind === "next" || r.kind === "bug" || r.kind === "hold",
129
+ );
130
+ const claims = claimsOf(all);
131
+ const openIds = new Set(workOpen.map((r) => r.id));
132
+ const keepClaims = [...claims.values()].filter((c) => openIds.has(c.ref));
133
+
134
+ const syncedAt: Record<string, number> = {};
135
+ for (const r of all)
136
+ if (r.kind === "synced") syncedAt[r.spec] = Date.parse(r.ts);
137
+ const keepDecisionNote = open.filter(
138
+ (r) =>
139
+ (r.kind === "decision" || r.kind === "note") &&
140
+ (!r.spec || !(r.spec in syncedAt) || syncedAt[r.spec] < Date.parse(r.ts)),
141
+ );
142
+
143
+ return [...workOpen, ...keepDecisionNote, ...keepClaims];
144
+ };
@@ -0,0 +1,285 @@
1
+ // store.ts — types + config + read/write primitives for the append-only memory log
2
+
3
+ import {
4
+ appendFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ } from "node:fs";
10
+ import { basename, dirname, join, relative } from "node:path";
11
+
12
+ // --- types ---
13
+
14
+ type WorkKind = "next" | "bug" | "decision" | "note" | "hold";
15
+
16
+ type WorkRow = {
17
+ ts: string;
18
+ agent: string;
19
+ id: string;
20
+ kind: WorkKind;
21
+ text: string;
22
+ spec?: string;
23
+ files?: string[];
24
+ };
25
+
26
+ type CloseRow = {
27
+ ts: string;
28
+ agent: string;
29
+ kind: "close";
30
+ ref: string;
31
+ text: string;
32
+ };
33
+
34
+ type ClaimRow = {
35
+ ts: string;
36
+ agent: string;
37
+ kind: "claim";
38
+ ref: string;
39
+ };
40
+
41
+ type ReleaseRow = {
42
+ ts: string;
43
+ agent: string;
44
+ kind: "release";
45
+ ref: string;
46
+ text?: string;
47
+ };
48
+
49
+ type SyncedRow = {
50
+ ts: string;
51
+ agent: string;
52
+ kind: "synced";
53
+ spec: string;
54
+ };
55
+
56
+ type LogRow = WorkRow | CloseRow | ClaimRow | ReleaseRow | SyncedRow;
57
+
58
+ // --- config ---
59
+
60
+ const root = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"])
61
+ .stdout.toString()
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 ทั้งสองแบบ
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/ เหมือนเดิม — ไม่ขยายในรอบนี้
70
+ const appBase: string | undefined = ["apps", "packages", "services"]
71
+ .map((d) => `${root}/${d}/${app}`)
72
+ .find((p) => existsSync(p));
73
+ const monorepo = appBase !== undefined;
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 ของมันอยู่ข้าง ๆ นั่นเอง
84
+ const centralAtMonorepoRoot =
85
+ import.meta.dir === `${root}/.fapony/.memory` && monorepo;
86
+ const scaffolded =
87
+ import.meta.dir.includes("/.fapony/.memory") && !centralAtMonorepoRoot;
88
+
89
+ // มี apps/ แต่ไม่มี apps/<app> = เดา app ผิด (worktree ชื่อไม่ตรง / typo ใน MEM_APP) — ตายตรงนี้
90
+ // ดีกว่า fallback เงียบ ๆ ไปเขียน log ที่ root ซึ่งจะกลายเป็น log กำพร้าที่ไม่มีใครอ่าน
91
+ // (สำเนาที่ scaffold ไว้ใต้ apps/<x>/.fapony/.memory/ อิงโฟลเดอร์ตัวเอง ไม่ต้องเดา จึงไม่เข้าเงื่อนไขนี้)
92
+ if (!scaffolded && !monorepo && existsSync(`${root}/apps`)) {
93
+ console.error(
94
+ `unknown app (guessed "${app}" from ${basename(root)}) — pass MEM_APP=<app>`,
95
+ );
96
+ process.exit(1);
97
+ }
98
+ // default ใหม่: log อยู่ใต้ .fapony/.memory — fallback ไป .memory/ เดิมเมื่อมี log เก่าอยู่จริง
99
+ // เช็ค log.jsonl ไม่ใช่ dir: โฟลเดอร์ว่างที่ใครเผลอ mkdir ทิ้งไว้ต้องไม่ล็อก repo ไว้กับ layout เก่า
100
+ const newDir = appBase
101
+ ? `${appBase}/.fapony/.memory`
102
+ : `${root}/.fapony/.memory`;
103
+ const legacyDir = appBase ? `${appBase}/.memory` : `${root}/.memory`;
104
+ const dir = scaffolded
105
+ ? import.meta.dir
106
+ : existsSync(`${legacyDir}/log.jsonl`)
107
+ ? legacyDir
108
+ : newDir;
109
+ // ใครเขียนแถวนี้ — client ตั้ง MEM_AGENT ทับได้ ("claude-code", "opencode")
110
+ const agent = process.env.MEM_AGENT || process.env.USER || "unknown";
111
+
112
+ // ชื่อไฟล์ = *คน* ไม่ใช่ client ตั้งใจให้ต่างจาก agent ข้างบน: ถ้าใช้ MEM_AGENT ตั้งชื่อไฟล์
113
+ // สองคนที่เปิด Claude Code จะกลับไปเขียน log.claude-code.jsonl ใบเดียวกัน = ชนเหมือนเดิม
114
+ // git user.name มีอยู่แล้วทุกเครื่องที่ commit ได้ จึงไม่ต้องตั้ง env และไม่ต้องเพิ่ม config
115
+ const person = (
116
+ Bun.spawnSync(["git", "config", "user.name"]).stdout.toString().trim() ||
117
+ process.env.USER ||
118
+ "unknown"
119
+ )
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9._-]+/g, "-")
122
+ .replace(/^-+|-+$/g, "");
123
+
124
+ // เขียนไฟล์ของตัวเอง อ่านของทุกคน — สองคนไม่เคยแตะไฟล์เดียวกัน = merge conflict
125
+ // เป็นศูนย์โดยโครงสร้าง ไม่ต้องพึ่ง merge=union หรือให้ GitHub ทำตัวดีตอน merge PR
126
+ const LOG = `${dir}/log.${person || "unknown"}.jsonl`;
127
+
128
+ // log.jsonl = ของเดิมก่อนแยกไฟล์ (ยังอ่านตลอดไป ไม่ต้อง migrate)
129
+ // ข้าม log.YYYY-MM-DD.jsonl ที่ rotate สร้าง ไม่งั้น rotate จะไม่ลดอะไรเลยเพราะอ่านกลับเข้ามา
130
+ const isLogFile = (f: string): boolean =>
131
+ f === "log.jsonl" ||
132
+ (/^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f) &&
133
+ !/^log\.\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
134
+
135
+ const logFiles = (): string[] =>
136
+ existsSync(dir)
137
+ ? readdirSync(dir)
138
+ .filter(isLogFile)
139
+ .sort()
140
+ .map((f) => join(dir, f))
141
+ : [];
142
+
143
+ // โฟลเดอร์ที่ plan/ กับ done/ ของโปรเจกต์นี้อยู่ใต้มัน — จุดเดียวที่ประกอบ path เหล่านี้
144
+ // (ก่อนหน้านี้ commands/plan.ts hardcode `apps/<app>/plan` 10 จุด = ตายสนิทกับ repo เดี่ยว)
145
+ const planBase = scaffolded
146
+ ? dirname(import.meta.dir) // <project>/.fapony
147
+ : (appBase ?? root);
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 ของอีกโปรเจกต์หนึ่ง
154
+ const configDir = scaffolded ? dirname(planBase) : root;
155
+
156
+ const configPaths = ((): Record<string, string> => {
157
+ try {
158
+ const raw = readFileSync(`${configDir}/fapony.config.json`, "utf8");
159
+ return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
160
+ } catch {
161
+ // ไม่มีไฟล์ / JSON เสีย → ใช้ค่าที่เดาได้ ไม่ใช่ error: memory ต้องทำงานได้โดยไม่มี fapony
162
+ return {};
163
+ }
164
+ })();
165
+
166
+ const fromConfig = (key: string): string | null =>
167
+ typeof configPaths[key] === "string"
168
+ ? join(configDir, configPaths[key])
169
+ : null;
170
+
171
+ // app ที่ย้าย plan เข้า .fapony/ แล้วให้ใช้ของใหม่ ที่ยังไม่ย้ายใช้ของเดิม — โมโนเรโปจึงย้ายทีละ app ได้
172
+ // โดยไม่ต้องแตะ config (config มี planDir ค่าเดียว ประกาศเมื่อไหร่ app อื่นก็ชี้ผิดตามไปด้วย)
173
+ const base = existsSync(`${planBase}/.fapony`)
174
+ ? `${planBase}/.fapony`
175
+ : planBase;
176
+
177
+ const planDir = fromConfig("planDir") ?? `${base}/plan`;
178
+
179
+ // done/ อยู่ข้าง plan/ (ย้ายแล้วลึกเท่าเดิม ลิงก์ relative ในไฟล์รอด) — repo ที่ยัง layout เก่า
180
+ // เก็บ plan/done/ ไว้ ก็ใช้ของเดิมต่อ ไม่ต้องย้ายก่อนถึงจะ sweep ได้
181
+ const doneDir =
182
+ fromConfig("doneDir") ??
183
+ (!existsSync(`${base}/done`) && existsSync(`${planDir}/done`)
184
+ ? `${planDir}/done`
185
+ : `${base}/done`);
186
+
187
+ // path ที่เอาไว้โชว์/บันทึกลง log — อิง repo root เสมอ (`apps/vela/plan`, `.fapony/plan`)
188
+ const rel = (p: string) => relative(root, p) || ".";
189
+
190
+ // คำสั่งที่บอกให้ผู้ใช้พิมพ์ ต้องเป็น path ของ mem.ts ตัวที่กำลังรันอยู่จริง ไม่ใช่ค่าคงที่ —
191
+ // สำเนาที่ `fapony init` วางไว้อยู่ที่ .fapony/.memory/ ไม่ใช่ .memory/ ที่ help text เดิม hardcode
192
+ const memCmd = `bun ${rel(dir)}/mem.ts`;
193
+
194
+ const KINDS: WorkKind[] = ["next", "bug", "decision", "note", "hold"];
195
+
196
+ // --- core ---
197
+
198
+ const rows = (): LogRow[] =>
199
+ logFiles()
200
+ .flatMap((f) =>
201
+ readFileSync(f, "utf8")
202
+ .split("\n")
203
+ .filter(Boolean)
204
+ .flatMap((l: string, i: number) => {
205
+ try {
206
+ return [JSON.parse(l) as LogRow];
207
+ } catch {
208
+ // ponytail: 1 บรรทัดพัง (escape เสีย) ไม่ควรทำให้ทั้ง log อ่านไม่ได้ — ข้ามแล้วเตือน
209
+ console.error(
210
+ `[mem] skipped ${basename(f)} line ${i + 1} (bad JSON)`,
211
+ );
212
+ return [];
213
+ }
214
+ }),
215
+ )
216
+ // หลายไฟล์ต่อกันแล้วลำดับเวลาสลับ — ทุก selector อ่านจากบนลงล่างโดยถือว่าเรียงตาม ts
217
+ .sort((a, b) => a.ts.localeCompare(b.ts));
218
+
219
+ function put(r: Omit<WorkRow, "ts" | "agent">): void;
220
+ function put(r: Omit<CloseRow, "ts" | "agent">): void;
221
+ function put(r: Omit<ClaimRow, "ts" | "agent">): void;
222
+ function put(r: Omit<ReleaseRow, "ts" | "agent">): void;
223
+ function put(r: Omit<SyncedRow, "ts" | "agent">): void;
224
+ function put(
225
+ r:
226
+ | Omit<WorkRow, "ts" | "agent">
227
+ | Omit<CloseRow, "ts" | "agent">
228
+ | Omit<ClaimRow, "ts" | "agent">
229
+ | Omit<ReleaseRow, "ts" | "agent">
230
+ | Omit<SyncedRow, "ts" | "agent">,
231
+ ) {
232
+ mkdirSync(dir, { recursive: true });
233
+ appendFileSync(
234
+ LOG,
235
+ `${JSON.stringify({ ts: new Date().toISOString(), agent, ...r })}\n`,
236
+ );
237
+ }
238
+
239
+ // ponytail: กัน id ชน — base36 + increment ต่อ retry + random suffix
240
+ // ใช้ร่วมกันทุกที่ที่ต้อง generate WorkRow.id (cmdAdd, ship-log ใน cmdPlanSweep)
241
+ // ห้าม copy loop นี้ไปวางที่ใหม่ — แก้ scheme ที่นี่ที่เดียว
242
+ function nextId(all: LogRow[]): string {
243
+ const used = new Set(all.map((r) => ("id" in r ? r.id : "")));
244
+ let base = Date.now();
245
+ let id = base.toString(36);
246
+ while (used.has(id)) {
247
+ id = base.toString(36) + Math.random().toString(36).slice(2, 4);
248
+ base++;
249
+ }
250
+ return id;
251
+ }
252
+
253
+ // เขียนแถวดิบ (ts/agent เดิม ไม่ generate ใหม่) — ใช้ตอน rotate ย้าย row เก่าไปไฟล์ใหม่
254
+ // ปกติเขียน log ต้องผ่าน put() เท่านั้น อันนี้ทางเดียวที่ยกเว้น
255
+ const appendRaw = (path: string, r: LogRow): void =>
256
+ appendFileSync(path, `${JSON.stringify(r)}\n`);
257
+
258
+ export type {
259
+ ClaimRow,
260
+ CloseRow,
261
+ LogRow,
262
+ ReleaseRow,
263
+ SyncedRow,
264
+ WorkKind,
265
+ WorkRow,
266
+ };
267
+ export {
268
+ agent,
269
+ app,
270
+ appendRaw,
271
+ configDir,
272
+ dir,
273
+ doneDir,
274
+ KINDS,
275
+ LOG,
276
+ memCmd,
277
+ nextId,
278
+ person,
279
+ planBase,
280
+ planDir,
281
+ put,
282
+ rel,
283
+ root,
284
+ rows,
285
+ };