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,146 @@
1
+ // src/price/fetch.ts — ดึงตารางราคา OpenRouter + cache เป็น prices.json
2
+ //
3
+ // ราคาเป็น cache ไม่ใช่ state: เก็บที่ ~/.config/fapony/prices.json (เคารพ
4
+ // FAPONY_STATE_DIR) ห้ามเพิ่มตาราง SQLite (กฎ DB Schema: 2 ตารางเท่านั้น)
5
+ // refresh เกิดตอนคนสั่ง `fapony price-scan` เท่านั้น — query ไม่ fetch เอง
6
+ // (วินัยเดียวกับ usage-scan) offline แล้วอ่าน cache เดิม
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ renameSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { faponyDir } from "../db/load.js";
17
+ import type { Config } from "../db/types.js";
18
+
19
+ const PRICES_FILENAME = "prices.json";
20
+ const MODELS_URL = "https://openrouter.ai/api/v1/models";
21
+
22
+ /** เรตราย token (ดอลลาร์) — ทุกเรตมาจาก OpenRouter ตรง ๆ ไม่เดา */
23
+ export interface ModelRates {
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ /** null = ตารางไม่ให้มา → ใช้เรต input แทน (ดู calcCost) */
28
+ cacheWrite: number | null;
29
+ }
30
+
31
+ export interface PriceTable {
32
+ fetched_at: string;
33
+ models: Record<string, ModelRates>;
34
+ }
35
+
36
+ export function pricesPath(config?: Config): string {
37
+ return join(faponyDir(config), PRICES_FILENAME);
38
+ }
39
+
40
+ /** อ่าน cache — คืน null เมื่อไม่มีไฟล์หรือพัง (caller แสดง — + hint) */
41
+ export function loadPrices(config?: Config): PriceTable | null {
42
+ const p = pricesPath(config);
43
+ if (!existsSync(p)) return null;
44
+ try {
45
+ const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial<PriceTable>;
46
+ if (!raw || typeof raw.models !== "object" || !raw.models) return null;
47
+ return {
48
+ fetched_at: typeof raw.fetched_at === "string" ? raw.fetched_at : "",
49
+ models: raw.models,
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** เขียนแบบ atomic (tmp + rename) สร้าง state dir เมื่อยังไม่มี */
57
+ export function writePrices(table: PriceTable, config?: Config): void {
58
+ const p = pricesPath(config);
59
+ mkdirSync(dirname(p), { recursive: true });
60
+ const tmp = `${p}.tmp`;
61
+ writeFileSync(tmp, JSON.stringify(table, null, 2), "utf-8");
62
+ renameSync(tmp, p);
63
+ }
64
+
65
+ function toRate(v: unknown): number | null {
66
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
67
+ if (typeof v === "string" && v.trim() !== "") {
68
+ const n = Number(v);
69
+ return Number.isFinite(n) ? n : null;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * แปลง response ดิบของ OpenRouter เป็นตารางเรต (pure — เทสต์ได้โดยไม่ยิงเน็ต)
76
+ *
77
+ * ฟิลด์จริง (ยืนยัน 2026-09-14, 445 models): pricing.prompt / .completion /
78
+ * .input_cache_read / .input_cache_write (optional) / .input_cache_write_1h
79
+ * (เรต 1h TTL ของบางรุ่น — ใช้เรต 5m มาตรฐานพอ เพราะ log ไม่บอก TTL)
80
+ * เมิน pricing.web_search / pricing.overrides (tier ตาม min_prompt_tokens /
81
+ * utc_days — ใช้ base rate แล้วประกาศข้อจำกัด) · id ขึ้นต้น ~ (alias) ตัดทิ้ง
82
+ * id ลงท้าย :free / :batch เก็บตามนั้น (เป็นแถวราคาของมันเอง)
83
+ */
84
+ export function parsePricesResponse(json: unknown): Record<string, ModelRates> {
85
+ const out: Record<string, ModelRates> = {};
86
+ const data = (json as { data?: unknown })?.data;
87
+ if (!Array.isArray(data)) return out;
88
+ for (const m of data) {
89
+ const rec = m as { id?: unknown; pricing?: unknown };
90
+ if (typeof rec.id !== "string" || !rec.id) continue;
91
+ const pricing = rec.pricing as Record<string, unknown> | undefined;
92
+ if (!pricing || typeof pricing !== "object") continue;
93
+ const input = toRate(pricing.prompt);
94
+ const output = toRate(pricing.completion);
95
+ if (input === null || output === null) continue;
96
+ const cacheRead = toRate(pricing.input_cache_read) ?? 0;
97
+ const cacheWrite = toRate(pricing.input_cache_write);
98
+ const id = rec.id.startsWith("~") ? rec.id.slice(1) : rec.id;
99
+ out[id] = { input, output, cacheRead, cacheWrite };
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * รวมตารางใหม่เข้ากับ cache เดิม — merge ไม่ replace: id ที่หายไปจาก
106
+ * response รอบนี้ (รุ่นเก่าหลุดตาราง) ต้องยังคิดราคาได้ด้วยเรตเดิม
107
+ */
108
+ export function mergePriceTables(
109
+ old: PriceTable | null,
110
+ fresh: Record<string, ModelRates>,
111
+ ): PriceTable {
112
+ return {
113
+ fetched_at: new Date().toISOString(),
114
+ models: { ...(old?.models ?? {}), ...fresh },
115
+ };
116
+ }
117
+
118
+ /** ดึงตารางจาก OpenRouter (public, ไม่ต้อง auth) — fetcher แทรกได้ไว้เทสต์ */
119
+ export async function fetchPriceTable(
120
+ fetcher: typeof fetch = fetch,
121
+ ): Promise<Record<string, ModelRates>> {
122
+ const res = await fetcher(MODELS_URL);
123
+ if (!res.ok) throw new Error(`OpenRouter ${res.status}`);
124
+ return parsePricesResponse(await res.json());
125
+ }
126
+
127
+ export async function cmdPriceScan(rawArgs: string[]): Promise<void> {
128
+ if (rawArgs.length > 0) {
129
+ console.error("usage: fapony price-scan");
130
+ process.exit(1);
131
+ }
132
+ let fresh: Record<string, ModelRates>;
133
+ try {
134
+ fresh = await fetchPriceTable();
135
+ } catch (err) {
136
+ console.error(
137
+ `fapony price-scan: fetch failed (${String(err)}) — cache เดิมยังอยู่ ใช้ราคาที่มีได้`,
138
+ );
139
+ process.exit(1);
140
+ }
141
+ const merged = mergePriceTables(loadPrices(), fresh);
142
+ writePrices(merged);
143
+ console.log(
144
+ `prices: ${Object.keys(fresh).length} models fetched, ${Object.keys(merged.models).length} cached → ${pricesPath()}`,
145
+ );
146
+ }
@@ -0,0 +1,8 @@
1
+ // src/price/index.ts — barrel re-export
2
+
3
+ export { cmdPriceScan, loadPrices, type PriceTable } from "./fetch.js";
4
+ export {
5
+ type ImputedModel,
6
+ type ImputeSummary,
7
+ imputeResult,
8
+ } from "./resolve.js";
@@ -0,0 +1,213 @@
1
+ // src/price/resolve.ts — normalize model id + คิด list-price equivalent
2
+ //
3
+ // กฎเหล็ก: map ไม่ได้ต้องเป็น unpriced ห้ามตีเป็น 0 เงียบ ๆ (failure mode หลัก
4
+ // ของฟีเจอร์นี้) · free มีแต่ของที่ราคา 0 จริง (local / แถว :free ในตาราง)
5
+
6
+ import type { ModelBreakdown, PassiveUsageResult } from "../session/types.js";
7
+ import type { ModelRates, PriceTable } from "./fetch.js";
8
+
9
+ export type PriceStatus = "priced" | "free" | "unpriced";
10
+
11
+ export interface PriceResolution {
12
+ status: PriceStatus;
13
+ rates: ModelRates | null;
14
+ }
15
+
16
+ /** prefix ของ client ที่แปะหน้าทุก model id — ตัดทิ้งแล้ว lookup ใหม่ */
17
+ const CLIENT_PREFIXES = ["openrouter/", "opencode-go/", "opencode/"];
18
+
19
+ /** tier ต่อท้ายของ OpenRouter — :free คือ endpoint ฟรีจริง :batch คือส่วนลด */
20
+ function stripTier(id: string): string {
21
+ return id.endsWith(":free") || id.endsWith(":batch")
22
+ ? id.slice(0, id.lastIndexOf(":"))
23
+ : id;
24
+ }
25
+
26
+ /**
27
+ * รายชื่อ candidate id ตามลำดับความเฉพาะ: ตรงตัวก่อน กว้างทีหลัง
28
+ * (ตรงตัวชนก่อนเสมอ — bare slug แมตช์กว้างสุดอยู่ท้าย)
29
+ */
30
+ export function candidateIds(provider: string, model: string): string[] {
31
+ const full = provider ? `${provider}/${model}` : model;
32
+ const out: string[] = [full];
33
+ let rest = full;
34
+ for (const p of CLIENT_PREFIXES) {
35
+ if (rest.startsWith(p)) {
36
+ rest = rest.slice(p.length);
37
+ out.push(rest);
38
+ break;
39
+ }
40
+ }
41
+ // opencode ต่อ -free ท้าย slug ของรุ่นฟรี (deepseek-v4-flash-free)
42
+ if (rest.endsWith("-free")) out.push(rest.slice(0, -"-free".length));
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * หาเรตให้ model หนึ่งตัว — คืน null เฉพาะของ local เท่านั้นที่ข้ามตาราง
48
+ * (local ไม่เคยมีราคาตั้งแต่แรก ไม่ใช่ "หาไม่เจอ")
49
+ */
50
+ function isLocalProvider(provider: string): boolean {
51
+ return provider === "lmstudio_local";
52
+ }
53
+
54
+ export function resolvePrice(
55
+ provider: string,
56
+ model: string,
57
+ table: PriceTable,
58
+ ): PriceResolution {
59
+ if (!model || model === "(no model id)" || model === "(unknown)")
60
+ return { status: "unpriced", rates: null };
61
+ if (isLocalProvider(provider)) return { status: "free", rates: null };
62
+ // ลงท้าย :free หรือ -free = เรียกผ่าน free endpoint / รุ่นฟรีของ client มา
63
+ // ราคาจริงคือ 0 (ไม่ใช่ list price) ไม่ว่าจะ map OpenRouter ได้หรือไม่
64
+ if (model.endsWith(":free") || model.endsWith("-free"))
65
+ return { status: "free", rates: null };
66
+ for (const id of candidateIds(provider, model)) {
67
+ const rates = table.models[id] ?? table.models[stripTier(id)];
68
+ if (rates) {
69
+ // แถวราคา 0 ทั้งแถว (:free / รุ่นฟรี) = free จริง ไม่ใช่ unpriced
70
+ if (
71
+ rates.input === 0 &&
72
+ rates.output === 0 &&
73
+ rates.cacheRead === 0 &&
74
+ (rates.cacheWrite ?? 0) === 0
75
+ )
76
+ return { status: "free", rates };
77
+ return { status: "priced", rates };
78
+ }
79
+ }
80
+ // zcode เก็บแค่ slug ไม่มี vendor (GLM-5.3-Flash) — เทียบส่วนหลัง / ตรงตัว
81
+ // แบบ case-insensitive (exact ไม่ใช่ fuzzy: ยาวเท่ากันทั้งสตริง)
82
+ // ฝั่งตารางตัด tier (:free/:batch) ก่อนเทียบ — slug เปลือยจะได้ชนแถว :free
83
+ // ที่ราคา 0 จริง (เช่น ling-3.0-flash-fin) กลายเป็น free ไม่ใช่ unpriced
84
+ const slug = stripTier(
85
+ candidateIds(provider, model).at(-1) ?? "",
86
+ ).toLowerCase();
87
+ if (slug) {
88
+ const ids = Object.keys(table.models).sort();
89
+ for (const id of ids) {
90
+ const rawSuffix = id.includes("/")
91
+ ? id.slice(id.lastIndexOf("/") + 1)
92
+ : id;
93
+ if (stripTier(rawSuffix).toLowerCase() === slug) {
94
+ const rates = table.models[id];
95
+ if (
96
+ rates.input === 0 &&
97
+ rates.output === 0 &&
98
+ rates.cacheRead === 0 &&
99
+ (rates.cacheWrite ?? 0) === 0
100
+ )
101
+ return { status: "free", rates };
102
+ return { status: "priced", rates };
103
+ }
104
+ }
105
+ }
106
+ return { status: "unpriced", rates: null };
107
+ }
108
+
109
+ export interface TokenCounts {
110
+ input: number;
111
+ cacheRead: number;
112
+ cacheWrite: number;
113
+ output: number;
114
+ }
115
+
116
+ /**
117
+ * คิดเงิน pure: แยกเรต input / cache-read / cache-write — ห้ามใช้เรตเดียวรวบ
118
+ *
119
+ * reasoning ไม่คูณแยก: ของ Anthropic-family thinking รวมอยู่ใน output อยู่แล้ว
120
+ * (reader แยกเก็บไว้ดูเฉย ๆ) คูณแยก = double count · cache_write ไม่มีในตาราง
121
+ * → fallback เรต input (เขียน cache แพงกว่า/เท่าอ่านสด ไม่มีทางถูกกว่า)
122
+ */
123
+ export function calcCost(t: TokenCounts, rates: ModelRates): number {
124
+ return (
125
+ t.input * rates.input +
126
+ t.cacheRead * rates.cacheRead +
127
+ t.cacheWrite * (rates.cacheWrite ?? rates.input) +
128
+ t.output * rates.output
129
+ );
130
+ }
131
+
132
+ export interface ImputedModel {
133
+ provider: string;
134
+ model: string;
135
+ session_count: number;
136
+ tokens_input: number;
137
+ tokens_output: number;
138
+ tokens_cache_read: number;
139
+ tokens_cache_write: number;
140
+ status: PriceStatus;
141
+ /** ดอลลาร์ list-price — 0 เมื่อ free/unpriced (ดู status อย่าอ่านเลขอย่างเดียว) */
142
+ imputed_cost: number;
143
+ }
144
+
145
+ export interface ImputeSummary {
146
+ total_imputed: number;
147
+ priced_sessions: number;
148
+ free_sessions: number;
149
+ unpriced_sessions: number;
150
+ unpriced_tokens: number;
151
+ by_model: ImputedModel[];
152
+ }
153
+
154
+ /**
155
+ * ตีราคาทั้ง PassiveUsageResult — ใช้กับผลสด (stats/usage) หรือแถว cache
156
+ * (usage-web) ก็ได้เพราะรับแค่ token ต่อ model
157
+ */
158
+ export function imputeResult(
159
+ result: PassiveUsageResult,
160
+ table: PriceTable,
161
+ ): ImputeSummary {
162
+ const by_model: ImputedModel[] = [];
163
+ let total_imputed = 0;
164
+ let priced_sessions = 0;
165
+ let free_sessions = 0;
166
+ let unpriced_sessions = 0;
167
+ let unpriced_tokens = 0;
168
+ for (const m of result.by_model as ModelBreakdown[]) {
169
+ const r = resolvePrice(m.provider, m.model, table);
170
+ let cost = 0;
171
+ if (r.status === "priced" && r.rates) {
172
+ cost = calcCost(
173
+ {
174
+ input: m.tokens_input,
175
+ cacheRead: m.tokens_cache_read,
176
+ cacheWrite: m.tokens_cache_write,
177
+ output: m.tokens_output,
178
+ },
179
+ r.rates,
180
+ );
181
+ total_imputed += cost;
182
+ priced_sessions += m.session_count;
183
+ } else if (r.status === "free") {
184
+ free_sessions += m.session_count;
185
+ } else {
186
+ unpriced_sessions += m.session_count;
187
+ unpriced_tokens +=
188
+ m.tokens_input +
189
+ m.tokens_output +
190
+ m.tokens_cache_read +
191
+ m.tokens_cache_write;
192
+ }
193
+ by_model.push({
194
+ provider: m.provider,
195
+ model: m.model,
196
+ session_count: m.session_count,
197
+ tokens_input: m.tokens_input,
198
+ tokens_output: m.tokens_output,
199
+ tokens_cache_read: m.tokens_cache_read,
200
+ tokens_cache_write: m.tokens_cache_write,
201
+ status: r.status,
202
+ imputed_cost: cost,
203
+ });
204
+ }
205
+ return {
206
+ total_imputed,
207
+ priced_sessions,
208
+ free_sessions,
209
+ unpriced_sessions,
210
+ unpriced_tokens,
211
+ by_model,
212
+ };
213
+ }
@@ -0,0 +1,92 @@
1
+ // src/report/cli.ts — CLI commands for fapony report / report-web
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { writeFileSync } from "node:fs";
5
+ import { dirname, resolve } from "node:path";
6
+ import { loadConfig } from "../db/index.js";
7
+ import { toolVerificationReport } from "../mcp/tools/report.js";
8
+ import { parseToolResult } from "../mcp/types.js";
9
+ import { getStatsData } from "../stats/data.js";
10
+ import { renderReportHtml } from "./render.js";
11
+
12
+ export function cmdReport(args: string[]): void {
13
+ const runId = parseInt(args[0], 10);
14
+ if (!runId || Number.isNaN(runId)) {
15
+ console.error("usage: fapony report <run-id>");
16
+ process.exit(1);
17
+ }
18
+
19
+ const result = toolVerificationReport({ run_id: runId });
20
+ if (result.isError) {
21
+ const data = parseToolResult(result) as { error?: unknown };
22
+ console.error(
23
+ `fapony report: ${typeof data.error === "string" ? data.error : "unknown error"}`,
24
+ );
25
+ process.exit(1);
26
+ }
27
+ console.log(result.content[0].text);
28
+ }
29
+
30
+ /**
31
+ * True when `abs` sits in a git repo and is NOT gitignored — writing the report
32
+ * there means the generated HTML turns up in `git status` and rides along in
33
+ * someone's next commit.
34
+ *
35
+ * Replaces the rule-5 refusal (dropped 2026-09-17). That check blocked any path
36
+ * inside a *configured* worktree, which got it wrong three ways: it refused
37
+ * paths the user had typed themselves, it missed every repo not listed in the
38
+ * config, and it fired even when the path was already gitignored. The hazard was
39
+ * never "inside a worktree" — it is "would be committed".
40
+ */
41
+ export function wouldBeCommitted(abs: string): boolean {
42
+ // check-ignore exits 0 = ignored, 1 = not ignored, 128 = not a repo.
43
+ // A missing git, or a dir that does not exist, leaves status null — stay quiet.
44
+ const probe = spawnSync("git", ["check-ignore", "-q", abs], {
45
+ cwd: dirname(abs),
46
+ stdio: "ignore",
47
+ });
48
+ return probe.status === 1;
49
+ }
50
+
51
+ export function cmdReportWeb(args: string[]): void {
52
+ const config = loadConfig();
53
+ // Accept optional --worktree <path> to scope the report to one project.
54
+ const wtIdx = args.indexOf("--worktree");
55
+ const worktree =
56
+ wtIdx !== -1 && args[wtIdx + 1] ? args[wtIdx + 1] : undefined;
57
+ // --force overrides the rule-5c refusal when the path would be committed.
58
+ const force = args.includes("--force");
59
+ // First positional arg is still the output file (legacy).
60
+ const outFile =
61
+ wtIdx !== -1
62
+ ? args.filter((_a, i) => i !== wtIdx && i !== wtIdx + 1)[0]
63
+ : args[0];
64
+
65
+ // Rule 5c: refuse when the output would end up in git status. The user
66
+ // typed the path — that makes it a commit hazard, not a prohibition case.
67
+ // --force overrides this (for scripts that accept the risk).
68
+ if (outFile && wouldBeCommitted(resolve(outFile))) {
69
+ if (!force) {
70
+ console.error(
71
+ `fapony report-web: ${outFile} is in a git repo and not gitignored — the generated HTML would show up in git status. Add it to .gitignore, write outside the repo, or pass --force to override.`,
72
+ );
73
+ process.exit(1);
74
+ }
75
+ console.error(
76
+ `fapony report-web: ${outFile} is in a git repo and not gitignored — --force written anyway.`,
77
+ );
78
+ }
79
+
80
+ const uw = config.usageWeb ?? {};
81
+
82
+ const ownerName = uw.ownerName?.trim() ? uw.ownerName.trim() : undefined;
83
+ const stats = getStatsData(worktree);
84
+ const html = renderReportHtml(stats, new Date().toISOString(), ownerName);
85
+
86
+ if (outFile) {
87
+ writeFileSync(outFile, html, "utf-8");
88
+ console.log(`report written to ${outFile}`);
89
+ } else {
90
+ console.log(html);
91
+ }
92
+ }
@@ -0,0 +1,37 @@
1
+ // src/report/format.ts — pure formatting helpers for the HTML report
2
+
3
+ export const MIN_SAMPLE_SIZE = 5;
4
+
5
+ export function fmtRate(r: number): string {
6
+ return `${(r * 100).toFixed(0)}%`;
7
+ }
8
+
9
+ export function fmtMinutes(m: number): string {
10
+ if (m < 1) return "<1m";
11
+ return `${m.toFixed(0)}m`;
12
+ }
13
+
14
+ export function insufficientData(total: number, label: string): string {
15
+ if (total < MIN_SAMPLE_SIZE) {
16
+ return `<div class="insufficient">⚠ Insufficient data: ${total} ${label} (need ≥${MIN_SAMPLE_SIZE} for meaningful comparison)</div>`;
17
+ }
18
+ return "";
19
+ }
20
+
21
+ /** Freshness label from an ISO-like timestamp. */
22
+ function freshness(createdAt: string): string {
23
+ const now = Date.now();
24
+ const then = new Date(`${createdAt.replace(" ", "T")}Z`).getTime();
25
+ const days = Math.floor((now - then) / 86400000);
26
+ if (days === 0) return "today";
27
+ if (days === 1) return "yesterday";
28
+ if (days < 7) return `${days}d ago`;
29
+ if (days < 30) return `${Math.floor(days / 7)}w ago`;
30
+ return `${Math.floor(days / 30)}mo ago`;
31
+ }
32
+
33
+ /** Freshness label from a StatsData.latestRunAt string. */
34
+ export function latestRunFreshness(latestRunAt: string): string {
35
+ if (!latestRunAt) return "never";
36
+ return freshness(latestRunAt);
37
+ }
@@ -0,0 +1,4 @@
1
+ // src/report/index.ts — barrel re-export
2
+
3
+ export { cmdReport, cmdReportWeb } from "./cli.js";
4
+ export { renderReportHtml } from "./render.js";