fapony 0.2.0 → 0.3.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.
@@ -235,3 +235,74 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
235
235
  return errorResult(e instanceof Error ? e.message : String(e));
236
236
  }
237
237
  }
238
+
239
+ // --- mem_close ---
240
+ //
241
+ // A separate tool on purpose, not kind:"close" inside mem_add: a close row
242
+ // carries {ref, text} with no files[] and no spec, while mem_add requires a
243
+ // non-empty files[] (and a spec for hold) — folding them into one schema
244
+ // would make required fields depend on the value of another field, the shape
245
+ // models call wrong most often. Mirrors CLI `mem close <id> "<msg>"`
246
+ // (commands/write.ts cmdClose): the id must exist; the tombstone voids the
247
+ // claim by itself.
248
+
249
+ export interface MemCloseResult {
250
+ ref: string;
251
+ text: string;
252
+ ts: string;
253
+ }
254
+
255
+ export function memClose(args: {
256
+ worktree: string;
257
+ id: string;
258
+ text: string;
259
+ }): MemCloseResult {
260
+ if (!args.id.trim()) {
261
+ throw new Error("id is required");
262
+ }
263
+ if (!args.text.trim()) {
264
+ throw new Error("text is required and must not be empty");
265
+ }
266
+
267
+ initStore(args.worktree);
268
+ const all = rows();
269
+ if (!all.some((r) => "id" in r && r.id === args.id)) {
270
+ throw new Error(`no id "${args.id}" in the log`);
271
+ }
272
+
273
+ const ts = new Date().toISOString();
274
+ put({ kind: "close", ref: args.id, text: args.text });
275
+
276
+ return { ref: args.id, text: args.text, ts };
277
+ }
278
+
279
+ export function toolMemClose(args: Record<string, unknown>): ToolResult {
280
+ const worktree =
281
+ typeof args.worktree === "string" ? args.worktree.trim() : "";
282
+ if (!worktree) {
283
+ return errorResult(
284
+ "worktree is required and must be an absolute path " +
285
+ "(git rev-parse --show-toplevel)",
286
+ );
287
+ }
288
+ if (!worktree.startsWith("/")) {
289
+ return errorResult(`worktree must be an absolute path, got: ${worktree}`);
290
+ }
291
+
292
+ const id = typeof args.id === "string" ? args.id.trim() : "";
293
+ if (!id) {
294
+ return errorResult("id is required — the row id to close");
295
+ }
296
+
297
+ const text = typeof args.text === "string" ? args.text.trim() : "";
298
+ if (!text) {
299
+ return errorResult("text is required and must not be empty");
300
+ }
301
+
302
+ try {
303
+ const result = memClose({ worktree, id, text });
304
+ return jsonResult(result);
305
+ } catch (e) {
306
+ return errorResult(e instanceof Error ? e.message : String(e));
307
+ }
308
+ }
@@ -4,13 +4,12 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
- import { mergeBytesByTool, type UsageDetail } from "../session/index.js";
8
7
  import { getServerSha } from "./primitives.js";
9
8
  import {
10
9
  TOOLS,
11
10
  toolMemAdd,
11
+ toolMemClose,
12
12
  toolMemFind,
13
- toolPassiveUsage,
14
13
  toolVerdictSubmit,
15
14
  } from "./tools/index.js";
16
15
  import { errorResult, type ToolResult } from "./types.js";
