fapony 0.1.0 → 0.1.2

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.
package/README.md CHANGED
@@ -86,7 +86,7 @@ Stated up front, because the gap between these two things is where most tooling
86
86
  ## Quick start (MCP)
87
87
 
88
88
  ```bash
89
- # 1. Install (Bun is the only runtime dependency fapony itself has zero packages)
89
+ # 1. Install (needs Bun — https://bun.sh)
90
90
  git clone https://github.com/kire21b/fapony.git && cd fapony
91
91
  bun install
92
92
  bun link # puts `fapony` on your PATH; or run via `bun fapony.ts`
package/fapony.ts CHANGED
@@ -19,7 +19,6 @@ import { cmdReviewSeed } from "./src/review-seed.js";
19
19
  import { cmdSetup } from "./src/setup.js";
20
20
  import { cmdStats } from "./src/stats/index.js";
21
21
  import { cmdTelemetry } from "./src/telemetry.js";
22
- import { cmdTest } from "./src/test.js";
23
22
  import { cmdUpdate } from "./src/update.js";
24
23
  import { cmdUsageScan, cmdUsageWeb } from "./src/usage/index.js";
25
24
 
@@ -68,6 +67,10 @@ if (cmd === "analyze") {
68
67
  } else if (cmd === "usage-web") {
69
68
  cmdUsageWeb(a);
70
69
  } else if (cmd === "test") {
70
+ // dynamic: src/test.js re-exports test/index.js, which the npm package
71
+ // doesn't ship (repo self-check only, not a published command) — a static
72
+ // import here would fail module load for every command, not just this one
73
+ const { cmdTest } = await import("./src/test.js");
71
74
  await cmdTest();
72
75
  } else {
73
76
  console.error(`fapony: unknown command "${cmd ?? ""}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fapony",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Measurement layer for coding agents — measure what agents do, verify what they claim. 6 MCP tools, any agent, no loop required",
5
5
  "license": "MIT",
6
6
  "author": "delamind (https://github.com/kire21b)",
@@ -26,17 +26,18 @@
26
26
  "templates/",
27
27
  "skill/"
28
28
  ],
29
- "devDependencies": {
30
- "@biomejs/biome": "^2.5.12",
31
- "bun-types": "^1.4.0",
32
- "typescript": "^5.9.3"
33
- },
34
29
  "scripts": {
35
30
  "lint": "biome check .",
36
31
  "typecheck": "tsc --noEmit",
37
32
  "test": "bun fapony.ts test",
38
33
  "test:fast": "SKIP_SLOW=1 bun fapony.ts test",
39
34
  "check": "bun run lint && bun run typecheck && bun fapony.ts test",
35
+ "prepublishOnly": "bash scripts/smoke-publish.sh",
40
36
  "overview": "bun fapony.ts report-web /tmp/fapony-overview.html && open /tmp/fapony-overview.html"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "^2.5.12",
40
+ "bun-types": "^1.4.0",
41
+ "typescript": "^5.9.3"
41
42
  }
42
- }
43
+ }
@@ -102,7 +102,7 @@ the real scope instead.
102
102
  So the seed buys you structure; the draft budget goes on judgment:
103
103
 
104
104
  - **Read the Context block, then skip to filling** — everything else is the empty template.
105
- - **Fill every section yourself** — §1–§6 and the TL;DR start as `_agent เติม_` slots.
105
+ - **Fill every section yourself** — §1–§6 and the TL;DR start as `_agent fills in_` slots.
106
106
  - **Run the Phase −1 commands for facts** when the idea needs them, and put the numbers in the
107
107
  section they answer — a number you measured beats a number the seed guessed at.
108
108
  - **Signatures live in the SPEC chunks only.** Never paste them into plan §7 — link to the spec.
package/src/analyze.ts CHANGED
@@ -376,7 +376,7 @@ export function diagnose(
376
376
  findings.push({
377
377
  kind: "cycle",
378
378
  file: cycle.join(" ↔ "),
379
- detail: "import วนกลับหากันrefactor ฝั่งไหนก่อนก็พังอีกฝั่ง",
379
+ detail: "circular importsrefactoring either side breaks the other",
380
380
  evidence: [...cycle, cycle[0]].join(" → "),
381
381
  });
382
382
  }
