fapony 0.1.1 → 0.1.3

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 (44) hide show
  1. package/README.md +22 -36
  2. package/images/logo.png +0 -0
  3. package/images/logo.webp +0 -0
  4. package/images/logo@400.webp +0 -0
  5. package/images/sample.webp +0 -0
  6. package/images/summary.webp +0 -0
  7. package/package.json +10 -8
  8. package/skill/plan-with-pony/SKILL.md +1 -1
  9. package/src/analyze.ts +9 -8
  10. package/src/conventions-seed.ts +10 -10
  11. package/src/db/index.ts +1 -1
  12. package/src/db/store.ts +4 -0
  13. package/src/debt.ts +26 -22
  14. package/src/digest/collect.ts +7 -7
  15. package/src/digest/html.ts +2 -2
  16. package/src/digest/text.ts +1 -1
  17. package/src/gate.ts +3 -3
  18. package/src/hook.ts +119 -21
  19. package/src/init-mem.ts +3 -3
  20. package/src/install/opencode.ts +95 -8
  21. package/src/install/types.ts +1 -1
  22. package/src/install.ts +5 -1
  23. package/src/lint-baseline.ts +9 -9
  24. package/src/mcp/tools/mem.ts +1 -1
  25. package/src/mcp/tools/stats.ts +2 -2
  26. package/src/mcp/tools/usage.ts +3 -3
  27. package/src/mcp/tools/verdict.ts +29 -16
  28. package/src/mcp/transport.ts +1 -1
  29. package/src/plan-seed.ts +23 -21
  30. package/src/price/fetch.ts +19 -19
  31. package/src/price/resolve.ts +24 -24
  32. package/src/review-seed.ts +1 -1
  33. package/src/stats/data.ts +8 -7
  34. package/src/stats/format.ts +6 -5
  35. package/src/usage/render.ts +6 -5
  36. package/templates/PLAN.md +1 -1
  37. package/templates/mem/commands/plan.ts +44 -44
  38. package/templates/mem/commands/read.ts +5 -5
  39. package/templates/mem/commands/rotate.ts +6 -6
  40. package/templates/mem/commands/selftest.ts +14 -14
  41. package/templates/mem/commands/write.ts +13 -13
  42. package/templates/mem/mem.ts +5 -5
  43. package/templates/mem/selectors.ts +18 -18
  44. package/templates/mem/store.ts +50 -50
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}`);
@@ -112,6 +112,7 @@ export function cmdInstallOpencode(
112
112
  const skillsDir = claudeSkillsDir(getHome);
113
113
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
114
114
  installReadHintPlugin(dryRun, getHome);
115
+ installCommitHintPlugin(dryRun, getHome);
115
116
  return;
116
117
  }
117
118
 
@@ -134,6 +135,7 @@ export function cmdInstallOpencode(
134
135
  }
135
136
  console.log(computeDiff(before, after));
136
137
  installReadHintPlugin(dryRun, getHome);
138
+ installCommitHintPlugin(dryRun, getHome);
137
139
  return;
138
140
  }
139
141
 
@@ -154,6 +156,7 @@ export function cmdInstallOpencode(
154
156
  const skillsDir = claudeSkillsDir(getHome);
155
157
  reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
156
158
  installReadHintPlugin(dryRun, getHome);
159
+ installCommitHintPlugin(dryRun, getHome);
157
160
  }
158
161
 
159
162
  /**
@@ -164,35 +167,86 @@ export function cmdInstallOpencode(
164
167
  * throws, never dedupes ("you already read this" goes false after context
165
168
  * compaction — a hook that guesses wrong must never trap the agent).
166
169
  *
167
- * Logic lives in src/hook.ts (readHintFor) — the plugin imports it from the
168
- * install root (path baked at install time), same one-copy-per-client shape
169
- * as the skills symlinks: a git pull in INSTALL_ROOT updates every client.
170
- * Best-effort, same policy as the claude hooks: an existing file that isn't
171
- * fapony's is never overwritten, and a failure never fails the install.
170
+ * Logic lives in src/hook.ts (readHintFor + readContextLines) — the plugin
171
+ * imports it from the install root (path baked at install time), same
172
+ * one-copy-per-client shape as the skills symlinks: a git pull in
173
+ * INSTALL_ROOT updates every client. Mirrors Claude's cmdHookReadHint
174
+ * (PreToolUse), which appends both the size hint and the debt/mem context —
175
+ * OpenCode was missing the second half until now (only the size hint was
176
+ * wired), so a convention debt on the file you just opened, or a mem-log
177
+ * row about it, never reached OpenCode even though the detector already
178
+ * runs on every read. Best-effort, same policy as the claude hooks: an
179
+ * existing file that isn't fapony's is never overwritten, and a failure
180
+ * never fails the install.
172
181
  */
173
182
  export function readHintPluginSource(root: string): string {
174
183
  const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
175
184
  return `// fapony read hint — annotates full-file reads of large source files with a
