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
package/src/debt.ts ADDED
@@ -0,0 +1,667 @@
1
+ // src/debt.ts — `fapony debt`: which files have not moved to a shipped convention yet.
2
+ //
3
+ // คำถามที่ไม่มีใครตอบได้: "ไฟล์ไหนยังไม่ย้าย" — rules files (CLAUDE.md, Cursor rules)
4
+ // บอกได้แค่ว่า "กฎคืออะไร" (ชั้น 2) และ "ก๊อปไฟล์ไหน" (ชั้น 1) — ตำแหน่งของหนี้
5
+ // (ชั้น 3) อยู่ในหัวเจ้าของ และหายเมื่อลืม (SPEC-convention-debt §1)
6
+ //
7
+ // นิยามของ convention อยู่ในรีโปที่ถูกวัด (<repo>/.fapony/conventions.json — ผ่าน
8
+ // resolver เดียวกับ mem log, SPEC §2.1) — fapony ไม่รู้จัก React หรือ Hono และ
9
+ // ต้องไม่รู้จัก · หนึ่ง convention = pattern ที่ควรใช้ (ok) + pattern ที่แปลว่า
10
+ // ยังไม่ย้าย (stale) + ขอบเขต (where) + เงื่อนไขของไฟล์ (guard, เช่น extends Base)
11
+ //
12
+ // หนี้ถูกคำนวณสดทุกครั้ง ไม่เขียนลงที่ใดเลย (แบบเดียวกับ analyze: cache คือหนี้ล้วน —
13
+ // ลิสต์ที่ freeze ไว้ตกรุ่นเงียบ ๆ เหมือน MASTER.md) · กฎเหล็ก: checker ไม่ null =
14
+ // fapony ไม่รายงานหนี้ข้อนั้น — การรายงานซ้ำกับ eslint คือ abstraction ที่มี
15
+ // implementation เดียว (กฎ 1) และสอนให้ agent ข้ามทั้งคู่ (SPEC §2)
16
+ //
17
+ // Read-only stdout: no file writes, no state.db, no cache (rule 5b).
18
+
19
+ import { existsSync, readFileSync, statSync } from "node:fs";
20
+ import { isAbsolute, join, relative, resolve } from "node:path";
21
+ import { collectSourceFiles } from "./analyze.js";
22
+ import { openDb } from "./db/index.js";
23
+ import { type MemRow, readMemLog, resolveAppFaponyDir } from "./memory.js";
24
+
25
+ // A stale regex matching more than this many files is not a convention — it is
26
+ // a broken/wide regex (stale="e" would flag the repo). SPEC §6: drop the entry
27
+ // and say so, never report 600 files.
28
+ const DEBT_FILE_CAP = 250;
29
+ // Same cap for the file list printed per convention — a wall is not an answer.
30
+ const LIST_SHOWN = 40;
31
+
32
+ export interface Convention {
33
+ id: string;
34
+ rule: string;
35
+ /** Repo-relative dir scope ("." = whole repo). */
36
+ where: string;
37
+ /** Regex source: a match means the file still has the debt. null = not derivable (checker rows) or not filled in yet. */
38
+ stale: string | null;
39
+ /** Regex source: files that already moved (informational count). */
40
+ ok?: string;
41
+ /** Regex source a file must ALSO match to be in scope (e.g. "extends Base"). */
42
+ guard?: string;
43
+ /** Non-null = a checker (eslint rule / script) exists → fapony never reports this debt. */
44
+ checker?: string | null;
45
+ /** Human answered "no checker" on the promotion question — never ask again. */
46
+ decided?: "no-checker" | null;
47
+ }
48
+
49
+ // --- conventions.json resolution (same guess as the mem log, SPEC §2.1) ---
50
+
51
+ export function resolveConventionsPath(worktree: string): string | null {
52
+ const base = resolveAppFaponyDir(worktree);
53
+ const app = join(base, "conventions.json");
54
+ if (existsSync(app)) return app;
55
+ // Monorepo where the app has not scaffolded .fapony/ yet, and single repos
56
+ // that ran `fapony init` at the root — the root file still scopes fine
57
+ // because every `where` is repo-relative.
58
+ const root = join(worktree, ".fapony", "conventions.json");
59
+ return existsSync(root) ? root : null;
60
+ }
61
+
62
+ export interface LoadedConventions {
63
+ path: string | null;
64
+ convs: Convention[];
65
+ /** Rows kept for display but not scannable, plus invalid rows — said out loud, never silent. */
66
+ warnings: string[];
67
+ }
68
+
69
+ function asString(v: unknown): string | undefined {
70
+ return typeof v === "string" && v.length > 0 ? v : undefined;
71
+ }
72
+
73
+ /** Parses .fapony/conventions.json. Missing file = empty + no error (SPEC §6). */
74
+ export function loadConventions(worktree: string): LoadedConventions {
75
+ const path = resolveConventionsPath(worktree);
76
+ if (!path) return { path: null, convs: [], warnings: [] };
77
+ let raw: string;
78
+ try {
79
+ raw = readFileSync(path, "utf-8");
80
+ } catch {
81
+ return {
82
+ path,
83
+ convs: [],
84
+ warnings: [`conventions.json unreadable: ${path}`],
85
+ };
86
+ }
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(raw);
90
+ } catch (e) {
91
+ return {
92
+ path,
93
+ convs: [],
94
+ warnings: [
95
+ `conventions.json is not valid JSON — ${
96
+ e instanceof Error ? e.message.split("\n")[0] : "parse error"
97
+ }`,
98
+ ],
99
+ };
100
+ }
101
+ const rows: unknown[] = Array.isArray(parsed)
102
+ ? parsed
103
+ : Array.isArray((parsed as { conventions?: unknown }).conventions)
104
+ ? (parsed as { conventions: unknown[] }).conventions
105
+ : [];
106
+ const convs: Convention[] = [];
107
+ const warnings: string[] = [];
108
+ rows.forEach((r, i) => {
109
+ const o = r as Record<string, unknown>;
110
+ const id = asString(o.id);
111
+ const rule = asString(o.rule);
112
+ if (!id || !rule) {
113
+ warnings.push(
114
+ `conventions[${i}]: id and rule are required — row dropped`,
115
+ );
116
+ return;
117
+ }
118
+ convs.push({
119
+ id,
120
+ rule,
121
+ where: asString(o.where) ?? ".",
122
+ stale: asString(o.stale) ?? null,
123
+ ok: asString(o.ok),
124
+ guard: asString(o.guard),
125
+ checker: asString(o.checker) ?? null,
126
+ decided: o.decided === "no-checker" ? "no-checker" : null,
127
+ });
128
+ });
129
+ return { path, convs, warnings };
130
+ }
131
+
132
+ // --- The scan (fresh every call — derive, never store) ---
133
+
134
+ interface Compiled {
135
+ conv: Convention;
136
+ staleRe: RegExp | null;
137
+ okRe: RegExp | null;
138
+ guardRe: RegExp | null;
139
+ whereDir: string;
140
+ }
141
+
142
+ function compile(conv: Convention): { c: Compiled; error?: string } {
143
+ const re = (
144
+ src: string | null | undefined,
145
+ what: string,
146
+ ): { re: RegExp | null; error?: string } => {
147
+ if (!src) return { re: null };
148
+ try {
149
+ return { re: new RegExp(src) };
150
+ } catch (e) {
151
+ return {
152
+ re: null,
153
+ error: `${what} regex broken (${e instanceof Error ? e.message.split("\n")[0] : "?"})`,
154
+ };
155
+ }
156
+ };
157
+ const stale = re(conv.stale, `${conv.id}: stale`);
158
+ if (stale.error)
159
+ return {
160
+ c: {
161
+ conv,
162
+ staleRe: null,
163
+ okRe: null,
164
+ guardRe: null,
165
+ whereDir: conv.where,
166
+ },
167
+ error: stale.error,
168
+ };
169
+ const ok = re(conv.ok, `${conv.id}: ok`);
170
+ if (ok.error)
171
+ return {
172
+ c: {
173
+ conv,
174
+ staleRe: null,
175
+ okRe: null,
176
+ guardRe: null,
177
+ whereDir: conv.where,
178
+ },
179
+ error: ok.error,
180
+ };
181
+ const guard = re(conv.guard, `${conv.id}: guard`);
182
+ if (guard.error)
183
+ return {
184
+ c: {
185
+ conv,
186
+ staleRe: null,
187
+ okRe: null,
188
+ guardRe: null,
189
+ whereDir: conv.where,
190
+ },
191
+ error: guard.error,
192
+ };
193
+ return {
194
+ c: {
195
+ conv,
196
+ staleRe: stale.re,
197
+ okRe: ok.re,
198
+ guardRe: guard.re,
199
+ // where="src" must scope src/ and src/x/y.ts but not src-other/;
200
+ // where="." scopes everything.
201
+ whereDir: conv.where === "." ? "" : conv.where.replace(/\/+$/, ""),
202
+ },
203
+ };
204
+ }
205
+
206
+ function inScope(whereDir: string, file: string): boolean {
207
+ return whereDir === "" || file.startsWith(`${whereDir}/`);
208
+ }
209
+
210
+ export interface DebtEntry {
211
+ conv: Convention;
212
+ /** Files with the debt (stale match), sorted. */
213
+ files: string[];
214
+ /** Files that already moved (ok match) — null when ok is not set. */
215
+ movedCount: number | null;
216
+ }
217
+
218
+ export interface DebtReport {
219
+ worktree: string;
220
+ scannedFiles: number;
221
+ ms: number;
222
+ /** Scannable conventions with their debt list (checker rows never land here). */
223
+ entries: DebtEntry[];
224
+ /** Declared but not fillable by fapony: checker null + no stale — the human/agent fills `stale`. */
225
+ declared: Convention[];
226
+ /** Skipped-with-reason: checker rows are silent by design (not dropped), these are real drops. */
227
+ dropped: { id: string; reason: string }[];
228
+ /** Silent-by-design count: checker non-null — reported as a number, not a list. */
229
+ checkedCount: number;
230
+ }
231
+
232
+ export function debtScan(
233
+ worktree: string,
234
+ loaded: LoadedConventions,
235
+ ): DebtReport {
236
+ const t0 = performance.now();
237
+ const entries: DebtEntry[] = [];
238
+ const declared: Convention[] = [];
239
+ const dropped: { id: string; reason: string }[] = [];
240
+ let checkedCount = 0;
241
+
242
+ const compiled: Compiled[] = [];
243
+ for (const conv of loaded.convs) {
244
+ if (conv.checker) {
245
+ // กฎเหล็ก — fapony เงียบ ปล่อยให้ checker ทำงาน (SPEC §2)
246
+ checkedCount++;
247
+ continue;
248
+ }
249
+ if (!conv.stale) {
250
+ // ช่องเดียวที่คนเติม (SPEC §2.2) — โชว์ว่าค้าง ไม่เดาแทน
251
+ declared.push(conv);
252
+ continue;
253
+ }
254
+ const { c, error } = compile(conv);
255
+ if (error || !c.staleRe) {
256
+ dropped.push({ id: conv.id, reason: error ?? "uncompilable" });
257
+ continue;
258
+ }
259
+ if (!existsSync(join(worktree, c.whereDir || "."))) {
260
+ dropped.push({
261
+ id: conv.id,
262
+ reason: `where: ${conv.where} does not exist`,
263
+ });
264
+ continue;
265
+ }
266
+ compiled.push(c);
267
+ }
268
+
269
+ const files = collectSourceFiles(worktree);
270
+ const debt: Map<string, string[]> = new Map(
271
+ compiled.map((c) => [c.conv.id, []]),
272
+ );
273
+ const moved: Map<string, number> = new Map(
274
+ compiled.map((c) => [c.conv.id, 0]),
275
+ );
276
+ const tooBroad: Map<string, number> = new Map();
277
+
278
+ for (const rel of files) {
279
+ let content: string;
280
+ try {
281
+ content = readFileSync(join(worktree, rel), "utf-8");
282
+ } catch {
283
+ continue;
284
+ }
285
+ for (const c of compiled) {
286
+ if (!c.staleRe) continue; // filtered at compile; narrows the type
287
+ if (!inScope(c.whereDir, rel)) continue;
288
+ if (c.guardRe && !c.guardRe.test(content)) continue;
289
+ if (c.staleRe.test(content)) {
290
+ const cur = debt.get(c.conv.id) ?? [];
291
+ cur.push(rel);
292
+ debt.set(c.conv.id, cur);
293
+ // Stop counting a runaway regex early — the entry will be dropped.
294
+ if (cur.length > DEBT_FILE_CAP) tooBroad.set(c.conv.id, cur.length);
295
+ }
296
+ if (c.okRe && c.okRe.test(content)) {
297
+ moved.set(c.conv.id, (moved.get(c.conv.id) ?? 0) + 1);
298
+ }
299
+ }
300
+ }
301
+
302
+ for (const c of compiled) {
303
+ const n = tooBroad.get(c.conv.id);
304
+ if (n !== undefined) {
305
+ dropped.push({
306
+ id: c.conv.id,
307
+ reason: `stale regex matches ${n}+ files — too broad, entry dropped (narrow stale/where/guard)`,
308
+ });
309
+ continue;
310
+ }
311
+ entries.push({
312
+ conv: c.conv,
313
+ files: (debt.get(c.conv.id) ?? []).sort(),
314
+ movedCount: c.okRe ? (moved.get(c.conv.id) ?? 0) : null,
315
+ });
316
+ }
317
+
318
+ return {
319
+ worktree,
320
+ scannedFiles: files.length,
321
+ ms: Math.round(performance.now() - t0),
322
+ entries,
323
+ declared,
324
+ dropped,
325
+ checkedCount,
326
+ };
327
+ }
328
+
329
+ /** Per-file lookup (hook-read-hint + --files): which conventions flag this file. */
330
+ export function debtForFile(
331
+ worktree: string,
332
+ absFile: string,
333
+ loaded: LoadedConventions,
334
+ ): Convention[] {
335
+ const rel = relative(worktree, absFile).split("\\").join("/");
336
+ if (rel.startsWith("..") || isAbsolute(rel)) return [];
337
+ let content: string;
338
+ try {
339
+ content = readFileSync(absFile, "utf-8");
340
+ } catch {
341
+ return [];
342
+ }
343
+ const out: Convention[] = [];
344
+ for (const conv of loaded.convs) {
345
+ if (conv.checker || !conv.stale) continue;
346
+ const { c, error } = compile(conv);
347
+ if (error || !c.staleRe) continue;
348
+ if (!inScope(c.whereDir, rel)) continue;
349
+ if (c.guardRe && !c.guardRe.test(content)) continue;
350
+ if (c.staleRe.test(content)) out.push(conv);
351
+ }
352
+ return out;
353
+ }
354
+
355
+ // --- Promotion signal (chunk 5) — "เรื่องนี้ซ้ำครั้งที่ N แล้ว ทำ checker ไหม" ---
356
+ //
357
+ // "ผมจะทำ eslint ตอนที่คิดได้" — จังหวะ "คิดได้" คือสิ่งที่หายไป (SPEC §3) fapony
358
+ // เห็นประวัติข้าม session (mem + verdicts) จึงนับได้ว่าเรื่องเดียวกันถูกแก้ซ้ำกี่ครั้ง
359
+ // แล้วยื่นคำถามให้คนตัดสิน — ไม่ตัดสินเอง ไม่เขียน eslint rule เอง (SPEC §6 fail list)
360
+ //
361
+ // การ match "เรื่องเดียวกัน" — แม่นยำเท่าที่ข้อมูลให้ (SPEC §7: แถวเก่าไม่มี files[]
362
+ // ยังไม่ตัดสิน): แถวที่มี files[] ต้อง intersect กับ debt list · ข้อความต้องเอ่ยถึง
363
+ // สัญลักษณ์ของ convention (ok เช่น fmtMoney, หรือ identifier ≥ 6 ตัวจาก stale เช่น
364
+ // toLocaleString/useMutation — "throw"/"Error" สั้นเกินจึงไม่นับ กัน over-match)
365
+
366
+ export const PROMOTION_THRESHOLD = 3;
367
+ const PROMOTION_MAX = 3;
368
+ /** Identifiers shorter than this are too generic to match prose on ("throw", "Error"). */
369
+ const WORD_MIN = 6;
370
+
371
+ export interface Promotion {
372
+ convId: string;
373
+ rule: string;
374
+ occurrences: number;
375
+ dates: string[];
376
+ debtCount: number;
377
+ }
378
+
379
+ function conventionWords(conv: Convention): string[] {
380
+ const words = new Set<string>();
381
+ for (const src of [conv.ok, conv.stale]) {
382
+ if (!src) continue;
383
+ for (const m of src.matchAll(/[A-Za-z_$][\w$]*/g)) {
384
+ if (m[0].length >= WORD_MIN) words.add(m[0]);
385
+ }
386
+ }
387
+ return [...words];
388
+ }
389
+
390
+ function rowMatchesConv(
391
+ hay: string,
392
+ files: string[] | undefined,
393
+ debtFiles: Set<string>,
394
+ words: string[],
395
+ ): boolean {
396
+ if (files && files.length > 0) {
397
+ if (files.some((f) => debtFiles.has(f))) return true;
398
+ }
399
+ const lower = hay.toLowerCase();
400
+ return words.some((w) => lower.includes(w.toLowerCase()));
401
+ }
402
+
403
+ interface EvidenceRow {
404
+ ts: string;
405
+ files?: string[];
406
+ hay: string;
407
+ }
408
+
409
+ function gatherEvidence(worktree: string): EvidenceRow[] {
410
+ const out: EvidenceRow[] = [];
411
+ try {
412
+ for (const r of readMemLog(worktree).rows) {
413
+ if (r.kind !== "bug" && r.kind !== "decision") continue;
414
+ out.push({ ts: r.ts, files: r.files, hay: `${r.text}\n${r.spec ?? ""}` });
415
+ }
416
+ } catch {
417
+ // mem missing — verdicts alone still count
418
+ }
419
+ try {
420
+ const db = openDb();
421
+ const events = db
422
+ .prepare(
423
+ `SELECT e.ts AS ts, e.data AS data FROM events e
424
+ JOIN runs r ON r.id = e.run_id
425
+ WHERE r.worktree = ? AND e.kind = 'gate' ORDER BY e.id`,
426
+ )
427
+ .all(worktree) as { ts: string; data: string | null }[];
428
+ for (const e of events) {
429
+ if (!e.data) continue;
430
+ try {
431
+ const d = JSON.parse(e.data) as {
432
+ verdict?: string;
433
+ reason_code?: string;
434
+ note?: string;
435
+ files?: string[];
436
+ };
437
+ const countsAsFix =
438
+ d.verdict === "fail" ||
439
+ d.reason_code === "scope_mismatch" ||
440
+ d.reason_code === "spec_gap";
441
+ if (!countsAsFix) continue;
442
+ out.push({ ts: e.ts, files: d.files, hay: d.note ?? "" });
443
+ } catch {}
444
+ }
445
+ } catch {
446
+ // no ledger yet — mem alone still counts
447
+ }
448
+ return out;
449
+ }
450
+
451
+ /** Repeated-fix questions for conventions that have no checker and no "no-checker" decision. */
452
+ export function findPromotions(
453
+ worktree: string,
454
+ report: DebtReport,
455
+ ): Promotion[] {
456
+ const evidence = gatherEvidence(worktree);
457
+ if (evidence.length === 0) return [];
458
+ const out: Promotion[] = [];
459
+ for (const entry of report.entries) {
460
+ const { conv } = entry;
461
+ if (conv.checker || conv.decided === "no-checker") continue;
462
+ if (entry.files.length === 0) continue;
463
+ const debtFiles = new Set(entry.files);
464
+ const words = conventionWords(conv);
465
+ const hits = evidence.filter((r) =>
466
+ rowMatchesConv(r.hay, r.files, debtFiles, words),
467
+ );
468
+ if (hits.length < PROMOTION_THRESHOLD) continue;
469
+ const dates = [...new Set(hits.map((h) => h.ts.slice(0, 10)))].sort();
470
+ out.push({
471
+ convId: conv.id,
472
+ rule: conv.rule,
473
+ occurrences: hits.length,
474
+ dates,
475
+ debtCount: entry.files.length,
476
+ });
477
+ }
478
+ // Newest first, capped — three questions are already a conversation.
479
+ out.sort((a, b) => b.occurrences - a.occurrences);
480
+ return out.slice(0, PROMOTION_MAX);
481
+ }
482
+
483
+ export function formatPromotions(promotions: Promotion[]): string[] {
484
+ if (promotions.length === 0) return [];
485
+ const lines: string[] = [
486
+ "",
487
+ "promotion — repeated fixes on conventions with no checker:",
488
+ ];
489
+ for (const p of promotions) {
490
+ lines.push(
491
+ `\n"${p.convId}" (${p.debtCount} file(s) still wrong) came up ${p.occurrences}× ` +
492
+ `(${p.dates.slice(0, 3).join(", ")}${p.dates.length > 3 ? ", …" : ""})`,
493
+ );
494
+ lines.push(` ${p.rule}`);
495
+ lines.push(
496
+ " [1] make a checker — an agent drafts the eslint rule in this repo, you review",
497
+ );
498
+ lines.push(
499
+ ' [2] one-off, no checker — record "decided": "no-checker" on this entry, never asked again',
500
+ );
501
+ lines.push(" [3] later — ask again when this comes up a few more times");
502
+ }
503
+ return lines;
504
+ }
505
+
506
+ // --- Formatting ---
507
+
508
+ const WRAP_WIDTH = 88;
509
+
510
+ function wrapFiles(files: string[]): string[] {
511
+ const lines: string[] = [];
512
+ let cur = "";
513
+ for (const f of files) {
514
+ const piece = cur ? `${cur} · ${f}` : f;
515
+ if (piece.length > WRAP_WIDTH && cur) {
516
+ lines.push(` ${cur}`);
517
+ cur = f;
518
+ } else {
519
+ cur = piece;
520
+ }
521
+ }
522
+ if (cur) lines.push(` ${cur}`);
523
+ return lines;
524
+ }
525
+
526
+ export function formatDebt(report: DebtReport): string {
527
+ const lines: string[] = [];
528
+ lines.push(
529
+ `fapony debt — ${report.entries.length + report.declared.length + report.checkedCount} convention(s), ` +
530
+ `${report.scannedFiles} files scanned, ${report.ms}ms — derived fresh, not stored`,
531
+ );
532
+ for (const e of report.entries) {
533
+ const moved = e.movedCount !== null ? ` · moved ${e.movedCount}` : "";
534
+ lines.push(`\n${e.conv.id} — ${e.conv.rule} (where ${e.conv.where})`);
535
+ if (e.files.length === 0) {
536
+ lines.push(` debt 0${moved} — clean`);
537
+ continue;
538
+ }
539
+ lines.push(` debt ${e.files.length}${moved}:`);
540
+ lines.push(...wrapFiles(e.files.slice(0, LIST_SHOWN)));
541
+ if (e.files.length > LIST_SHOWN) {
542
+ lines.push(` … +${e.files.length - LIST_SHOWN} more files`);
543
+ }
544
+ }
545
+ for (const c of report.declared) {
546
+ lines.push(`\n${c.id} — ${c.rule} (where ${c.where})`);
547
+ lines.push(
548
+ ` declared, no checker, stale not filled in — fill "stale" in conventions.json`,
549
+ );
550
+ }
551
+ if (report.checkedCount > 0) {
552
+ lines.push(
553
+ `\n${report.checkedCount} convention(s) have a checker — fapony stays silent, the checker reports`,
554
+ );
555
+ }
556
+ for (const d of report.dropped) {
557
+ lines.push(`⚠ ${d.id}: ${d.reason}`);
558
+ }
559
+ return lines.join("\n");
560
+ }
561
+
562
+ // --- CLI ---
563
+
564
+ const USAGE = "usage: fapony debt [path] [--files f1,f2] [--json]";
565
+
566
+ function worktreeOf(arg: string | undefined): string {
567
+ const base = resolve(arg ?? ".");
568
+ try {
569
+ const p = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
570
+ cwd: base,
571
+ stdout: "pipe",
572
+ stderr: "pipe",
573
+ });
574
+ if (p.exitCode === 0) return p.stdout.toString().trim();
575
+ } catch {
576
+ // fall through
577
+ }
578
+ return base;
579
+ }
580
+
581
+ export function cmdDebt(args: string[]): void {
582
+ let path: string | undefined;
583
+ let filesMode: string[] | null = null;
584
+ let json = false;
585
+ for (let i = 0; i < args.length; i++) {
586
+ const a = args[i];
587
+ if (a === "--files") {
588
+ const v = args[i + 1];
589
+ if (!v || v.startsWith("--")) {
590
+ console.error(`fapony debt: --files needs a value\n${USAGE}`);
591
+ process.exit(1);
592
+ }
593
+ i++;
594
+ filesMode = v
595
+ .split(",")
596
+ .map((s) => s.trim())
597
+ .filter(Boolean);
598
+ if (filesMode.length === 0) {
599
+ console.error(`fapony debt: --files needs at least one path\n${USAGE}`);
600
+ process.exit(1);
601
+ }
602
+ } else if (a === "--json") {
603
+ json = true;
604
+ } else if (a === "-h" || a === "--help") {
605
+ console.log(USAGE);
606
+ return;
607
+ } else if (!a.startsWith("--")) {
608
+ path = a;
609
+ } else {
610
+ console.error(`fapony debt: unknown argument "${a}"\n${USAGE}`);
611
+ process.exit(1);
612
+ }
613
+ }
614
+
615
+ const worktree = worktreeOf(path);
616
+ const loaded = loadConventions(worktree);
617
+
618
+ if (filesMode) {
619
+ const out = filesMode.map((f) => {
620
+ const abs = isAbsolute(f) ? f : resolve(worktree, f);
621
+ if (!existsSync(abs) || !statSync(abs).isFile()) {
622
+ return { file: f, debt: [], note: "not found" as const };
623
+ }
624
+ return { file: f, debt: debtForFile(worktree, abs, loaded) };
625
+ });
626
+ if (json) {
627
+ console.log(JSON.stringify({ worktree, files: out }, null, 2));
628
+ return;
629
+ }
630
+ let any = false;
631
+ for (const r of out) {
632
+ for (const c of r.debt) {
633
+ any = true;
634
+ console.log(`${r.file} — ${c.id}: ${c.rule}`);
635
+ }
636
+ if ("note" in r) console.log(`${r.file} — ${r.note}`);
637
+ }
638
+ if (!any && out.every((r) => r.debt.length === 0)) {
639
+ console.log("no convention debt in the given file(s)");
640
+ }
641
+ return;
642
+ }
643
+
644
+ if (loaded.path === null) {
645
+ // SPEC §6: ไม่มี conventions.json = เงียบสนิท ไม่ error ไม่ชวนสร้าง
646
+ console.log(
647
+ `fapony debt — no conventions.json in ${worktree} (nothing tracked yet)`,
648
+ );
649
+ return;
650
+ }
651
+ const report = debtScan(worktree, loaded);
652
+ if (json) {
653
+ console.log(
654
+ JSON.stringify(
655
+ { ...report, promotions: findPromotions(worktree, report) },
656
+ null,
657
+ 2,
658
+ ),
659
+ );
660
+ return;
661
+ }
662
+ console.log(formatDebt(report));
663
+ for (const w of loaded.warnings) console.log(`⚠ ${w}`);
664
+ for (const l of formatPromotions(findPromotions(worktree, report))) {
665
+ console.log(l);
666
+ }
667
+ }