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,420 @@
1
+ // src/conventions-seed.ts — `fapony init` fill-signal (PLAN-convention-debt chunk 2).
2
+ //
3
+ // คำถาม: "การ fill signal ตอน init ทำได้แค่ไหน" (SPEC-convention-debt §2.2) —
4
+ // วัดสองรอบ คำตอบรอบแรกผิด: eslint คือของที่ทีมส่วนใหญ่ปล่อย default ตอนติดตั้ง,
5
+ // pair-mining พังกับ commit ก้อนใหญ่และ cap ผ่อนไม่ได้ (วัดแล้ว) — ของที่**ทุกรีโปมี**
6
+ // คือโค้ดตัวเอง ฐานจึงเป็น wrapper detector (อ่าน snapshot อย่างเดียว ไม่แตะ history),
7
+ // eslint เป็น bonus เมื่อมี, pair-mining ยังไม่ทำ (chunk 7 วัด precision ก่อน)
8
+ //
9
+ // เขียนได้ที่เดียว: <target>/.fapony/conventions.json — นิยาม convention อยู่ในรีโปที่
10
+ // ถูกวัด (SPEC §2.1), commit ได้เพราะทุก field เป็น repo-relative · มีอยู่แล้ว = ไม่แตะ
11
+ // (rule 5c) · fapony ไม่เดา convention ที่ไม่มีร่องรอย — อันที่ยังไม่เคย migrate และ
12
+ // ไม่มี wrapper ไม่มีร่องรอยทั้งใน snapshot และ history ช่องนั้นเหลือให้คนเติม (stale)
13
+
14
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname, join, relative } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { collectSourceFiles, isSkippedDir, isTestFile } from "./analyze.js";
18
+ import { extractBody, extractExports } from "./map.js";
19
+
20
+ const RESTRICTED_RULES = new Set([
21
+ "no-restricted-imports",
22
+ "no-restricted-syntax",
23
+ "no-restricted-properties",
24
+ ]);
25
+ // Flat-config era + eslint.rules.js companions — where the measured entries live.
26
+ // Legacy .eslintrc (JSON/YAML) is not importable and is skipped on purpose.
27
+ const CONFIG_IMPORTABLE_RE = /^(eslint\.config|eslint\.rules)\.(js|mjs|cjs)$/;
28
+ const RESTRICTED_TEXT_RE = /no-restricted-(imports|syntax|properties)/;
29
+
30
+ export interface SeedResult {
31
+ file: string;
32
+ /** Conventions derived from eslint no-restricted-* entries (they carry checker). */
33
+ eslintRows: number;
34
+ /** Wrapper-derived candidates (checker null — no checker exists yet). */
35
+ wrapperRows: number;
36
+ configs: string[];
37
+ /** Config found but not importable (missing deps of that repo) — said, never silent. */
38
+ skipped: string[];
39
+ /** conventions.json already existed — left untouched (rule 5c). */
40
+ kept?: boolean;
41
+ }
42
+
43
+ export interface SeedRow {
44
+ id: string;
45
+ rule: string;
46
+ where: string;
47
+ stale: string | null;
48
+ ok?: string;
49
+ guard?: string;
50
+ checker: string | null;
51
+ decided?: null;
52
+ }
53
+
54
+ function slug(s: string): string {
55
+ return (
56
+ s
57
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
58
+ .replace(/^-+|-+$/g, "")
59
+ .toLowerCase() || "x"
60
+ );
61
+ }
62
+
63
+ function flatText(s: unknown): string | null {
64
+ if (typeof s !== "string") return null;
65
+ const t = s.replace(/\s+/g, " ").trim();
66
+ return t.length > 0 ? t : null;
67
+ }
68
+
69
+ function escapeRe(s: string): string {
70
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ }
72
+
73
+ // "src/**/*.{ts,tsx}" → "src" · "**\/x" → "." · "src/routes/**/*.tsx" → "src/routes"
74
+ function globDir(glob: unknown): string | null {
75
+ if (typeof glob !== "string" || glob.length === 0) return null;
76
+ const cut = glob.replace(/[\\/]\*\*.*$/, "").replace(/\*\*.*$/, "");
77
+ const dir = cut.replace(/\/+$/, "");
78
+ return dir === "" ? "." : dir;
79
+ }
80
+
81
+ // --- eslint config extraction ---
82
+
83
+ interface Block {
84
+ files?: unknown;
85
+ rules?: Record<string, unknown>;
86
+ }
87
+
88
+ function* walkBlocks(node: unknown, depth = 0): Generator<Block> {
89
+ if (depth > 6 || !node || typeof node !== "object") return;
90
+ if (Array.isArray(node)) {
91
+ for (const n of node) yield* walkBlocks(n, depth + 1);
92
+ return;
93
+ }
94
+ const o = node as Record<string, unknown>;
95
+ if (o.rules && typeof o.rules === "object") yield o as Block;
96
+ for (const v of Object.values(o)) {
97
+ if (Array.isArray(v) || (v && typeof v === "object")) {
98
+ yield* walkBlocks(v, depth + 1);
99
+ }
100
+ }
101
+ }
102
+
103
+ function rowsFromBlock(block: Block, whereBase: string): SeedRow[] {
104
+ const out: SeedRow[] = [];
105
+ const whereGlob = Array.isArray(block.files) ? globDir(block.files[0]) : null;
106
+ const dir = whereBase === "." ? "" : `${whereBase}/`;
107
+ const where =
108
+ whereGlob && whereGlob !== "." ? `${dir}${whereGlob}` : whereBase || ".";
109
+ const push = (message: unknown, stale: string | null, ruleId: string) => {
110
+ const text = flatText(message);
111
+ if (!text) return; // checker โดยไม่มีข้อความ = ไม่รู้ว่ากฎคืออะไร
112
+ out.push({ id: "", rule: text, where, stale, checker: ruleId });
113
+ };
114
+ for (const [ruleId, value] of Object.entries(block.rules ?? {})) {
115
+ if (!RESTRICTED_RULES.has(ruleId) || !Array.isArray(value)) continue;
116
+ const [, ...entries] = value;
117
+ if (ruleId === "no-restricted-imports") {
118
+ // ["error", { paths: [{name, message, importNames?}], patterns: [{group, message}] }]
119
+ for (const opt of entries) {
120
+ if (!opt || typeof opt !== "object") continue;
121
+ const o = opt as Record<string, unknown>;
122
+ for (const p of Array.isArray(o.paths) ? o.paths : []) {
123
+ const e = p as Record<string, unknown>;
124
+ if (!e || typeof e !== "object") continue;
125
+ const name = typeof e.name === "string" ? e.name : null;
126
+ const stale =
127
+ name && !Array.isArray(e.importNames)
128
+ ? `["']${escapeRe(name)}["']`
129
+ : null; // named-import bans are not one text pattern
130
+ push(e.message, stale, ruleId);
131
+ }
132
+ for (const p of Array.isArray(o.patterns) ? o.patterns : []) {
133
+ const e = p as Record<string, unknown>;
134
+ if (!e || typeof e !== "object") continue;
135
+ push(e.message, null, ruleId); // gitignore-style group — no text regex
136
+ }
137
+ }
138
+ continue;
139
+ }
140
+ // no-restricted-syntax / -properties: ["error", entry, entry, ...]
141
+ for (const e of entries) {
142
+ if (!e || typeof e !== "object") continue;
143
+ const o = e as Record<string, unknown>;
144
+ let stale: string | null = null;
145
+ if (ruleId === "no-restricted-properties") {
146
+ const prop = typeof o.property === "string" ? o.property : null;
147
+ if (prop) {
148
+ stale =
149
+ typeof o.object === "string"
150
+ ? `${escapeRe(o.object)}\\.${escapeRe(prop)}\\(`
151
+ : `\\.${escapeRe(prop)}\\(`;
152
+ }
153
+ }
154
+ // no-restricted-syntax: the selector is AST-shaped — no honest text
155
+ // regex. The row still lands (checker set → the debt detector stays
156
+ // silent anyway; the row's value is the rule text in one place).
157
+ push(o.message, stale, ruleId);
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ function findConfigFiles(root: string): string[] {
164
+ const out: string[] = [];
165
+ const stack = [root];
166
+ while (stack.length > 0) {
167
+ const dir = stack.pop() as string;
168
+ let entries: import("node:fs").Dirent[];
169
+ try {
170
+ entries = readdirSync(dir, { withFileTypes: true });
171
+ } catch {
172
+ continue;
173
+ }
174
+ for (const e of entries) {
175
+ if (e.isSymbolicLink()) continue;
176
+ if (e.isDirectory()) {
177
+ if (isSkippedDir(e.name, dir)) continue;
178
+ stack.push(join(dir, e.name));
179
+ } else if (e.isFile() && CONFIG_IMPORTABLE_RE.test(e.name)) {
180
+ out.push(join(dir, e.name));
181
+ }
182
+ }
183
+ }
184
+ return out.sort();
185
+ }
186
+
187
+ async function eslintRows(
188
+ root: string,
189
+ ): Promise<{ rows: SeedRow[]; configs: string[]; skipped: string[] }> {
190
+ const rows: SeedRow[] = [];
191
+ const configs: string[] = [];
192
+ const skipped: string[] = [];
193
+ const seen = new Set<string>();
194
+ for (const abs of findConfigFiles(root)) {
195
+ let raw: string;
196
+ try {
197
+ raw = readFileSync(abs, "utf-8");
198
+ } catch {
199
+ continue;
200
+ }
201
+ if (!RESTRICTED_TEXT_RE.test(raw)) continue;
202
+ configs.push(relative(root, abs));
203
+ let mod: Record<string, unknown>;
204
+ try {
205
+ // The config is code from the measured repo — same trust as running its
206
+ // own eslint. Import (not text-parse) so shared constants and .map()
207
+ // computed entries resolve to real values.
208
+ mod = (await import(pathToFileURL(abs).href)) as Record<string, unknown>;
209
+ } catch (e) {
210
+ skipped.push(
211
+ `${relative(root, abs)} — import failed: ${
212
+ e instanceof Error ? e.message.split("\n")[0] : "?"
213
+ }`,
214
+ );
215
+ continue;
216
+ }
217
+ const cfgDir = dirname(relative(root, abs));
218
+ for (const block of walkBlocks(mod.default ?? mod)) {
219
+ rows.push(...rowsFromBlock(block, cfgDir === "." ? "." : cfgDir));
220
+ }
221
+ }
222
+ // Dedupe identical (checker, message) pairs that flat-config supersets repeat,
223
+ // then hand out unique ids.
224
+ const byMessage = new Map<string, SeedRow>();
225
+ for (const r of rows) {
226
+ const key = `${r.checker}\n${r.rule}`;
227
+ if (!byMessage.has(key)) byMessage.set(key, r);
228
+ }
229
+ const usedId = new Set<string>();
230
+ const final: SeedRow[] = [];
231
+ for (const r of byMessage.values()) {
232
+ let id = `eslint-${slug(r.rule).slice(0, 40)}`;
233
+ for (let n = 2; usedId.has(id); n++)
234
+ id = `eslint-${slug(r.rule).slice(0, 40)}-${n}`;
235
+ usedId.add(id);
236
+ final.push({ ...r, id });
237
+ }
238
+ return { rows: final, configs, skipped };
239
+ }
240
+
241
+ // --- wrapper detector (snapshot only — no history, no eslint) ---
242
+
243
+ const KEYWORDS = new Set([
244
+ "if",
245
+ "for",
246
+ "while",
247
+ "switch",
248
+ "catch",
249
+ "function",
250
+ "return",
251
+ "new",
252
+ "typeof",
253
+ "delete",
254
+ "void",
255
+ "in",
256
+ "of",
257
+ "do",
258
+ "else",
259
+ "super",
260
+ "import",
261
+ "await",
262
+ "async",
263
+ "yield",
264
+ "case",
265
+ "throw",
266
+ ]);
267
+ const MAX_WRAPPER_CANDIDATES = 40;
268
+ const MIN_USERS = 3;
269
+ const MIN_DIRECT = 2;
270
+ const MAX_DIRECT = 100;
271
+
272
+ interface Candidate {
273
+ wrapper: string;
274
+ file: string;
275
+ callText: string; // regex source, e.g. \.toLocaleString\( or newId\(
276
+ display: string; // e.g. .toLocaleString( / newId(
277
+ }
278
+
279
+ function callsInBody(body: string, local: Set<string>): Set<string> {
280
+ const found = new Set<string>();
281
+ for (const m of body.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
282
+ const name = m[1];
283
+ if (!KEYWORDS.has(name) && !local.has(name)) found.add(name);
284
+ }
285
+ for (const m of body.matchAll(/\.([A-Za-z_$][\w$]*)\s*\(/g)) {
286
+ found.add(`.${m[1]}`);
287
+ }
288
+ return found;
289
+ }
290
+
291
+ function detectWrappers(root: string): SeedRow[] {
292
+ const files = collectSourceFiles(root);
293
+ const candidates: Candidate[] = [];
294
+ for (const rel of files) {
295
+ if (candidates.length >= MAX_WRAPPER_CANDIDATES) break;
296
+ if (isTestFile(rel)) continue; // test fixtures are not convention sources
297
+ let source: string;
298
+ try {
299
+ source = readFileSync(join(root, rel), "utf-8");
300
+ } catch {
301
+ continue;
302
+ }
303
+ const scan = extractExports(source);
304
+ if (scan.error) continue;
305
+ for (const sym of scan.symbols) {
306
+ if (sym.kind !== "const" && sym.kind !== "fn") continue;
307
+ const body = extractBody(source, sym.line).join("\n");
308
+ if (body.length < 10 || body.length > 2000) continue;
309
+ const local = new Set(scan.symbols.map((s) => s.name));
310
+ for (const call of callsInBody(body, local)) {
311
+ const ident = call.startsWith(".") ? call.slice(1) : call;
312
+ if (ident === sym.name) continue;
313
+ candidates.push({
314
+ wrapper: sym.name,
315
+ file: rel,
316
+ callText: call.startsWith(".")
317
+ ? `\\.${escapeRe(ident)}\\(`
318
+ : `\\b${escapeRe(ident)}\\(`,
319
+ display: call.startsWith(".") ? `.${ident}(` : `${ident}(`,
320
+ });
321
+ if (candidates.length >= MAX_WRAPPER_CANDIDATES) break;
322
+ }
323
+ if (candidates.length >= MAX_WRAPPER_CANDIDATES) break;
324
+ }
325
+ }
326
+ if (candidates.length === 0) return [];
327
+
328
+ // One pass over the tree counting, per candidate: files still calling the
329
+ // wrapped thing directly (never referencing the wrapper) vs files using the
330
+ // wrapper. Both directions must be real for this to be a live migration.
331
+ const direct = new Map<Candidate, number>();
332
+ const users = new Map<Candidate, number>();
333
+ const wrapperRe = new Map<Candidate, RegExp>();
334
+ for (const c of candidates) {
335
+ wrapperRe.set(c, new RegExp(`\\b${escapeRe(c.wrapper)}\\b`));
336
+ direct.set(c, 0);
337
+ users.set(c, 0);
338
+ }
339
+ for (const rel of files) {
340
+ let content: string;
341
+ try {
342
+ content = readFileSync(join(root, rel), "utf-8");
343
+ } catch {
344
+ continue;
345
+ }
346
+ for (const c of candidates) {
347
+ if (rel === c.file) continue;
348
+ const uses = wrapperRe.get(c)!.test(content);
349
+ if (uses) users.set(c, users.get(c)! + 1);
350
+ if (!uses && new RegExp(c.callText).test(content)) {
351
+ direct.set(c, direct.get(c)! + 1);
352
+ }
353
+ }
354
+ }
355
+
356
+ const out: SeedRow[] = [];
357
+ const seenId = new Set<string>();
358
+ for (const c of candidates) {
359
+ const d = direct.get(c) ?? 0;
360
+ const u = users.get(c) ?? 0;
361
+ if (d < MIN_DIRECT || d > MAX_DIRECT || u < MIN_USERS) continue;
362
+ const id = `wrap-${slug(c.wrapper)}`;
363
+ if (seenId.has(id)) continue;
364
+ seenId.add(id);
365
+ out.push({
366
+ id,
367
+ rule: `use ${c.wrapper}() instead of ${c.display} directly (${d} file(s) still call it raw, ${u} file(s) already use the wrapper)`,
368
+ where: ".",
369
+ stale: c.callText,
370
+ ok: `\\b${escapeRe(c.wrapper)}\\b`,
371
+ checker: null,
372
+ });
373
+ }
374
+ return out;
375
+ }
376
+
377
+ // --- entry ---
378
+
379
+ export async function seedConventionsFile(target: string): Promise<SeedResult> {
380
+ const file = join(target, ".fapony", "conventions.json");
381
+ const base: SeedResult = {
382
+ file,
383
+ eslintRows: 0,
384
+ wrapperRows: 0,
385
+ configs: [],
386
+ skipped: [],
387
+ };
388
+ try {
389
+ JSON.parse(readFileSync(file, "utf-8"));
390
+ return { ...base, kept: true }; // exists → never overwrite (rule 5c)
391
+ } catch {
392
+ // no file yet — proceed
393
+ }
394
+ const rows: SeedRow[] = [];
395
+ let configs: string[] = [];
396
+ const skipped: string[] = [];
397
+ try {
398
+ const es = await eslintRows(target);
399
+ rows.push(...es.rows);
400
+ configs = es.configs;
401
+ skipped.push(...es.skipped);
402
+ } catch {
403
+ // eslint extraction must never fail the scaffold
404
+ }
405
+ try {
406
+ rows.push(...detectWrappers(target));
407
+ } catch {
408
+ // a wrapper scan failure is not an init failure
409
+ }
410
+ const payload = `${JSON.stringify({ conventions: rows }, null, 2)}\n`;
411
+ mkdirSync(join(target, ".fapony"), { recursive: true });
412
+ writeFileSync(file, payload);
413
+ return {
414
+ file,
415
+ eslintRows: rows.filter((r) => r.checker !== null).length,
416
+ wrapperRows: rows.filter((r) => r.checker === null).length,
417
+ configs,
418
+ skipped,
419
+ };
420
+ }
@@ -0,0 +1,26 @@
1
+ import type { Config } from "./types.js";
2
+
3
+ export const DEFAULT_SAFETY_DENY = [
4
+ "reset\\s+--hard",
5
+ "clean\\s+-[a-z]*f",
6
+ "checkout\\s+--\\s",
7
+ "git\\s+stash",
8
+ ];
9
+ export const DEFAULT_PLAN_DIR = ".fapony/plan";
10
+ export const DEFAULT_SPEC_DIR = ".fapony/spec";
11
+ // Archive sits beside plan/, not inside it, so archiving never changes a file's
12
+ // depth and its relative links survive the move untouched.
13
+ export const DEFAULT_DONE_DIR = ".fapony/done";
14
+ export const DEFAULT_MEMORY_ENTRY = ".fapony/.memory/mem.ts";
15
+ export const DEFAULT_EVIDENCE_FILE = ".fapony/evidence.json";
16
+
17
+ export const DEFAULT_CONFIG: Config = {
18
+ worktrees: {},
19
+ review: {
20
+ maxRounds: 2,
21
+ },
22
+ memory: null,
23
+ telemetry: null,
24
+ paths: null,
25
+ safety: null,
26
+ };
@@ -0,0 +1,33 @@
1
+ import {
2
+ DEFAULT_DONE_DIR,
3
+ DEFAULT_EVIDENCE_FILE,
4
+ DEFAULT_MEMORY_ENTRY,
5
+ DEFAULT_PLAN_DIR,
6
+ DEFAULT_SAFETY_DENY,
7
+ DEFAULT_SPEC_DIR,
8
+ } from "./defaults.js";
9
+ import type { Config } from "./types.js";
10
+
11
+ export function safetyDeny(config?: Config): string[] {
12
+ return config?.safety?.deny ?? DEFAULT_SAFETY_DENY;
13
+ }
14
+
15
+ export function planDir(config?: Config): string {
16
+ return config?.paths?.planDir ?? DEFAULT_PLAN_DIR;
17
+ }
18
+
19
+ export function specDir(config?: Config): string {
20
+ return config?.paths?.specDir ?? DEFAULT_SPEC_DIR;
21
+ }
22
+
23
+ export function doneDir(config?: Config): string {
24
+ return config?.paths?.doneDir ?? DEFAULT_DONE_DIR;
25
+ }
26
+
27
+ export function memoryEntry(config?: Config): string {
28
+ return config?.paths?.memoryEntry ?? DEFAULT_MEMORY_ENTRY;
29
+ }
30
+
31
+ export function evidenceFile(config?: Config): string {
32
+ return config?.paths?.evidenceFile ?? DEFAULT_EVIDENCE_FILE;
33
+ }
@@ -0,0 +1,7 @@
1
+ // barrel — re-export ทุกอย่างจาก db/* ให้ import เดิมจาก "./db/index.js" ใช้ได้
2
+
3
+ export * from "./defaults.js";
4
+ export * from "./getters.js";
5
+ export * from "./load.js";
6
+ export * from "./store.js";
7
+ export * from "./types.js";
package/src/db/load.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { DEFAULT_CONFIG } from "./defaults.js";
5
+ import type { Config } from "./types.js";
6
+
7
+ // XDG Base Directory convention (macOS ignores Apple's ~/Library/Application Support
8
+ // for CLI tools by common practice — gh, ripgrep-adjacent tools, etc. use ~/.config too)
9
+ // Override order: $FAPONY_STATE_DIR > config.paths.stateDir > $XDG_CONFIG_HOME > ~/.config
10
+ export function faponyDir(config?: Config): string {
11
+ if (process.env.FAPONY_STATE_DIR) return process.env.FAPONY_STATE_DIR;
12
+ if (config?.paths?.stateDir) return config.paths.stateDir;
13
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
14
+ return join(base, "fapony");
15
+ }
16
+
17
+ function _dbPath(config?: Config): string {
18
+ return join(faponyDir(config), "state.db");
19
+ }
20
+
21
+ export function configFilePath(): string {
22
+ if (process.env.FAPONY_CONFIG) return process.env.FAPONY_CONFIG;
23
+ return join(process.cwd(), "fapony.config.json");
24
+ }
25
+
26
+ function freshDefaultConfig(): Config {
27
+ return {
28
+ ...DEFAULT_CONFIG,
29
+ worktrees: { ...DEFAULT_CONFIG.worktrees },
30
+ review: {
31
+ ...DEFAULT_CONFIG.review,
32
+ },
33
+ };
34
+ }
35
+
36
+ export function loadConfig(configPath?: string): Config {
37
+ const resolved = configPath ?? configFilePath();
38
+ if (!existsSync(resolved)) return freshDefaultConfig();
39
+
40
+ try {
41
+ const raw = readFileSync(resolved, "utf-8");
42
+ const file = JSON.parse(raw) as Partial<Config>;
43
+
44
+ return {
45
+ ...DEFAULT_CONFIG,
46
+ ...file,
47
+ review: {
48
+ ...DEFAULT_CONFIG.review,
49
+ ...file.review,
50
+ },
51
+ paths: file.paths ? { ...file.paths } : DEFAULT_CONFIG.paths,
52
+ safety: file.safety ? { ...file.safety } : DEFAULT_CONFIG.safety,
53
+ };
54
+ } catch {
55
+ return freshDefaultConfig();
56
+ }
57
+ }