@@ -389,7 +389,8 @@ export function diagnose(
389
389
  findings.push({
390
390
  kind: "orphan",
391
391
  file: f,
392
- detail: "ไม่มีใคร import และไม่ใช่ entry point — dead code candidate",
392
+ detail:
393
+ "no one imports it and it is not an entry point — dead code candidate",
393
394
  evidence: "0 dependents",
394
395
  });
395
396
  }
@@ -405,8 +406,8 @@ export function diagnose(
405
406
  findings.push({
406
407
  kind: "hub-untested",
407
408
  file,
408
- detail: `${n} ไฟล์พึ่งอยู่ ไม่มีเทสไหน import มันเลยแก้ตรงนี้ไม่มีอะไรจับตอนพัง`,
409
- evidence: `พึ่งอยู่: ${shown}${rest}`,
409
+ detail: `${n} files depend on it; no test imports it edit here and nothing catches the break`,
410
+ evidence: `dependents: ${shown}${rest}`,
410
411
  });
411
412
  }
412
413
 
@@ -417,10 +418,10 @@ export function diagnose(
417
418
  findings.push({
418
419
  kind: "changed-untested",
419
420
  file: c,
420
- detail: `เพิ่งแก้แต่ไม่มีเทสไหนพึ่งอยู่ (${deps.size} dependent) — พังแล้วไม่มีอะไรจับ`,
421
+ detail: `recently changed but no test depends on it (${deps.size} dependent) — nothing catches it if it breaks`,
421
422
  evidence:
422
423
  deps.size > 0
423
- ? `พึ่งอยู่: ${[...deps].sort().join(", ")}`
424
+ ? `dependents: ${[...deps].sort().join(", ")}`
424
425
  : "0 dependents",
425
426
  });
426
427
  }
@@ -475,7 +476,7 @@ export function formatAnalyze(graph: ImportGraph, findings: Finding[]): string {
475
476
  lines.push("");
476
477
 
477
478
  if (findings.length === 0) {
478
- lines.push("no findings — โครงสร้างไม่มีอะไรน่าห่วง");
479
+ lines.push("no findings — structure looks healthy");
479
480
  } else {
480
481
  for (const f of findings.slice(0, 5)) {
481
482
  const icon = f.kind === "orphan" ? "·" : "⚠";
@@ -488,7 +489,7 @@ export function formatAnalyze(graph: ImportGraph, findings: Finding[]): string {
488
489
  if (rest > 0) lines.push(`… and ${rest} more`);
489
490
  lines.push(
490
491
  graph.unresolved > 0
491
- ? `${findings.length} findings. ${graph.unresolved} unresolved imports (path alias / package name) — ตัวเลข dependent อาจต่ำกว่าจริง`
492
+ ? `${findings.length} findings. ${graph.unresolved} unresolved imports (path alias / package name) — dependent counts may be lower than reality`
492
493
  : `${findings.length} findings.`,
493
494
  );
494
495
  }
@@ -1,15 +1,15 @@
1
1
  // src/conventions-seed.ts — `fapony init` fill-signal (PLAN-convention-debt chunk 2).
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 ก่อน)
3
+ // Question: "how far can signal-filling at init go" (SPEC-convention-debt §2.2) —
4
+ // Measured twice; the first answer was wrong: eslint is what most teams leave at default on install,
5
+ // pair-mining breaks on large commits and the cap cannot be relaxed (measured) — what **every repo has**
6
+ // is its own code, so the base is a wrapper detector (reads the snapshot only, never touches history),
7
+ // eslint is a bonus when present, pair-mining is not done yet (chunk 7 measures precision first)
8
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)
9
+ // Written to one place: <target>/.fapony/conventions.json — the convention definition lives in the repo being
10
+ // measured (SPEC §2.1), committable because every field is repo-relative · already exists = leave untouched
11
+ // (rule 5c) · fapony does not guess a convention with no trace one never migrated and
12
+ // with no wrapper has no trace in either snapshot or history, that slot is left for a human to fill (stale)
13
13
 
14
14
  import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join, relative } from "node:path";