@@ -39,68 +38,18 @@ Skip it and every tool still answers correctly, on a thinner history.`;
39
38
  //
40
39
  // Written after every MCP tool call. The Claude Code statusline script reads
41
40
  // this file (< 1ms, no spawn, no db). Format: single line of text.
42
- // Only fapony_usage with detail:true produces meaningful data (bytes_by_tool,
43
- // aggregated across all clients — the bytes live on the claude_code
44
- // sub-object, never top-level); other tools write a minimal "fapony" marker.
41
+ // No remaining tool produces bytes_by_tool detail, so every call writes the
42
+ // minimal "fapony" marker — cross-client spend lives on the CLI now
43
+ // (`fapony usage-scan` + `fapony usage-web`), which the statusline cannot
44
+ // spawn (it must stay < 1ms).
45
45
 
46
46
  const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
47
47
 
48
- function writeStatuslineCache(toolResult: ToolResult): void {
48
+ function writeStatuslineCache(): void {
49
49
  try {
50
- // Extract bytes_by_tool from fapony_usage detail JSON response.
51
- let line = "fapony";
52
- if (
53
- toolResult &&
54
- typeof toolResult === "object" &&
55
- "content" in toolResult &&
56
- Array.isArray(toolResult.content)
57
- ) {
58
- for (const c of toolResult.content) {
59
- if (
60
- c &&
61
- typeof c === "object" &&
62
- c.type === "text" &&
63
- typeof c.text === "string"
64
- ) {
65
- // Try to extract bytes_by_tool from JSON text response.
66
- // Aggregated across all clients: the bytes live on the Claude Code
67
- // sub-object (claude_code.detail), never on the top-level detail,
68
- // so reading top-level alone would always miss.
69
- try {
70
- const parsed = JSON.parse(c.text) as {
71
- detail?: UsageDetail | null;
72
- zcode?: { detail?: UsageDetail | null } | null;
73
- claude_code?: { detail?: UsageDetail | null } | null;
74
- codex?: { detail?: UsageDetail | null } | null;
75
- };
76
- const bbt = mergeBytesByTool(
77
- parsed?.detail,
78
- parsed?.zcode?.detail,
79
- parsed?.claude_code?.detail,
80
- parsed?.codex?.detail,
81
- );
82
- const entries = Object.entries(bbt).sort((a, b) => b[1] - a[1]);
83
- const total = entries.reduce((s, e) => s + e[1], 0);
84
- if (total > 0) {
85
- // Format: "84.2k" for total, or "Read 42k · Grep 31k" for top tools.
86
- const fmt = (n: number) =>
87
- n >= 1024 ? `${(n / 1024).toFixed(1)}k` : `${Math.round(n)}`;
88
- if (entries.length <= 3) {
89
- line = `fapony ${entries.map((e) => `${e[0]} ${fmt(e[1])}`).join(" · ")}`;
90
- } else {
91
- line = `fapony ${fmt(total)}`;
92
- }
93
- }
94
- } catch {
95
- // Not JSON — that's fine, use default "fapony" marker.
96
- }
97
- break;
98
- }
99
- }
100
- }
101
50
  const dir = join(homedir(), ".config", "fapony");
102
51
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
103
- writeFileSync(STATUSLINE_PATH, line, "utf-8");
52
+ writeFileSync(STATUSLINE_PATH, "fapony", "utf-8");
104
53
  } catch {
105
54
  // Cache write is best-effort — never block MCP on it.
106
55
  }
@@ -110,7 +59,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
110
59
 
111
60
  const MCP_PROTOCOL_VERSION = "2025-03-26";
112
61
  const SERVER_NAME = "fapony-handcheck";
113
- const SERVER_VERSION = "0.2.0";
62
+ const SERVER_VERSION = "0.3.0";
114
63
 
115
64
  // --- JSON-RPC dispatch ---
116
65
 
@@ -152,20 +101,20 @@ function dispatchToolCall(params: {
152
101
  case "verdict_submit":
153
102
  result = toolVerdictSubmit(args);
154
103
  break;
155
- case "fapony_usage":
156
- result = toolPassiveUsage(args);
157
- break;
158
104
  case "mem_find":
159
105
  result = toolMemFind(args);
160
106
  break;
161
107
  case "mem_add":
162
108
  result = toolMemAdd(args);
163
109
  break;
110
+ case "mem_close":
111
+ result = toolMemClose(args);
112
+ break;
164
113
  default:
165
114
  return errorResult(`unknown tool: ${params.name}`);
166
115
  }
167
116
  // Write statusline cache after every tool call — best-effort, never blocks.
168
- writeStatuslineCache(result);
117
+ writeStatuslineCache();
169
118
  return result;
170
119
  }
171
120
 
@@ -2,10 +2,11 @@
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { basename, join } from "node:path";
5
+ import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
5
6
  import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
6
7
  import { claimsOf, openRows, staleReport } from "../selectors.js";
7
8
  import type { CloseRow, WorkRow } from "../store.js";
8
- import { allRows, app, memCmd, planDir, rows } from "../store.js";
9
+ import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
9
10
  import { planSweepCmd, shippedNotMoved } from "./plan.js";
10
11
  import { THRESHOLD } from "./rotate.js";
11
12
 
@@ -406,6 +407,22 @@ export const cmdKickoff = (a: string[]) => {
406
407
  });
407
408
  }
408
409
 
410
+ // group 4 — the lint baseline. "Is this red mine or was it already red" is
411
+ // already answered by `lint-baseline`, but only if it was captured before
412
+ // the work started — and nobody remembers at session open, which is what
413
+ // kickoff is. Only offered when the repo declared a lint command; without
414
+ // one the capture would just fail.
415
+ try {
416
+ if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
417
+ suggestions.push({
418
+ text: "lint baseline ยังไม่ capture — แดงที่มีอยู่ก่อนจะถูกนับเป็นของคุณ",
419
+ run: "fapony lint-baseline --capture",
420
+ });
421
+ }
422
+ } catch {
423
+ // state dir unreadable — the baseline line is a convenience, never a gate
424
+ }
425
+
409
426
  // Print and optionally execute. Only a suggestion with a complete command is
410
427
  // runnable — a plan item is context, a bug's close is a template needing a message.
411
428
  if (suggestions.length) {
package/src/mem/store.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "node:fs";
16
16
  import { hostname, networkInterfaces } from "node:os";
17
17
  import { basename, dirname, join, relative } from "node:path";
18
+ import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "../db/index.js";
18
19
  import { whereMemDir } from "../memory.js";
19
20
 
20
21
  // --- types ---
@@ -174,7 +175,7 @@ export function initStore(worktree: string, overrideMemDir?: string): void {
174
175
  `two or more .fapony/.memory dirs under ${root} — refusing to guess:\n` +
175
176
  `${list}\ncd into one, set paths.memDir, or pass --mem-dir`;
176
177
  }
177
- dir = resolved.dir ?? join(root, ".fapony", ".memory");
178
+ dir = resolved.dir ?? join(root, DEFAULT_MEM_DIR);
178
179
 
179
180
  // planBase: the .fapony dir — plan/done/conventions live here.
180
181
  // Derive from dir by going up from .fapony/.memory → .fapony
@@ -184,10 +185,11 @@ export function initStore(worktree: string, overrideMemDir?: string): void {
184
185
  app = dirname(planBase);
185
186
 
186
187
  // Read fapony.config.json
187
- const configDir = planBase === `${root}/.fapony` ? root : dirname(planBase);
188
+ const configDir =
189
+ planBase === `${root}/${FAPONY_DIR}` ? root : dirname(planBase);
188
190
  const configPaths = ((): Record<string, string> => {
189
191
  try {
190
- const raw = readFileSync(`${configDir}/fapony.config.json`, "utf8");
192
+ const raw = readFileSync(`${configDir}/${CONFIG_FILENAME}`, "utf8");
191
193
  return (JSON.parse(raw)?.paths ?? {}) as Record<string, string>;
192
194
  } catch {
193
195
  return {};
@@ -198,7 +200,7 @@ export function initStore(worktree: string, overrideMemDir?: string): void {
198
200
  ? join(configDir, configPaths[key])
199
201
  : null;
200
202
 
201
- planDir = fromConfig("planDir") ?? `${planBase}/plan`;
203
+ planDir = `${planBase}/plan`;
202
204
 
203
205
  // done/ sits beside plan/ (same depth, relative links survive)
204
206
  doneDir =
package/src/memory.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { execSync } from "node:child_process";
6
6
  import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
7
7
  import { dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { CONFIG_FILENAME, DEFAULT_MEM_DIR, FAPONY_DIR } from "./db/defaults.js";
8
9
  import { type Config, loadConfig, safetyDeny } from "./db/index.js";
9
10
  import { assertSafe } from "./safety.js";
10
11
  import { templateArgs } from "./util.js";
@@ -202,7 +203,7 @@ function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
202
203
  const boundary = repoRootOf(start) ?? "/";
203
204
  let dir = start;
204
205
  while (true) {
205
- const candidate = join(dir, ".fapony", ".memory");
206
+ const candidate = join(dir, DEFAULT_MEM_DIR);
206
207
  if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
207
208
  return candidate;
208
209
  }
@@ -217,7 +218,13 @@ function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
217
218
  export interface MemDirResult {
218
219
  dir: string | null;
219
220
  step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
220
- /** For step "ambiguous" — the sibling `.fapony/.memory/` dirs that hold logs. */
221
+ /**
222
+ * `.fapony/.memory/` dirs under the repo root that hold logs but are not in
223
+ * scope from `fromDir`. Set for "ambiguous" (two or more, the refusal) and
224
+ * also for "repo-root"/"none" (one) — a caller that reports "nothing
225
+ * recorded here" is lying when a single app-scoped log exists one level
226
+ * down, and the honest line needs the path to say where.
227
+ */
221
228
  candidates?: string[];
222
229
  }
223
230
 
@@ -254,8 +261,8 @@ function findMemDirsUnder(root: string): string[] {
254
261
  }
255
262
  for (const e of entries) {
256
263
  if (!e.isDirectory()) continue;
257
- if (e.name === ".fapony") {
258
- const candidate = join(dir, ".fapony", ".memory");
264
+ if (e.name === FAPONY_DIR) {
265
+ const candidate = join(dir, DEFAULT_MEM_DIR);
259
266
  if (hasMemLogs(candidate)) found.push(candidate);
260
267
  continue;
261
268
  }
@@ -298,7 +305,7 @@ function resolveMemDirFrom(
298
305
  // path source here — spec §1 pins this to fapony.config.json)
299
306
  const configDir = root ?? cwd;
300
307
  try {
301
- const configPath = join(configDir, "fapony.config.json");
308
+ const configPath = join(configDir, CONFIG_FILENAME);
302
309
  if (existsSync(configPath)) {
303
310
  const config = loadConfig(configPath);
304
311
  if (config?.paths?.memDir) {
@@ -319,20 +326,23 @@ function resolveMemDirFrom(
319
326
  // Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
320
327
  // has two or more app-scoped ones — refuse instead of silently creating a
321
328
  // third log at the root that no app-scoped reader will ever see.
322
- if (root) {
323
- const candidates = findMemDirsUnder(root);
324
- if (candidates.length >= 2) {
325
- return { dir: null, step: "ambiguous", candidates };
326
- }
329
+ // One candidate is not ambiguous, so resolution is unchanged — but it still
330
+ // travels back with the result: out-of-scope is not the same as absent.
331
+ const candidates = root ? findMemDirsUnder(root) : [];
332
+ if (candidates.length >= 2) {
333
+ return { dir: null, step: "ambiguous", candidates };
327
334
  }
335
+ const outOfScope = candidates.length ? { candidates } : {};
328
336
 
329
337
  // Step 4: <repo root>/.fapony/.memory/ — where a new log is created
330
338
  if (root) {
331
- const rootDir = join(root, ".fapony", ".memory");
332
- if (existsSync(rootDir)) return { dir: rootDir, step: "repo-root" };
339
+ const rootDir = join(root, DEFAULT_MEM_DIR);
340
+ if (existsSync(rootDir)) {
341
+ return { dir: rootDir, step: "repo-root", ...outOfScope };
342
+ }
333
343
  }
334
344
 
335
- return { dir: null, step: "none" };
345
+ return { dir: null, step: "none", ...outOfScope };
336
346
  }
337
347
 
338
348
  export function resolveMemDir(
package/src/plan-seed.ts CHANGED
@@ -38,6 +38,7 @@ import { basename, join, relative, resolve, sep } from "node:path";
38
38
  import { collectSourceFiles, isSkippedDir, SCAN_EXTS } from "./analyze.js";
39
39
  import { computeModelFit } from "./context/projectHealth.js";
40
40
  import { doneDir, planDir, specDir } from "./db/getters.js";
41
+ import { CONFIG_FILENAME } from "./db/index.js";
41
42
  import { loadConfig } from "./db/load.js";
42
43
  import type { Config } from "./db/types.js";
43
44
  import { extractExports } from "./map.js";
@@ -97,7 +98,7 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
97
98
  if (keys.length === 0) return placeholder;
98
99
 
99
100
  const hits: { shipped: string; line: string }[] = [];
100
- for (const dir of [doneDir(config), specDir(config)]) {
101
+ for (const dir of [doneDir(config), specDir()]) {
101
102
  const abs = join(cwd, dir);
102
103
  let names: string[];
103
104
  try {
@@ -524,7 +525,7 @@ export function cmdPlanSeed(args: string[]): void {
524
525
  process.exit(1);
525
526
  }
526
527
  const cwd = process.cwd();
527
- const config = loadConfig(join(cwd, "fapony.config.json"));
528
+ const config = loadConfig(join(cwd, CONFIG_FILENAME));
528
529
  // Resolve the git worktree root so mem/ledger queries hit the same key
529
530
  // state.db uses (git rev-parse --show-toplevel). Running from a subdir
530
531
  // would otherwise mismatch: stats return empty, Context (fapony) always
@@ -568,7 +569,7 @@ export function cmdPlanSeed(args: string[]): void {
568
569
  );
569
570
  }
570
571
 
571
- const planDirAbs = join(cwd, planDir(config));
572
+ const planDirAbs = join(cwd, planDir());
572
573
  const planPath = join(planDirAbs, `PLAN-${name}.md`);
573
574
  if (existsSync(planPath)) {
574
575
  console.error(
@@ -582,7 +583,7 @@ export function cmdPlanSeed(args: string[]): void {
582
583
 
583
584
  let specLink: string | null = null;
584
585
  if (withSpec) {
585
- const specDirAbs = join(cwd, specDir(config));
586
+ const specDirAbs = join(cwd, specDir());
586
587
  const specPath = join(specDirAbs, `SPEC-${name}.md`);
587
588
  if (existsSync(specPath)) {
588
589
  console.error(
@@ -602,7 +603,7 @@ export function cmdPlanSeed(args: string[]): void {
602
603
  : null,
603
604
  ),
604
605
  );
605
- specLink = `../${specDir(config).split("/").pop()}/SPEC-${name}.md`;
606
+ specLink = `../${specDir().split("/").pop()}/SPEC-${name}.md`;
606
607
  }
607
608
 
608
609
  mkdirSync(planDirAbs, { recursive: true });
@@ -613,7 +614,7 @@ export function cmdPlanSeed(args: string[]): void {
613
614
  priorArt,
614
615
  contextFapony,
615
616
  specLink,
616
- `${planDir(config)}/PLAN-${name}.md`,
617
+ `${planDir()}/PLAN-${name}.md`,
617
618
  ),
618
619
  );
619
620
  console.log(`wrote ${planPath}${specLink ? ` + SPEC-${name}.md` : ""}`);
@@ -270,7 +270,7 @@ export function readDetailFromDb(
270
270
  .all(...filter.params) as DetailPerSessionToolRow[];
271
271
 
272
272
  // Context bytes per tool — summed `state.output` length. Same signal the
273
- // Claude Code/Codex readers derive from tool_result blocks, so `fapony_usage`
273
+ // Claude Code/Codex readers derive from tool_result blocks, so usage-scan
274
274
  // stops reporting zero bytes for the SQLite clients.
275
275
  const bytesRows = db
276
276
  .prepare(
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // Adding a client: write its reader (src/session/<name>.ts, same
4
4
  // PassiveUsageResult shape as an existing one — SQLite or JSONL, whatever the
5
- // client actually stores), then add one entry below. usage-scan and
6
- // fapony_usage both iterate this list; neither needs any other change.
5
+ // client actually stores), then add one entry below. usage-scan iterates
6
+ // this list; nothing else needs any other change.
7
7
  //
8
8
  // This does not remove the real work of a new client (reverse-engineering
9
9
  // its storage format) — it only removes the "wire it into 2 call sites"
@@ -16,12 +16,10 @@ import type { PassiveUsageReader } from "./types.js";
16
16
  import { readZcodeUsage } from "./zcode.js";
17
17
 
18
18
  export interface ClientAdapter {
19
- /** cache `client` field and fapony_usage JSON key. */
19
+ /** usage-cache.jsonl `client` field and usage-scan label default. */
20
20
  key: string;
21
21
  /** usage-scan progress label — defaults to `key`. */
22
22
  scanLabel?: string;
23
- /** fapony_usage text-report section label — defaults to `key`. */
24
- reportLabel?: string;
25
23
  /** The one client whose totals lead the report as the unlabeled top-level summary. */
26
24
  primary?: boolean;
27
25
  read: PassiveUsageReader;
@@ -38,7 +36,6 @@ export const CLIENTS: ClientAdapter[] = [
38
36
  {
39
37
  key: "claude_code",
40
38
  scanLabel: "claude-code",
41
- reportLabel: "claude code",
42
39
  read: readClaudeCodeUsage,
43
40
  },
44
41
  { key: "codex", read: readCodexUsage },
package/src/setup.ts CHANGED
@@ -6,6 +6,7 @@ import { existsSync, statSync, writeFileSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { seedConventionsFile } from "./conventions-seed.js";
9
+ import { CONFIG_FILENAME, FAPONY_DIR } from "./db/index.js";
9
10
  import { initProject } from "./init.js";
10
11
  import { isAffirmative } from "./util.js";
11
12
 
@@ -181,7 +182,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
181
182
  enableMemory,
182
183
  });
183
184
 
184
- const configPath = join(process.cwd(), "fapony.config.json");
185
+ const configPath = join(process.cwd(), CONFIG_FILENAME);
185
186
  if (existsSync(configPath)) {
186
187
  const overwrite = await askFn(
187
188
  "⚠ fapony.config.json already exists. Overwrite? (y/n)",
@@ -197,7 +198,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
197
198
  console.log(`\n ✓ Wrote ${configPath}`);
198
199
 
199
200
  // --- scaffold worktree ---
200
- const faponyDir = join(worktreePath, ".fapony");
201
+ const faponyDir = join(worktreePath, FAPONY_DIR);
201
202
  if (!existsSync(faponyDir)) {
202
203
  try {
203
204
  initProject(worktreePath);
@@ -228,7 +229,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
228
229
  │ fapony install --platform claude │
229
230
  │ │
230
231
  │ 2. Ask your agent: │
231
- │ "Run fapony_stats and fapony_usage" │
232
+ │ "Find past decisions with mem_find" │
232
233
  │ │
233
234
  │ 3. Read a run's report: │
234
235
  │ fapony report <run-id> │
package/src/stats/data.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  // src/stats/data.ts — StatsData shape + getStatsData()
2
2
 
3
- import { readdirSync, readFileSync } from "node:fs";
3
+ import { readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
-
6
- import { type Event, openDb, type Run } from "../db/index.js";
5
+ import { type Event, openDb, PLAN_DIR, type Run } from "../db/index.js";
7
6
  import { loadConfig } from "../db/load.js";
8
7
  import { enrichGateWindows } from "../gates.js";
9
8
  import { avg, minutesBetween } from "../math.js";
@@ -89,9 +88,7 @@ function enrichGates(
89
88
  /**
90
89
  * Count un-shipped plan files in a worktree's planDir.
91
90
  *
92
- * Reads the *target repo's own* fapony.config.json for `paths.planDir` — the
93
- * central config's worktrees map is optional and usually absent, and each repo
94
- * picks its own plan dir (vela uses apps/vela/plan, not .fapony/plan).
91
+ * planDir is hardcoded to .fapony/plan — not configurable (gitignored = private).
95
92
  *
96
93
  * Returns null — never 0 — when the path isn't a readable directory, so a
97
94
  * sentinel row like "mcp-external" renders as "—" instead of claiming
@@ -99,19 +96,10 @@ function enrichGates(
99
96
  */
100
97
  export function countPendingPlans(worktree: string): number | null {
101
98
  if (!worktree.startsWith("/")) return null;
102
- let planDir = ".fapony/plan";
103
- try {
104
- const cfg = JSON.parse(
105
- readFileSync(join(worktree, "fapony.config.json"), "utf8"),
106
- ) as { paths?: { planDir?: unknown } };
107
- if (typeof cfg.paths?.planDir === "string" && cfg.paths.planDir)
108
- planDir = cfg.paths.planDir;
109
- } catch {
110
- // no config (or unreadable/malformed) — fall back to the scaffold default
111
- }
112
99
  try {
113
- return readdirSync(join(worktree, planDir)).filter((f) => f.endsWith(".md"))
114
- .length;
100
+ return readdirSync(join(worktree, PLAN_DIR)).filter((f) =>
101
+ f.endsWith(".md"),
102
+ ).length;
115
103
  } catch {
116
104
  return null;
117
105
  }