176
- // factual review-seed pointer. Annotate only: never blocks, never dedupes.
185
+ // factual review-seed pointer, plus any convention debt or mem-log row about
186
+ // the file. Annotate only: never blocks, never dedupes.
177
187
  // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
178
- import { readHintFor } from ${hookModule};
188
+ import { readHintFor, readContextLines } from ${hookModule};
179
189
 
180
190
  export const FaponyReadHint = async ({ directory }) => {
181
191
  return {
182
192
  "tool.execute.after": async (input, output) => {
183
193
  try {
184
194
  if (input.tool !== "read") return;
195
+ const parts = [];
185
196
  const hint = readHintFor({
186
197
  filePath: input.args?.filePath,
187
198
  offset: input.args?.offset,
188
199
  limit: input.args?.limit,
189
200
  cwd: directory,
190
201
  });
202
+ if (hint) parts.push(hint);
203
+ for (const line of readContextLines(input.args?.filePath, directory)) {
204
+ parts.push(line);
205
+ }
206
+ if (parts.length > 0 && typeof output.output === "string") {
207
+ output.output = output.output + "\\n" + parts.join("\\n");
208
+ }
209
+ } catch {
210
+ // a hint must never break a read
211
+ }
212
+ },
213
+ };
214
+ };
215
+ `;
216
+ }
217
+
218
+ /**
219
+ * OpenCode plugin — the commit hint's in-process shape. `tool.execute.after`
220
+ * on the bash tool: when the command contains `git commit` and the worktree
221
+ * has ungraded commits, appends a nudge to the command output. Annotate only
222
+ * (no decision:block — OpenCode has no stop-hook equivalent).
223
+ *
224
+ * Logic lives in src/hook.ts (commitHintFor) — same one-copy-per-client shape
225
+ * as the read hint: a git pull in INSTALL_ROOT updates every client.
226
+ */
227
+ export function commitHintPluginSource(root: string): string {
228
+ const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
229
+ return `// fapony commit hint — annotates git commit bash commands with a
230
+ // verdict reminder when commits have no verdict filed yet.
231
+ // Annotate only: never blocks, never dedupes.
232
+ // Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
233
+ import { commitHintFor } from ${hookModule};
234
+
235
+ export const FaponyCommitHint = async ({ directory }) => {
236
+ return {
237
+ "tool.execute.after": async (input, output) => {
238
+ try {
239
+ if (input.tool !== "bash") return;
240
+ const command = input.args?.command;
241
+ const hint = commitHintFor({
242
+ command,
243
+ cwd: directory,
244
+ });
191
245
  if (hint && typeof output.output === "string") {
192
246
  output.output = output.output + "\\n" + hint;
193
247
  }
194
248
  } catch {
195
- // a hint must never break a read
249
+ // a hint must never break a bash command
196
250
  }
197
251
  },
198
252
  };
@@ -232,3 +286,36 @@ function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
232
286
  ` read hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
233
287
  );
234
288
  }
289
+
290
+ function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
291
+ const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
292
+ const pluginPath = join(pluginsDir, "fapony-commit-hint.ts");
293
+ if (existsSync(pluginPath)) {
294
+ let current = "";
295
+ try {
296
+ current = readFileSync(pluginPath, "utf-8");
297
+ } catch {
298
+ current = "";
299
+ }
300
+ if (current.includes("commitHintFor")) {
301
+ console.error(` commit hint: already installed — no change`);
302
+ return;
303
+ }
304
+ console.error(
305
+ ` commit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
306
+ );
307
+ return;
308
+ }
309
+ if (!dryRun) {
310
+ try {
311
+ mkdirSync(pluginsDir, { recursive: true });
312
+ writeFileSync(pluginPath, commitHintPluginSource(INSTALL_ROOT), "utf-8");
313
+ } catch (e) {
314
+ console.error(` commit hint: failed to write — ${(e as Error).message}`);
315
+ return;
316
+ }
317
+ }
318
+ console.error(
319
+ ` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
320
+ );
321
+ }
@@ -11,7 +11,7 @@ export const MCP_KEY = "fapony";
11
11
 
12
12
  export const MCP_CONFIG = {
13
13
  type: "local",
14
- command: ["bun", "run", "fapony.ts", "mcp"],
14
+ command: ["bun", "run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
15
15
  };
16
16
 
17
17
  /** ZCode uses the stdio MCP shape: command is a string, args is an array.
package/src/install.ts CHANGED
@@ -31,7 +31,11 @@ export {
31
31
  export { cmdInstallCodex } from "./install/codex.js";
32
32
  export { cmdInstallCursor } from "./install/cursor.js";
33
33
  export { detectClients } from "./install/detect.js";
34
- export { cmdInstallOpencode } from "./install/opencode.js";
34
+ export {
35
+ cmdInstallOpencode,
36
+ commitHintPluginSource,
37
+ readHintPluginSource,
38
+ } from "./install/opencode.js";
35
39
  export {
36
40
  agentsSkillsDir,
37
41
  claudeSkillsDir,
@@ -1,15 +1,15 @@
1
1
  // src/lint-baseline.ts — `fapony lint-baseline`: separate "was red before" from "I made it red".
2
2
  //
3
- // warning กับ error ที่แดงอยู่ก่อนหน้า agent เข้ามา ทำให้ agent ต้อง `fix all` ก่อน
4
- // แล้วงานจริงถูกกลบใน diff ก้อนเดียว (SPEC-convention-debt §4 — จากปากเจ้าของ:
5
- // "138 จุดใน ~40 ไฟล์ที่ไม่เกี่ยวกับงาน") ตัวเลขของรีโปที่แดงอยู่แล้ว 212 จุด
6
- // agent ที่ไม่ได้ทำอะไรผิดต้องเห็น **0**
3
+ // warnings and errors already red before the agent arrived force the agent to `fix all` first
4
+ // and then the real work is buried in one large diff (SPEC-convention-debt §4 — from the owner:
5
+ // "138 spots across ~40 files unrelated to the work") the repo's own red count is 212 —
6
+ // an agent that did nothing wrong must see **0**
7
7
  //
8
- // กลไกไม่รู้จัก eslint: รันคำสั่งที่รีโปบอก (--cmd หรือ evidence.json ชื่อ "lint"),
9
- // parse เป็นชุด `path:rule-id` (**ไม่ใช่เลขบรรทัด**บรรทัดขยับทุกครั้งที่แก้ไฟล์),
10
- // เก็บ baseline ไว้ที่ base_sha · ผลลัพธ์ไม่เข้า state.db (2 ตารางห้ามเพิ่ม) —
11
- // เป็นไฟล์ชั่วคราวใต้ state dir และถูกทิ้งเมื่อ --diff รายงานแล้ว · รายงานอย่างเดียว
12
- // ไม่ block — agent ตัดสินเองว่าจะแก้ของเก่าด้วยไหม
8
+ // Mechanismdoes not know eslint: runs the command the repo declares (--cmd or evidence.json named "lint"),
9
+ // parses into a set of `path:rule-id` (**not line numbers** lines shift every time a file is edited),
10
+ // stores the baseline at base_sha · results never enter state.db (2 tables, no additions) —
11
+ // it is a temporary file under the state dir and is discarded once --diff reports · report only
12
+ // never blocksthe agent decides whether to fix the old issues too
13
13
 
14
14
  import { execSync } from "node:child_process";
15
15
  import {
@@ -1,6 +1,6 @@
1
1
  // src/mcp/tools/mem.ts — mem_find: read-only search over the project's mem log
2
2
  //
3
- // "เขียนได้อ่านกลับได้" — the write side works from the CLI with no MCP
3
+ // "writablereadable back" — the write side works from the CLI with no MCP
4
4
  // (vela: 2,920 rows / 49 days); what was missing is "which rows are about the
5
5
  // files I am about to touch". Read-only over readMemLog (PLAN-mem-mcp chunk 2).
6
6
  //
@@ -86,11 +86,11 @@ export function toolFaponyStats(args: Record<string, unknown>): ToolResult {
86
86
  };
87
87
  }
88
88
 
89
- // json:true → StatsData ล้วน (SPEC-verdict-stats) — ห้ามแทรก text อื่น
89
+ // json:true → pure StatsData (SPEC-verdict-stats) — no other text may be inserted
90
90
  if (args.json === true) {
91
91
  return jsonResult(data);
92
92
  }
93
93
 
94
- // json:false → text เดียวกับ `fapony stats` — same formatter, raw (not JSON-wrapped)
94
+ // json:false → the same text as `fapony stats` — same formatter, raw (not JSON-wrapped)
95
95
  return { content: [{ type: "text", text: formatStatsText(data) }] };
96
96
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "../../session/index.js";
14
14
  import { jsonResult, type ToolResult } from "../types.js";
15
15
 
16
- /** สรุป list-price ต่อ client — additive ไม่แตะบรรทัดเดิม */
16
+ /** list-price summary per client — additive, leaves existing lines untouched */
17
17
  function imputationOf(
18
18
  result: PassiveUsageResult,
19
19
  prices: PriceTable | null,
@@ -45,7 +45,7 @@ function imputedTextLines(
45
45
  return [` ${label}list-price equivalent: ${parts.join(" · ")}`];
46
46
  }
47
47
 
48
- /** ราคา list ราย model ต่อท้ายชื่อรุ่นเฉพาะแถวที่ client ไม่บันทึก cost */
48
+ /** list price per model appended to the model name only rows where the client records no cost */
49
49
  function imputedSuffix(
50
50
  provider: string,
51
51
  model: string,
@@ -78,7 +78,7 @@ export function toolPassiveUsage(args: Record<string, unknown>): ToolResult {
78
78
  result: client.read(worktree, since, until, detail),
79
79
  }));
80
80
 
81
- // ราคา list จาก cache อย่างเดียวอ่านครั้งเดียวต่อ call ไม่ใช่ต่อ section
81
+ // list prices from the cache onlyread once per call, not per section
82
82
  const prices = loadPrices();
83
83
 
84
84
  if (args.json === true) {
@@ -79,22 +79,35 @@ export function toolVerdictSubmit(args: Record<string, unknown>): ToolResult {
79
79
  : "mcp-external";
80
80
  const resolvedPlan =
81
81
  typeof plan === "string" && plan ? plan.trim().toLowerCase() : null;
82
- let open = resolvedPlan
83
- ? findOpenRun(db, resolvedWorktree, resolvedPlan)
84
- : null;
85
- // Fallback: if no exact match and plan is non-null, try any open run with
86
- // plan=null. This lets a verdict with free-text intent bind to a run that
87
- // was created without a plan (the common "no PLAN file" flow).
88
- if (!open && resolvedPlan) {
89
- open = findOpenRunWithNullPlan(db, resolvedWorktree);
90
- }
91
- if (open) {
92
- resolvedRunId = open.id;
93
- } else {
94
- // worktree/plan let callers (e.g. move-to-done) attribute the verdict
95
- // so byReasonCode/bestPassing aggregate correctly instead of collapsing
96
- // into "mcp-external".
97
- resolvedRunId = newRun(db, resolvedWorktree, resolvedPlan, null, "mcp");
82
+ // BEGIN IMMEDIATE makes the find-then-create below atomic across
83
+ // concurrent MCP processes: without it, two agents closing the same
84
+ // worktree+plan at once can both see "no open run" and each insert a
85
+ // row. The write lock taken here blocks (busy_timeout, not throws) the
86
+ // second caller until the first commits, so it re-reads and finds the
87
+ // row already there instead of duplicating it.
88
+ db.run("BEGIN IMMEDIATE");
89
+ try {
90
+ let open = resolvedPlan
91
+ ? findOpenRun(db, resolvedWorktree, resolvedPlan)
92
+ : null;
93
+ // Fallback: if no exact match and plan is non-null, try any open run with
94
+ // plan=null. This lets a verdict with free-text intent bind to a run that
95
+ // was created without a plan (the common "no PLAN file" flow).
96
+ if (!open && resolvedPlan) {
97
+ open = findOpenRunWithNullPlan(db, resolvedWorktree);
98
+ }
99
+ if (open) {
100
+ resolvedRunId = open.id;
101
+ } else {
102
+ // worktree/plan let callers (e.g. move-to-done) attribute the verdict
103
+ // so byReasonCode/bestPassing aggregate correctly instead of collapsing
104
+ // into "mcp-external".
105
+ resolvedRunId = newRun(db, resolvedWorktree, resolvedPlan, null, "mcp");
106
+ }
107
+ db.run("COMMIT");
108
+ } catch (e) {
109
+ db.run("ROLLBACK");
110
+ throw e;
98
111
  }
99
112
  }
100
113
 
@@ -112,7 +112,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
112
112
 
113
113
  const MCP_PROTOCOL_VERSION = "2025-03-26";
114
114
  const SERVER_NAME = "fapony-handcheck";
115
- const SERVER_VERSION = "0.1.0";
115
+ const SERVER_VERSION = "0.1.3";
116
116
 
117
117
  // --- JSON-RPC dispatch ---
118
118