@@ -108,7 +108,7 @@ function rowsFromBlock(block: Block, whereBase: string): SeedRow[] {
108
108
  whereGlob && whereGlob !== "." ? `${dir}${whereGlob}` : whereBase || ".";
109
109
  const push = (message: unknown, stale: string | null, ruleId: string) => {
110
110
  const text = flatText(message);
111
- if (!text) return; // checker โดยไม่มีข้อความ = ไม่รู้ว่ากฎคืออะไร
111
+ if (!text) return; // a checker without a message = the rule is unknown
112
112
  out.push({ id: "", rule: text, where, stale, checker: ruleId });
113
113
  };
114
114
  for (const [ruleId, value] of Object.entries(block.rules ?? {})) {
package/src/db/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // barrel — re-export ทุกอย่างจาก db/* ให้ import เดิมจาก "./db/index.js" ใช้ได้
1
+ // barrel — re-export everything from db/* so existing imports from "./db/index.js" keep working
2
2
 
3
3
  export * from "./defaults.js";
4
4
  export * from "./getters.js";
package/src/db/store.ts CHANGED
@@ -80,7 +80,11 @@ export function openDb(config?: Config): Database {
80
80
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
81
81
 
82
82
  const db = new Database(`${dir}/state.db`);
83
+ // bun default busy_timeout=0 = no retry on SQLITE_BUSY; multiple agent
84
+ // processes share this db, so concurrent writers throw immediately without this.
85
+ db.run("PRAGMA busy_timeout=5000");
83
86
  db.run("PRAGMA journal_mode=WAL");
87
+ db.run("PRAGMA synchronous=NORMAL");
84
88
 
85
89
  migrateDb(db);
86
90
 
package/src/debt.ts CHANGED
@@ -1,18 +1,20 @@
1
1
  // src/debt.ts — `fapony debt`: which files have not moved to a shipped convention yet.
2
2
  //
3
- // คำถามที่ไม่มีใครตอบได้: "ไฟล์ไหนยังไม่ย้าย" — rules files (CLAUDE.md, Cursor rules)
4
- // บอกได้แค่ว่า "กฎคืออะไร" (ชั้น 2) และ "ก๊อปไฟล์ไหน" (ชั้น 1) — ตำแหน่งของหนี้
5
- // (ชั้น 3) อยู่ในหัวเจ้าของ และหายเมื่อลืม (SPEC-convention-debt §1)
3
+ // The question nobody can answer: "which files have not moved" — rules files
4
+ // (CLAUDE.md, Cursor rules) can only say "what the rule is" (layer 2) and
5
+ // "which files were copied" (layer 1) where the debt is (layer 3) lives in
6
+ // the owner's head and vanishes when forgotten (SPEC-convention-debt §1)
6
7
  //
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)
8
+ // The convention definition lives in the measured repo (<repo>/.fapony/conventions.json
9
+ // — via the same resolver as the mem log, SPEC §2.1) — fapony does not know React
10
+ // or Hono and must not · one convention = pattern to use (ok) + pattern meaning
11
+ // not-yet-migrated (stale) + scope (where) + file condition (guard, e.g. extends Base)
11
12
  //
12
- // หนี้ถูกคำนวณสดทุกครั้ง ไม่เขียนลงที่ใดเลย (แบบเดียวกับ analyze: cache คือหนี้ล้วน —
13
- // ลิสต์ที่ freeze ไว้ตกรุ่นเงียบ เหมือน MASTER.md) · กฎเหล็ก: checker ไม่ null =
14
- // fapony ไม่รายงานหนี้ข้อนั้น การรายงานซ้ำกับ eslint คือ abstraction ที่มี
15
- // implementation เดียว (กฎ 1) และสอนให้ agent ข้ามทั้งคู่ (SPEC §2)
13
+ // Debt is computed live every time, never written anywhere (same as analyze:
14
+ // a cache is pure debt — a frozen list goes stale silently like MASTER.md) ·
15
+ // Iron rule: checker not null = fapony does not report that debt item — reporting
16
+ // twice with eslint is an abstraction with one implementation (rule 1) and
17
+ // teaches the agent to skip both (SPEC §2)
16
18
  //
17
19
  // Read-only stdout: no file writes, no state.db, no cache (rule 5b).
18
20
 
@@ -242,12 +244,12 @@ export function debtScan(
242
244
  const compiled: Compiled[] = [];
243
245
  for (const conv of loaded.convs) {
244
246
  if (conv.checker) {
245
- // กฎเหล็ก — fapony เงียบ ปล่อยให้ checker ทำงาน (SPEC §2)
247
+ // Iron rule — fapony stays silent, leave it to the checker (SPEC §2)
246
248
  checkedCount++;
247
249
  continue;
248
250
  }
249
251
  if (!conv.stale) {
250
- // ช่องเดียวที่คนเติม (SPEC §2.2) — โชว์ว่าค้าง ไม่เดาแทน
252
+ // The one slot a human fills (SPEC §2.2) — show it as pending, don't guess
251
253
  declared.push(conv);
252
254
  continue;
253
255
  }
@@ -352,16 +354,18 @@ export function debtForFile(
352
354
  return out;
353
355
  }
354
356
 
355
- // --- Promotion signal (chunk 5) — "เรื่องนี้ซ้ำครั้งที่ N แล้ว ทำ checker ไหม" ---
357
+ // --- Promotion signal (chunk 5) — "this recurred N times, time for a checker?" ---
356
358
  //
357
- // "ผมจะทำ eslint ตอนที่คิดได้" — จังหวะ "คิดได้" คือสิ่งที่หายไป (SPEC §3) fapony
358
- // เห็นประวัติข้าม session (mem + verdicts) จึงนับได้ว่าเรื่องเดียวกันถูกแก้ซ้ำกี่ครั้ง
359
- // แล้วยื่นคำถามให้คนตัดสิน ไม่ตัดสินเอง ไม่เขียน eslint rule เอง (SPEC §6 fail list)
359
+ // "I'll write eslint when I think of it" — the "think of it" moment is what goes
360
+ // missing (SPEC §3) · fapony sees history across sessions (mem + verdicts), so it
361
+ // can count how often the same thing was fixed, then put the question to a human —
362
+ // it does not decide, does not write the eslint rule itself (SPEC §6 fail list)
360
363
  //
361
- // การ match "เรื่องเดียวกัน"แม่นยำเท่าที่ข้อมูลให้ (SPEC §7: แถวเก่าไม่มี files[]
362
- // ยังไม่ตัดสิน): แถวที่มี files[] ต้อง intersect กับ debt list · ข้อความต้องเอ่ยถึง
363
- // สัญลักษณ์ของ convention (ok เช่น fmtMoney, หรือ identifier ≥ 6 ตัวจาก stale เช่น
364
- // toLocaleString/useMutation — "throw"/"Error" สั้นเกินจึงไม่นับ กัน over-match)
364
+ // Matching "the same thing" — only as precise as the data allows (SPEC §7: old rows
365
+ // lack files[], still undecided): a row with files[] must intersect the debt list ·
366
+ // the text must mention a convention symbol (ok such as fmtMoney, or an identifier
367
+ // ≥ 6 chars from stale such as toLocaleString/useMutation — "throw"/"Error" are too
368
+ // short and don't count, to avoid over-matching)
365
369
 
366
370
  export const PROMOTION_THRESHOLD = 3;
367
371
  const PROMOTION_MAX = 3;
@@ -642,7 +646,7 @@ export function cmdDebt(args: string[]): void {
642
646
  }
643
647
 
644
648
  if (loaded.path === null) {
645
- // SPEC §6: ไม่มี conventions.json = เงียบสนิท ไม่ error ไม่ชวนสร้าง
649
+ // SPEC §6: no conventions.json = completely silent, no error, no prompt to create one
646
650
  console.log(
647
651
  `fapony debt — no conventions.json in ${worktree} (nothing tracked yet)`,
648
652
  );
@@ -1,6 +1,6 @@
1
- // src/digest/collect.ts — รวมข้อมูลจาก 4 แหล่งเป็น DigestData ก้อนเดียว
1
+ // src/digest/collect.ts — merge data from 4 sources into a single DigestData
2
2
  //
3
- // ไม่ render อะไรเลย แค่อ่าน + จัดรูป struct
3
+ // Renders nothingjust reads + shapes into structs
4
4
 
5
5
  import { execSync } from "node:child_process";
6
6
  import { existsSync, readdirSync, readFileSync } from "node:fs";
@@ -144,7 +144,7 @@ function parseFrontmatter(text: string): { status?: string; kind?: string } {
144
144
  }
145
145
 
146
146
  function countCheckboxes(text: string): { done: number; total: number } {
147
- // นับ checkbox ใน section ## แรกเท่านั้น (same logic as plans.ts)
147
+ // Count checkboxes in the first ## section only (same logic as plans.ts)
148
148
  const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---/, "");
149
149
  const start = body.search(/^##\s+/m);
150
150
  if (start < 0) return { done: 0, total: 0 };
@@ -216,7 +216,7 @@ function readUsageAndCost(_worktree: string): {
216
216
  detail: string;
217
217
  } {
218
218
  const cache = readCache();
219
- // หา global entry (worktree=null) หรือรวมทุก entry
219
+ // Find a global entry (worktree=null), otherwise merge every entry
220
220
  const globalEntries = cache.filter((e) => !e.worktree);
221
221
  if (globalEntries.length === 0 && cache.length === 0) {
222
222
  return {
@@ -232,11 +232,11 @@ function readUsageAndCost(_worktree: string): {
232
232
  };
233
233
  }
234
234
 
235
- // ใช้ global entries ถ้ามี ไม่งั้นรวมทุก entry
235
+ // Use global entries if present, otherwise merge every entry
236
236
  const entries = globalEntries.length > 0 ? globalEntries : cache;
237
237
  const usage = entriesToUsage(entries);
238
238
 
239
- // คำนวณราคา
239
+ // Compute the price
240
240
  const prices = loadPrices();
241
241
  if (!prices) {
242
242
  return {
@@ -271,7 +271,7 @@ function readUsageAndCost(_worktree: string): {
271
271
  imputed: m.imputed_cost,
272
272
  }));
273
273
 
274
- // เพิ่ม cost จริงจาก cache
274
+ // Attach the real cost from the cache
275
275
  const cacheByKey = new Map(
276
276
  entries.flatMap((e) =>
277
277
  e.by_model.map((m) => [`${m.provider}\0${m.model}`, m]),
@@ -1,7 +1,7 @@
1
1
  // src/digest/html.ts — render digest as a single HTML page
2
2
  //
3
- // คอลัมน์เดียว max-width: 68rem · ไม่มี tab ไม่มีปุ่มพับ ไม่มี JS
4
- // ใช้ DARK_THEME_CSS + TABLE_CSS จาก src/web/html.ts
3
+ // single column max-width: 68rem · no tabs, no collapse buttons, no JS
4
+ // uses DARK_THEME_CSS + TABLE_CSS from src/web/html.ts
5
5
 
6
6
  import { DARK_THEME_CSS, esc, TABLE_CSS } from "../web/html.js";
7
7
  import type {
@@ -1,6 +1,6 @@
1
1
  // src/digest/text.ts — render digest as terminal text
2
2
  //
3
- // ลำดับ section ตาม SPEC §3 (เล่าเรื่อง ไม่ใช่ dashboard)
3
+ // Section order per SPEC §3 (tells a story, not a dashboard)
4
4
 
5
5
  import type { CostRow, DigestData, MemRow, PlanRow } from "./collect.js";
6
6
 
package/src/gate.ts CHANGED
@@ -53,9 +53,9 @@ export function gateOnce(
53
53
  }
54
54
 
55
55
  const config = loadConfig();
56
- // runs.worktree เก็บ absolute path (SERVER_INSTRUCTIONS บังคับ) — ไม่ใช่ key ของ config.worktrees
57
- // การ lookup จึงพลาดเสมอ และ fallback เดิม "." ทำให้คำสั่ง memory ของโปรเจกต์ไปรันใน cwd ของ
58
- // MCP server แทนที่จะเป็นโปรเจกต์นั้น · lookup ยังอยู่เผื่อ row เก่าที่เคยเก็บเป็น key
56
+ // runs.worktree stores an absolute path (SERVER_INSTRUCTIONS mandates it) — not a key in config.worktrees
57
+ // so the lookup always misses, and the old "." fallback ran the project's memory commands in the cwd of
58
+ // the MCP server instead of that project · the lookup stays in case an old row was stored as a key
59
59
  const worktree = config.worktrees[run.worktree] ?? run.worktree;
60
60
 
61
61
  // --- pass family (4 grades) ---
package/src/hook.ts CHANGED
@@ -1,17 +1,20 @@
1
1
  // src/hook.ts — Claude Code / Cursor Stop hook: refuse to end a turn that produced
2
2
  // commits but no verdict.
3
3
  //
4
- // ทำไมต้องเป็น hook ไม่ใช่ข้อความ: SERVER_INSTRUCTIONS เป็นการ *ขอ* ให้ agent จำ
5
- // วัดแล้วว่าไม่พอ · hook ไม่ได้ตัดสินเกรดแทน (ตัดสินไม่ได้ มันไม่เห็นว่างานผ่านหรือพัง)
6
- // มันแค่ไม่ให้จบเทิร์นจนกว่า agent จะตัดสินเอง แยก "ใครตัดสิน" ออกจาก "ใครบังคับให้ตัดสิน"
4
+ // Why a hook and not a message: SERVER_INSTRUCTIONS is a *request* that the
5
+ // agent remember, and it measured as not enough · the hook does not grade in
6
+ // the agent's place (it cannot it does not see whether the work passed or
7
+ // broke) it merely won't let the turn end until the agent grades itself,
8
+ // separating "who judges" from "who forces judgment"
7
9
  //
8
- // สัญญาณคือ commit ไม่ใช่ dirty tree — dirty = กำลังทำอยู่, commit = หน่วยงานจบแล้ว
9
- // ตรงกับนิยาม "1 run = 1 หน่วยงานที่วัดได้" (กฎ 7)
10
+ // The signal is a commit, not a dirty tree — dirty = still working, commit =
11
+ // the unit of work is done, matching the definition "1 run = 1 measurable
12
+ // unit of work" (rule 7)
10
13
  //
11
- // สอง payload หนึ่งการตัดสิน — field-mapping เท่านั้น:
14
+ // Two payloads, one decision — field-mapping only:
12
15
  // claude {cwd, transcript_path, stop_hook_active} → {"decision":"block"}
13
16
  // cursor {workspace_roots, conversation_id, loop_count, status} → {"followup_message"}
14
- // (cursor: loop_count ≥ 1 = hook เคยยิงแล้ว, status ≠ completed = ปล่อยผ่าน)
17
+ // (cursor: loop_count ≥ 1 = the hook already fired, status ≠ completed = allow)
15
18
 
16
19
  import { readFileSync, realpathSync, statSync } from "node:fs";
17
20
  import { homedir } from "node:os";
@@ -54,7 +57,7 @@ export function utcStamp(d: Date): string {
54
57
  *
55
58
  * PLAN-mem-mcp chunk 3: the block message now carries the commit list and the
56
59
  * mem-log status (last row date). Both are *information*, never conditions —
57
- * the block condition stays verdict-only (กฎ 7: the hook does not judge, it
60
+ * the block condition stays verdict-only (rule 7: the hook does not judge, it
58
61
  * reports what is pending so the agent decides what deserves recording).
59
62
  */
60
63
  export function decideStop(opts: {
@@ -224,7 +227,7 @@ export async function cmdHookStop(): Promise<void> {
224
227
  .get(worktree, since) as { n: number } | null;
225
228
  verdicts = row?.n ?? 0;
226
229
  // Informational only — read-only, degrade silently (mem status never
227
- // becomes a block condition, กฎ 7).
230
+ // becomes a block condition, rule 7).
228
231
  try {
229
232
  const mem = readMemLog(worktree);
230
233
  memLastTs = mem.rows[0]?.ts ?? null;
@@ -251,15 +254,17 @@ export async function cmdHookStop(): Promise<void> {
251
254
 
252
255
  // --- Read hint (PreToolUse annotate — never block, never dedupe) ---
253
256
  //
254
- // การอ่านไฟล์ใหญ่ทั้งไฟล์เป็นจุดที่ agent จ่าย token โดยไม่รู้ตัว เสียงเตือน
255
- // ใน skill ไม่เคยพอ (หลักเดียวกับ Stop hook: พูดตอนมันกำลังจ่าย) แต่ hook นี้
256
- // **annotate เท่านั้น**: ไม่มี permissionDecision, ไม่มี "อ่านไปแล้ว" dedupe
257
- // context compaction ทำให้ "อ่านไปแล้ว" กลายเป็นเท็จ และ hook ที่เดาผิดแล้วขัง
258
- // agent แย่กว่าไม่มี hook (กฎของ hook.ts เดิม) annotate ขังไม่ได้ด้วย
259
- // construction, ต้นทุนพลาดสูงสุดคือบรรทัดเดียวที่ไม่จำเป็น
257
+ // Reading a large file in full is where an agent spends tokens without
258
+ // noticing — warnings in a skill were never enough (same principle as the
259
+ // Stop hook: speak while it is spending). But this hook **annotates only**:
260
+ // no permissionDecision, no "already read" dedupe context compaction makes
261
+ // "already read" false, and a hook that guesses wrong and traps the agent is
262
+ // worse than no hook (the rule from the original hook.ts) — annotate cannot
263
+ // trap by construction, the worst cost of a miss is one unnecessary line
260
264
  //
261
- // ข้อความเป็น fact ล้วน (จำนวนบรรทัด + คำสั่ง + ค่าที่วัดครั้งเดียว) ไม่ใช่
262
- // estimate ต่อไฟล์ เดา token เป็นการแต่งตัวเป็นข้อมูล ขัด "facts only"
265
+ // The text is facts only (line count + command + a one-time measurement), not
266
+ // a per-file estimate — guessing tokens is dressing up as data, against
267
+ // "facts only"
263
268
 
264
269
  /** Below this size a full read is already cheap — stay silent. */
265
270
  export const READ_HINT_MIN_BYTES = 24_000;
@@ -314,6 +319,98 @@ export function readHintFor(opts: ReadHintInput): string | null {
314
319
  }
315
320
  }
316
321
 
322
+ // --- Commit hint (tool.execute.after — annotate only, never block) ---
323
+ //
324
+ // OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
325
+ // so it cannot block a turn; instead it appends an annotate to the bash tool
326
+ // output whenever there is a git commit with no verdict pending. It is the
327
+ // same kind of nudge as the read hint: no block, no dedupe, every unknown →
328
+ // silent · called from the opencode plugin by direct import (like
329
+ // readHintFor), no CLI subcommand because no client needs it as a subprocess
330
+ // (Cursor uses its own hook-stop instead)
331
+ //
332
+ // The text is facts only (commit list + verdict status), not an estimate
333
+
334
+ /** Below this number of commits, the hint is unnecessary noise. */
335
+ export const COMMIT_HINT_MIN_COMMITS = 1;
336
+ /** Cap commits shown in the hint message. */
337
+ const COMMIT_HINT_MAX_LIST = 5;
338
+
339
+ export interface CommitHintInput {
340
+ command: unknown;
341
+ cwd: string;
342
+ }
343
+
344
+ /**
345
+ * Nudge for bash commands containing `git commit` that produced
346
+ * ungraded commits. Returns a one-to-two line hint string, or null
347
+ * when there is nothing to nudge about (already graded, no commits,
348
+ * not a git commit command, not a git repo, any failure).
349
+ *
350
+ * Every unknown resolves to null — a hint must never fire on a
351
+ * guess. The work is cheap: one git rev-parse + one git log + one
352
+ * SQLite count.
353
+ */
354
+ export function commitHintFor(opts: CommitHintInput): string | null {
355
+ try {
356
+ if (typeof opts.command !== "string" || opts.command === "") return null;
357
+ // Only fire on git commit commands — not `git push`, `git pull`, etc.
358
+ if (!/\bgit\s+commit\b/.test(opts.command)) return null;
359
+
360
+ const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
361
+ if (!worktree) return null;
362
+
363
+ // Window = commits since the worktree's last verdict, not "does a
364
+ // verdict exist anywhere in its history" — a worktree that earned one
365
+ // verdict months ago must still nudge on every commit made since, the
366
+ // same way cmdHookStop windows on `e.ts >= since` (session start) rather
367
+ // than "any verdict this worktree has ever had".
368
+ const db = openDb();
369
+ const lastVerdict = db
370
+ .query(
371
+ `SELECT MAX(e.ts) AS ts FROM events e JOIN runs r ON r.id = e.run_id
372
+ WHERE e.kind = 'gate' AND r.worktree = ?`,
373
+ )
374
+ .get(worktree) as { ts: string | null } | null;
375
+ // git's --since is inclusive to the second, and the commit a verdict
376
+ // just graded often lands in the same UTC second as the verdict itself
377
+ // (verdict_submit runs right after the commit) — bump by 1s so that
378
+ // commit isn't re-flagged as ungraded because of its own grade.
379
+ const since = lastVerdict?.ts
380
+ ? utcStamp(
381
+ new Date(
382
+ new Date(`${lastVerdict.ts.replace(" ", "T")}Z`).getTime() + 1000,
383
+ ),
384
+ )
385
+ : null;
386
+
387
+ const log = since
388
+ ? git(["log", "--since", `${since} +0000`, "--format=%h %s"], worktree)
389
+ : git(["log", "--format=%h %s"], worktree);
390
+ const commitList = log ? log.split("\n").filter(Boolean) : [];
391
+ if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
392
+
393
+ const reason = decideStop({
394
+ stopHookActive: false, // annotate-only: never "already blocked"
395
+ worktree,
396
+ commits: commitList.length,
397
+ verdicts: 0, // every commit left in the window is, by construction, ungraded
398
+ commitList: commitList.slice(0, COMMIT_HINT_MAX_LIST),
399
+ });
400
+ if (!reason) return null;
401
+
402
+ // Prefix each line with "fapony:" so it's visually distinct
403
+ // from normal bash output in the agent's context.
404
+ const prefixed = reason
405
+ .split("\n")
406
+ .map((l) => `fapony: ${l}`)
407
+ .join("\n");
408
+ return prefixed;
409
+ } catch {
410
+ return null; // any failure = no hint
411
+ }
412
+ }
413
+
317
414
  /** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out.
318
415
  * No permissionDecision ever — the tool call always proceeds. */
319
416
  export async function cmdHookReadHint(): Promise<void> {
@@ -355,10 +452,11 @@ export async function cmdHookReadHint(): Promise<void> {
355
452
 
356
453
  // --- Debt + mem context (PLAN-convention-debt chunk 4) ---
357
454
  //
358
- // จังหวะเดียวที่การแก้หนี้คุ้ม token คือตอนที่เปิดไฟล์นั้นอยู่แล้ว hook-read-hint
359
- // จึงแนบสองอย่างต่อท้าย size hint: convention ที่ไฟล์ยังค้าง (debt detector, คำนวณสด)
360
- // และแถว mem ที่เอ่ยถึงไฟล์นั้น (ข้าม session) · **annotate เท่านั้น** เหมือนเดิม —
361
- // ไม่ block, ไม่ dedupe, ทุก unknown → เงียบ · cap รวม 5 บรรทัด (หนี้ 3 · mem 2)
455
+ // The one moment paying down debt is worth tokens is when the file is already
456
+ // open so hook-read-hint appends two things after the size hint: conventions
457
+ // the file still violates (debt detector, computed live) and mem rows that
458
+ // mention the file (across sessions) · **annotate only**, as before no
459
+ // block, no dedupe, every unknown → silent · combined cap 5 lines (debt 3 · mem 2)
362
460
 
363
461
  const DEBT_HINT_MAX = 3;
364
462
  const MEM_HINT_MAX = 2;
package/src/init-mem.ts CHANGED
@@ -31,9 +31,9 @@ export function cmdInitMem(args: string[]): void {
31
31
  const worktreeKey = args.find((x) => !x.startsWith("-"));
32
32
  const config = loadConfig();
33
33
 
34
- // ไม่ระบุ key = repo ที่ยืนอยู่ตอนนี้ทำให้ `fapony init-mem --update` รันในโปรเจกต์ของใครก็ได้
35
- // โดยไม่ต้องลงทะเบียน worktree ก่อน (loadConfig อ่าน fapony.config.json ของ cwd อยู่แล้ว
36
- // จึงได้ paths.memoryEntry ของโปรเจกต์นั้นมาเอง)
34
+ // no key given = the repo you are standing in so `fapony init-mem --update` runs in anyone's project
35
+ // without registering the worktree first (loadConfig already reads the cwd's fapony.config.json,
36
+ // so it picks up that project's paths.memoryEntry by itself)
37
37
  const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
38
38
  if (!worktree) {
39
39
  console.error(`unknown worktree key: ${worktreeKey}`);