fapony 0.1.1 → 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.
@@ -1,7 +1,7 @@
1
- // src/price/resolve.ts — normalize model id + คิด list-price equivalent
1
+ // src/price/resolve.ts — normalize model id + compute list-price equivalent
2
2
  //
3
- // กฎเหล็ก: map ไม่ได้ต้องเป็น unpriced ห้ามตีเป็น 0 เงียบ (failure mode หลัก
4
- // ของฟีเจอร์นี้) · free มีแต่ของที่ราคา 0 จริง (local / แถว :free ในตาราง)
3
+ // Hard rule: an unmappable model must be unpriced, never silently counted as 0 (the main failure mode
4
+ // of this feature) · free only applies to things that truly cost 0 (local / :free rows in the table)
5
5
 
6
6
  import type { ModelBreakdown, PassiveUsageResult } from "../session/types.js";
7
7
  import type { ModelRates, PriceTable } from "./fetch.js";
@@ -13,10 +13,10 @@ export interface PriceResolution {
13
13
  rates: ModelRates | null;
14
14
  }
15
15
 
16
- /** prefix ของ client ที่แปะหน้าทุก model id — ตัดทิ้งแล้ว lookup ใหม่ */
16
+ /** client prefix prepended to every model id — strip it, then look up again */
17
17
  const CLIENT_PREFIXES = ["openrouter/", "opencode-go/", "opencode/"];
18
18
 
19
- /** tier ต่อท้ายของ OpenRouter — :free คือ endpoint ฟรีจริง :batch คือส่วนลด */
19
+ /** OpenRouter's trailing tier — :free is a genuinely free endpoint, :batch is a discount */
20
20
  function stripTier(id: string): string {
21
21
  return id.endsWith(":free") || id.endsWith(":batch")
22
22
  ? id.slice(0, id.lastIndexOf(":"))
@@ -24,8 +24,8 @@ function stripTier(id: string): string {
24
24
  }
25
25
 
26
26
  /**
27
- * รายชื่อ candidate id ตามลำดับความเฉพาะ: ตรงตัวก่อน กว้างทีหลัง
28
- * (ตรงตัวชนก่อนเสมอ — bare slug แมตช์กว้างสุดอยู่ท้าย)
27
+ * Candidate ids ordered by specificity: exact first, broad later
28
+ * (an exact match always wins the bare slug is the broadest match and goes last)
29
29
  */
30
30
  export function candidateIds(provider: string, model: string): string[] {
31
31
  const full = provider ? `${provider}/${model}` : model;
@@ -38,14 +38,14 @@ export function candidateIds(provider: string, model: string): string[] {
38
38
  break;
39
39
  }
40
40
  }
41
- // opencode ต่อ -free ท้าย slug ของรุ่นฟรี (deepseek-v4-flash-free)
41
+ // opencode appends -free to the slug of a free model (deepseek-v4-flash-free)
42
42
  if (rest.endsWith("-free")) out.push(rest.slice(0, -"-free".length));
43
43
  return out;
44
44
  }
45
45
 
46
46
  /**
47
- * หาเรตให้ model หนึ่งตัว คืน null เฉพาะของ local เท่านั้นที่ข้ามตาราง
48
- * (local ไม่เคยมีราคาตั้งแต่แรก ไม่ใช่ "หาไม่เจอ")
47
+ * Find rates for one model — null only for local, which skips the table
48
+ * (local never had a price to begin with, it is not "not found")
49
49
  */
50
50
  function isLocalProvider(provider: string): boolean {
51
51
  return provider === "lmstudio_local";
@@ -59,14 +59,14 @@ export function resolvePrice(
59
59
  if (!model || model === "(no model id)" || model === "(unknown)")
60
60
  return { status: "unpriced", rates: null };
61
61
  if (isLocalProvider(provider)) return { status: "free", rates: null };
62
- // ลงท้าย :free หรือ -free = เรียกผ่าน free endpoint / รุ่นฟรีของ client มา
63
- // ราคาจริงคือ 0 (ไม่ใช่ list price) ไม่ว่าจะ map OpenRouter ได้หรือไม่
62
+ // ends with :free or -free = used a free endpoint / the client's free model
63
+ // the real price is 0 (not list price), whether or not OpenRouter maps it
64
64
  if (model.endsWith(":free") || model.endsWith("-free"))
65
65
  return { status: "free", rates: null };
66
66
  for (const id of candidateIds(provider, model)) {
67
67
  const rates = table.models[id] ?? table.models[stripTier(id)];
68
68
  if (rates) {
69
- // แถวราคา 0 ทั้งแถว (:free / รุ่นฟรี) = free จริง ไม่ใช่ unpriced
69
+ // an all-zero rate row (:free / free model) = genuinely free, not unpriced
70
70
  if (
71
71
  rates.input === 0 &&
72
72
  rates.output === 0 &&
@@ -77,10 +77,10 @@ export function resolvePrice(
77
77
  return { status: "priced", rates };
78
78
  }
79
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
80
+ // zcode stores only the slug with no vendor (GLM-5.3-Flash) — compare the suffix / exact
81
+ // case-insensitive match (exact, not fuzzy: the whole string must be equal in length)
82
+ // the table side strips the tier (:free/:batch) before comparing so a bare slug hits the :free
83
+ // row that is truly 0 (e.g. ling-3.0-flash-fin) and becomes free, not unpriced
84
84
  const slug = stripTier(
85
85
  candidateIds(provider, model).at(-1) ?? "",
86
86
  ).toLowerCase();
@@ -114,11 +114,11 @@ export interface TokenCounts {
114
114
  }
115
115
 
116
116
  /**
117
- * คิดเงิน pure: แยกเรต input / cache-read / cache-write — ห้ามใช้เรตเดียวรวบ
117
+ * Pure costing: separate rates for input / cache-read / cache-write — never one rate for all
118
118
  *
119
- * reasoning ไม่คูณแยก: ของ Anthropic-family thinking รวมอยู่ใน output อยู่แล้ว
120
- * (reader แยกเก็บไว้ดูเฉย ) คูณแยก = double count · cache_write ไม่มีในตาราง
121
- * → fallback เรต input (เขียน cache แพงกว่า/เท่าอ่านสด ไม่มีทางถูกกว่า)
119
+ * reasoning is not multiplied separately: Anthropic-family thinking is already included in output
120
+ * (the reader keeps it separately just for visibility); multiplying separately = double counting · cache_write absent from the table
121
+ * → fall back to the input rate (writing cache costs more than or equal to a fresh read, never less)
122
122
  */
123
123
  export function calcCost(t: TokenCounts, rates: ModelRates): number {
124
124
  return (
@@ -138,7 +138,7 @@ export interface ImputedModel {
138
138
  tokens_cache_read: number;
139
139
  tokens_cache_write: number;
140
140
  status: PriceStatus;
141
- /** ดอลลาร์ list-price — 0 เมื่อ free/unpriced (ดู status อย่าอ่านเลขอย่างเดียว) */
141
+ /** dollars at list-price — 0 when free/unpriced (check status, do not read the number alone) */
142
142
  imputed_cost: number;
143
143
  }
144
144
 
@@ -152,8 +152,8 @@ export interface ImputeSummary {
152
152
  }
153
153
 
154
154
  /**
155
- * ตีราคาทั้ง PassiveUsageResult — ใช้กับผลสด (stats/usage) หรือแถว cache
156
- * (usage-web) ก็ได้เพราะรับแค่ token ต่อ model
155
+ * Price an entire PassiveUsageResult — works on live results (stats/usage) or cache rows
156
+ * (usage-web) alike, because it only takes tokens per model
157
157
  */
158
158
  export function imputeResult(
159
159
  result: PassiveUsageResult,
@@ -544,7 +544,7 @@ function hasDynamicDispatch(absFile: string): boolean {
544
544
 
545
545
  // --- --body: declaration slice (indent-out, no parser) ---
546
546
  // extractBody lives in map.ts beside extractExports — the conventions seeder
547
- // reuses the same slice for wrapper detection (one implementation, กฎ 1).
547
+ // reuses the same slice for wrapper detection (one implementation, rule 1).
548
548
 
549
549
  // --- --callers: symbol→symbol over importer files (identifier scan) ---
550
550
 
package/src/stats/data.ts CHANGED
@@ -622,15 +622,16 @@ function addSessionTokens(
622
622
  }
623
623
 
624
624
  /**
625
- * tokens/pass — retry tax ที่มองเห็นได้ (SPEC-cost-per-pass).
625
+ * tokens/pass — the visible retry tax (SPEC-cost-per-pass).
626
626
  *
627
- * ตัวหารคือ pass-family gate ไม่ใช่ run: bucket เป็น gate อยู่แล้ว และงานที่
628
- * "จบ" คือ gate ที่ผ่าน · token เป็นยอดต่อ session (dedupe แล้ว) ไม่ใช่ต่อ gate —
629
- * session เดียวออกหลาย gate ได้ ถ้า sum ต่อ gate จะคูณเกินไม่เท่ากันทุกโมเดล
627
+ * The divisor is a pass-family gate, not a run: bucket is already a gate, and
628
+ * work "finished" is a gate that passed · token is a per-session total (deduped),
629
+ * not per gate one session can emit several gates, so summing per gate would
630
+ * multiply unevenly across models.
630
631
  *
631
- * `passes=0` หรือ token รวม 0 → null (ไม่ใช่ Infinity/NaN/0): "วัดไม่ได้" ต้อง
632
- * แยกจาก "ฟรี" และห้ามหารศูนย์ · cost/pass ยังไม่มีใน v1 — ไม่มี cache split
633
- * (tokensInput คือ fresh+cache_read+cache_write รวมกัน) จึงคิดราคาไม่ได้โดยไม่เดา
632
+ * `passes=0` or total token 0 → null (not Infinity/NaN/0): "unmeasurable" is not
633
+ * "free" and never divide by zero · no cost/pass in v1 — no cache split
634
+ * (tokensInput is fresh+cache_read+cache_write), so price needs a guess.
634
635
  */
635
636
  function tokensPerPass(
636
637
  passes: number,
@@ -51,9 +51,10 @@ function modelLine(
51
51
  }
52
52
 
53
53
  /**
54
- * บรรทัด list-price equivalent ต่อท้ายแต่ละ usage section — หน่วยเดียวที่
55
- * เทียบข้าม client ได้ (client ที่ไม่บันทึก cost มีราคาติดตรงนี้)
56
- * ราคา list ไม่ใช่เงินที่จ่ายจริง · unpriced แยกออกมาให้เห็น ไม่รวมใน 0
54
+ * list-price equivalent line appended to each usage section — the one unit
55
+ * that compares across clients (clients that do not record cost get a price
56
+ * attached here). List price is not money paid · unpriced is broken out
57
+ * separately, never folded into 0.
57
58
  */
58
59
  function imputedLines(
59
60
  result: PassiveUsageResult,
@@ -103,8 +104,8 @@ export function formatStatsText(data: StatsData): string {
103
104
  if (data.runs.total === 0) return "no runs yet";
104
105
 
105
106
  const lines: string[] = [];
106
- // ราคา list จาก cache อย่างเดียว — query ไม่ fetch เอง (offline ได้, ไม่มี
107
- // ไฟล์ = แสดง — + hint ไม่ throw)
107
+ // list price from cache onlythe query never fetches itself (works
108
+ // offline, missing file = shows — + hint, never throws)
108
109
  const prices = loadPrices();
109
110
 
110
111
  if (data.scope) {
@@ -304,8 +304,9 @@ function shareSection(
304
304
  }
305
305
 
306
306
  /**
307
- * เติมราคาตั้งให้แถวที่ client ไม่บันทึก cost (0) — ของที่มีราคาจริงอยู่แล้ว
308
- * ไม่แตะ · คืน view ไว้ render + note ไว้ใต้ตาราง (ป้าย list-price ทุกจุด)
307
+ * Imputes list prices for rows where the client records no cost (0) — rows
308
+ * that already have a real price are left alone · returns a view to render
309
+ * plus a note for below the table (list-price label everywhere)
309
310
  */
310
311
  function withImputed(
311
312
  data: PassiveUsageResult | null,
@@ -332,7 +333,7 @@ function withImputed(
332
333
  tokens_reasoning: real?.tokens_reasoning ?? 0,
333
334
  tokens_cache_read: m.tokens_cache_read,
334
335
  tokens_cache_write: m.tokens_cache_write,
335
- // รวมกับ cost จริงรายรุ่น (opencode บันทึกเอง) — ข้างไหนมีค่ากว่ากันเอาข้างนั้น
336
+ // combine with the real per-model cost (OpenCode records its own) — whichever has a value wins
336
337
  cost: real && real.cost > 0 ? real.cost : m.imputed_cost,
337
338
  };
338
339
  });
@@ -363,7 +364,7 @@ export function renderUsageHtml(
363
364
  .filter((k) => k !== "__global__")
364
365
  .sort();
365
366
 
366
- // ราคา list จาก cache อย่างเดียวserve ไม่ fetch เอง (offline ได้)
367
+ // list price from cache onlyserving never fetches itself (works offline)
367
368
  const table = prices === undefined ? loadPrices() : prices;
368
369
 
369
370
  const owner = ownerName?.trim() ? esc(ownerName.trim()) : "";
@@ -395,7 +396,7 @@ export function renderUsageHtml(
395
396
 
396
397
  const heading = isGlobal ? "All projects" : shortWt(label);
397
398
 
398
- // การ์ดที่ไม่มี session เลยไม่มีอะไรให้ดูซ่อนแทนที่จะโชว์ "no sessions"
399
+ // a card with no sessions has nothing to show hide it instead of showing "no sessions"
399
400
  const cards = [
400
401
  ["OpenCode", "var(--green)", pOc],
401
402
  ["ZCode", "var(--accent)", pZc],
package/templates/PLAN.md CHANGED
@@ -24,7 +24,7 @@ spec: SPEC-<feature>.md # if any
24
24
  - **Done when:** one line, testable
25
25
  - **Order:** what this waits on / what it unblocks (mirrors the frontmatter)
26
26
  - **Progress:**
27
- - [x] chunk 1 — <what landed> `<short sha>` <YYYY-MM-DD>
27
+ - [x] chunk 1 — <what landed> `<short sha>` <YYYY-MM-DD> · verdict: <grade>
28
28
  - [ ] chunk 2 — <what is next>
29
29
 
30
30
  ---
@@ -1,9 +1,9 @@
1
- // commands/plan.ts — plan-sweep: หา PLAN-*.md ที่ header บอก shipped แล้วแต่ยังไม่ย้ายเข้า done/
2
- // เหตุผล: ย้ายมือ = ต้องไล่แก้ relative link เอง (ในไฟล์ + ไฟล์อื่นที่ลิงก์มา) → ข้ามขั้นตอนบ่อย
3
- // ไม่มี arg = report เฉยๆ (ปลอดภัย โชว์ทุก kickoff/stale run ได้)
4
- // <file.md> = เช็คไฟล์เดียวว่าพร้อมย้ายไหม
5
- // <file.md> --apply = git mv + แก้ markdown link ในไฟล์เอง + แก้ inbound link จากไฟล์อื่นใน plan/
6
- // + แจ้ง warning plain-text mention (detect-only, ไม่ auto-fix)
1
+ // commands/plan.ts — plan-sweep: find PLAN-*.md whose header says shipped but not yet moved into done/
2
+ // rationale: moving by hand = chasing relative links yourself (in the file + files that link to it) → the step gets skipped often
3
+ // no arg = report only (safe, shows every kickoff/stale run)
4
+ // <file.md> = check a single file, is it ready to move
5
+ // <file.md> --apply = git mv + fix markdown links inside the file + fix inbound links from other files in plan/
6
+ // + warn about plain-text mentions (detect-only, no auto-fix)
7
7
 
8
8
  import {
9
9
  existsSync,
@@ -21,13 +21,13 @@ const SHIPPED = /^>\s*✅/m;
21
21
  const FRONT = /^---\r?\n([\s\S]*?)\r?\n---/;
22
22
  const HELD = /^status:\s*(blocked|superseded)\b/m;
23
23
 
24
- // header ✅ shipped ไม่ได้อยู่บรรทัดแรกอีกแล้ว — plan format ปัจจุบันขึ้นต้นด้วย frontmatter
25
- // แล้วตามด้วย `# title` (ดู templates/PLAN.md) เช็กหัวไฟล์แทนที่จะเช็กบรรทัดแรกบรรทัดเดียว
24
+ // the ✅ shipped header is no longer on the first line the current plan format starts with frontmatter
25
+ // then `# title` (see templates/PLAN.md); check the file's head rather than a single first line
26
26
  //
27
- // frontmatter ชนะ headerเสมอ: plan ที่ ship ไปบาง chunk แล้วติดรอของข้างนอก (VPS, คนใช้,
28
- // การตัดสินใจ) เขียน `status: blocked` ไว้ = ตั้งใจให้อยู่ใน plan/ ต่อ ไม่ใช่ของที่ลืมย้าย
29
- // ถ้าไม่ดูตรงนี้ plan แบบนั้นจะขึ้น "shipped but never archived" ทุกครั้งไปตลอด แล้วคนก็เลิกอ่าน
30
- // รายการนี้ทั้งรายการซึ่งเป็นอาการเดียวกับที่ทำให้ done/ ไม่เคยขยับตั้งแต่แรก
27
+ // frontmatter always beats the header: a plan that shipped some chunks and is waiting on externals (VPS, users,
28
+ // a decision) writes `status: blocked` = it is meant to stay in plan/, not forgotten to move
29
+ // if this is not checked, such plans show "shipped but never archived" forever, and people stop reading
30
+ // the whole list the same symptom that kept done/ from ever moving in the first place
31
31
  export const hasShippedHeader = (file: string): boolean => {
32
32
  const head = readFileSync(file, "utf8").slice(0, 2048);
33
33
  if (!SHIPPED.test(head)) return false;
@@ -49,10 +49,10 @@ const mdFiles = (dir: string): string[] =>
49
49
 
50
50
  const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
51
 
52
- // ไฟล์ย้าย dir แล้ว (เนื้อหาเดิม) — markdown link เดิมหมายถึง path เดิมเมื่ออิง oldDir, ต้อง re-relativize ผ่าน newDir
53
- // resolve จาก newDir (ที่ไฟล์อยู่ตอนนี้) — ถ้า target ก็ย้ายมา same dir → ได้ชื่อไฟล์ล้วน,
54
- // ถ้า target ยังอยู่ oldDir → ได้ ../target (ถูกทั้งสองทาง)
55
- // pass 1 only — แก้เฉพาะ [text](target) markdown links, ไม่แตะ plain text
52
+ // the file moved dir (same content) — an old markdown link meant the old path relative to oldDir, must re-relativize via newDir
53
+ // resolve from newDir (where the file is now) — if the target also moved to the same dir → plain filename,
54
+ // if the target stayed in oldDir → ../target (both correct)
55
+ // pass 1 only — fix only [text](target) markdown links, do not touch plain text
56
56
  export const rewriteMovedFileLinks = (
57
57
  file: string,
58
58
  oldDir: string,
@@ -76,8 +76,8 @@ export const rewriteMovedFileLinks = (
76
76
  return n;
77
77
  };
78
78
 
79
- // ไฟล์อื่นที่ลิงก์ชี้มาที่ path เดิม (oldAbs) → แก้ให้ชี้ path ใหม่ (newAbs) แทน
80
- // pass 1 only — แก้เฉพาะ [text](target) markdown links, ไม่แตะ plain text
79
+ // other files whose links point at the old path (oldAbs) → repoint them at the new path (newAbs)
80
+ // pass 1 only — fix only [text](target) markdown links, do not touch plain text
81
81
  export const rewriteMarkdownLinks = (
82
82
  file: string,
83
83
  oldAbs: string,
@@ -99,18 +99,18 @@ export const rewriteMarkdownLinks = (
99
99
  return n;
100
100
  };
101
101
 
102
- // นับ plain-text mention ของ target filename ในไฟล์ (detect-only, ไม่เขียนไฟล์)
103
- // regex: ไม่ใช่ markdown link [text](url) — จับทั้งแบบมี/ไม่มี .md extension
104
- // ครอบคลุม: prose, backtick code span, code fence — ทุก context ที่ไม่ใช่ markdown link
102
+ // count plain-text mentions of the target filename in a file (detect-only, writes nothing)
103
+ // regex: not a markdown link [text](url) — catches both with/without the .md extension
104
+ // covers: prose, backtick code span, code fence — every context that is not a markdown link
105
105
  //
106
- // Fix (2026-09-02): ตัด markdown link ทั้งก้อน [text](target) ออกก่อน (ทั้ง display text และ
107
- // target — ไม่ใช่แค่ target) แล้วค่อยรัน plain-text regex บน string ที่เหลือ โดยไม่ต้องพึ่ง
108
- // lookbehind แล้ว — lookbehind เดิม `(?<![/\[(])` ตั้งใจกัน false-positive จาก markdown link
109
- // แต่ผลข้างเคียงคือ exclude ทุก mention ที่มี `/` นำหน้าไปด้วย (เช่น `done/PLAN-x.md` หรือ
110
- // `apps/vela/plan/PLAN-x.md`) — false-negative ตัวจริงที่พลาดใน 6e411042
111
- // Fix รอบแรก (ตัดแค่ `](target)`) พลาดเหลือ `[display-text]` ไว้ไม่ได้ตัด ทำให้ link ที่ถูกต้อง
112
- // อยู่แล้วอย่าง `[PLAN-x.md](../done/PLAN-x.md)` (pattern จริงในไฟล์นี้ทั้งหมด) โดน count ซ้ำเป็น
113
- // plain-text mention — ต้องตัดทั้งก้อน [..](..) ไม่ใช่แค่ส่วน (..)
106
+ // Fix (2026-09-02): strip the whole markdown link [text](target) first (both display text and
107
+ // target — not just target), then run the plain-text regex on the remainder, no longer needing
108
+ // lookbehind — the old lookbehind `(?<![/\[(])` was meant to stop markdown-link false-positives
109
+ // but its side effect was excluding every mention with a leading `/` too (e.g. `done/PLAN-x.md` or
110
+ // `apps/vela/plan/PLAN-x.md`) — the real false-negative that slipped through in 6e411042
111
+ // The first fix (stripping only `](target)`) missedit left `[display-text]` unwrapped, making an already
112
+ // valid link like `[PLAN-x.md](../done/PLAN-x.md)` (the actual pattern throughout this file) get double-counted as a
113
+ // plain-text mention — strip the whole [..](..) block, not just the (..) part
114
114
  export const countPlainTextMentions = (
115
115
  file: string,
116
116
  target: string,
@@ -128,7 +128,7 @@ export const countPlainTextMentions = (
128
128
  return count;
129
129
  };
130
130
 
131
- // ใช้ร่วมกับ dashboard (now/kickoff) — ไฟล์ plan/ ที่มี header shipped แต่ยังไม่ย้ายเข้า done/
131
+ // shared with the dashboard (now/kickoff) — plan/ files with a shipped header but not yet moved into done/
132
132
  export const shippedNotMoved = (): string[] => {
133
133
  const dir = planDir;
134
134
  if (!existsSync(dir)) return [];
@@ -187,8 +187,8 @@ export const cmdPlanSweep = (a: string[]) => {
187
187
  return;
188
188
  }
189
189
 
190
- // ponytail: --apply เดิม (ก่อนหน้านี้) ไม่บังคับ 2 เงื่อนไขนี้เลยผ่าน dry-run message ได้ก็จริง
191
- // แต่รัน --apply ตรง ข้ามได้หมดเสี่ยงเวลา agent ship เองอัตโนมัติไม่มีคนเช็ค แก้เป็น hard block
190
+ // ponytail: the old --apply enforced neither of these two conditions you could pass the dry-run message
191
+ // but then run --apply directly and skip everything risky when an agent ships automatically with no human check, so hard block
192
192
  if (!shipped && !process.env.MEM_FORCE) {
193
193
  console.error(
194
194
  `${target}: no ✅ shipped header at the top — refusing to move (MEM_FORCE=1 to override)`,
@@ -211,8 +211,8 @@ export const cmdPlanSweep = (a: string[]) => {
211
211
  process.exit(1);
212
212
  }
213
213
 
214
- // ponytail: ไฟล์ที่เพิ่งเขียนในรอบนี้อาจยังไม่ git add — `git mv` fail แบบเงียบ (exit 128, ไม่ throw)
215
- // แล้วโค้ดต่อไปพัง ENOENT ตอนอ่าน dst ที่ไม่มีจริง — stage ก่อนเสมอ (no-op ถ้า track อยู่แล้ว)
214
+ // ponytail: a file just written this round may not be git add'ed yet — `git mv` fails silently (exit 128, no throw)
215
+ // then the next code hits ENOENT reading a dst that does not exist always stage first (no-op if already tracked)
216
216
  mkdirSync(doneDir, { recursive: true });
217
217
  Bun.spawnSync(["git", "add", src]);
218
218
  const mv = Bun.spawnSync(["git", "mv", src, dst]);
@@ -221,8 +221,8 @@ export const cmdPlanSweep = (a: string[]) => {
221
221
  process.exit(1);
222
222
  }
223
223
 
224
- // done/ เป็นพี่น้องกับ plan/ = ลึกเท่าเดิม ลิงก์ในไฟล์ยังชี้ถูกทุกเส้น ไม่ต้องแตะ
225
- // layout เก่า (plan/done/) ลึกขึ้น 1 ชั้น ถึงจะต้อง re-relativize
224
+ // done/ is a sibling of plan/ = same depth, links in the file still resolve, no need to touch
225
+ // the old layout (plan/done/) is one level deeper, so it does need re-relativizing
226
226
  const nested = dirname(doneDir) !== dirname(dir);
227
227
  const ownLinks = nested ? rewriteMovedFileLinks(dst, dir, doneDir) : 0;
228
228
 
@@ -247,7 +247,7 @@ export const cmdPlanSweep = (a: string[]) => {
247
247
  `inbound links rewritten: ${inbound} in ${inboundFiles} file(s) (scanned ${rel(dir)}/** only)`,
248
248
  );
249
249
 
250
- // log decision — record ship event (reuse existing kind, ไม่ต้อง schema ใหม่)
250
+ // log decision — record ship event (reuse existing kind, no new schema)
251
251
  const doneSpec = `${rel(dst)}`;
252
252
  put({
253
253
  id: nextId(rows()),
@@ -255,11 +255,11 @@ export const cmdPlanSweep = (a: string[]) => {
255
255
  text: `${target} shipped → ${rel(dst)}`,
256
256
  spec: doneSpec,
257
257
  });
258
- // ponytail: decision นี้ *คือ* การย้ายเอง ไม่มีอะไรต้องเขียนกลับเข้า spec อีก ไม่ mark synced
259
- // ทันที staleReport จะขึ้น "decision ยังไม่เข้า spec" ทุกครั้งที่ ship (เห็นจริงใน kickoff 2026-09-02)
258
+ // ponytail: this decision *is* the move itself, nothing to write back into the spec — do not mark synced
259
+ // immediately or staleReport shows "decision never made it into the spec" on every ship (seen in kickoff 2026-09-02)
260
260
  put({ kind: "synced", spec: doneSpec });
261
261
 
262
- // plain-text mention detection (detect-only, ไม่ auto-fix)
262
+ // plain-text mention detection (detect-only, no auto-fix)
263
263
  let plainTextTotal = 0;
264
264
  const plainTextFiles: string[] = [];
265
265
  for (const f of mdFiles(dir)) {
@@ -275,14 +275,14 @@ export const cmdPlanSweep = (a: string[]) => {
275
275
  );
276
276
  }
277
277
 
278
- // ไฟล์นอก plan/ ที่ mention target — detect-only
278
+ // files outside plan/ that mention the target — detect-only
279
279
  const grep = Bun.spawnSync([
280
280
  "git",
281
281
  "grep",
282
282
  "-l",
283
283
  target,
284
284
  "--",
285
- // ขอบเขต "ไฟล์นอก plan/" = โฟลเดอร์ที่ plan/ อยู่ใต้มัน (apps/vela, .fapony, …)
285
+ // the "files outside plan/" scope = the folder plan/ lives under (apps/vela, .fapony, …)
286
286
  rel(dirname(planDir)),
287
287
  `:!${rel(dir)}`,
288
288
  ])
@@ -324,8 +324,8 @@ export const cmdPlanCheck = (a: string[]) => {
324
324
  // only check active files (not done/) — done/ files are historical snapshots with external refs
325
325
  const linkRe = /\]\(([^)]+)\)/g;
326
326
  for (const f of active) {
327
- // ตัด fenced block + inline code ทิ้งก่อน (แทนที่ด้วย spaces เพื่อคง line offset) —
328
- // ตัวอย่าง link ใน code (เช่น spec ของเครื่องมือนี้เอง) ต้องไม่ถูกนับเป็น link จริง
327
+ // strip fenced blocks + inline code first (replace with spaces to preserve line offset) —
328
+ // example links in code (e.g. this tool's own spec) must not be counted as real links
329
329
  const src = readFileSync(f, "utf8")
330
330
  .replace(/```[\s\S]*?```/g, (b) => b.replace(/[^\n]/g, " "))
331
331
  .replace(/`[^`\n]*`/g, (b) => " ".repeat(b.length));
@@ -22,7 +22,7 @@ const planSweepLine = () => {
22
22
  };
23
23
 
24
24
  export const cmdNow = () => {
25
- // mem now (default) — next+bug+hold. decision/note ไม่ใช่งานค้างค้นด้วย find แทน
25
+ // mem now (default) — next+bug+hold. decision/note is not pending work search with find instead
26
26
  const all = rows();
27
27
  console.log(`# ${app} — ${all.length} entries`);
28
28
  printOpenRows(all, { showHold: true });
@@ -53,7 +53,7 @@ export const cmdStale = () => {
53
53
  };
54
54
 
55
55
  export const cmdFind = (a: string[]) => {
56
- // mem find <คำ> — grep text/spec ไม่สนตัวพิมพ์เล็กใหญ่, ล่าสุดก่อน, จำกัด 20 แถว
56
+ // mem find <word> — grep text/spec case-insensitively, newest first, capped at 20 rows
57
57
  const q = a.join(" ").toLowerCase();
58
58
  if (!q) {
59
59
  console.error(`usage: ${memCmd} find <word>`);
@@ -86,7 +86,7 @@ export const cmdKickoff = (a: string[]) => {
86
86
  const arg = a[0] ?? "";
87
87
 
88
88
  if (!arg) {
89
- // ไม่มี args = now + section "ล่าสุด" = closes 10 รายการล่าสุด
89
+ // no args = now + a "recent" section = the last 10 closes
90
90
  console.log(`# ${app} — ${all.length} entries`);
91
91
  printOpenRows(all, { showHold: true });
92
92
  console.log(`\n## recent\n${doneLines(all, 10).join("\n")}`);
@@ -101,7 +101,7 @@ export const cmdKickoff = (a: string[]) => {
101
101
  const rotate = rotateLine(all.length);
102
102
  if (rotate) console.log(rotate);
103
103
  } else if (arg.endsWith(".md")) {
104
- // spec.md = brief ของ spec นั้น
104
+ // spec.md = a brief for that spec
105
105
  const workAll = all.filter((r): r is WorkRow => "id" in r);
106
106
  const byId = new Map(workAll.map((r) => [r.id, r] as const));
107
107
  const open = openRows(all);
@@ -135,7 +135,7 @@ export const cmdKickoff = (a: string[]) => {
135
135
  console.log("(no entries for this spec)");
136
136
  }
137
137
  } else {
138
- // id = brief ของงานนั้น
138
+ // id = a brief for that task
139
139
  const workAll = all.filter((r): r is WorkRow => "id" in r);
140
140
  const byId = new Map(workAll.map((r) => [r.id, r] as const));
141
141
  const target = byId.get(arg);
@@ -1,15 +1,15 @@
1
1
  // commands/rotate.ts — compact log.jsonl once it grows past a row-count threshold
2
- // เก็บ open work rows + active claim ไว้ ที่เหลือ (close/release/synced ของ ref ที่ปิดแล้ว)
3
- // git mv ไปไฟล์ archive แยก (ไม่ลบ) — ประวัติเก่ายังอยู่ ค้นย้อนหลังได้ผ่าน git log/git show
2
+ // keep open work rows + active claims; the rest (close/release/synced of already-closed refs)
3
+ // is git mv'd to a separate archive file (not deleted) — old history remains, searchable via git log/git show
4
4
 
5
5
  import { existsSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { rotateKeep } from "../selectors.js";
8
8
  import { appendRaw, dir, LOG, rows } from "../store.js";
9
9
 
10
- // ponytail: threshold = จำนวนแถวทั้งหมด ไม่ใช่แค่ open — ที่กลัวคือไฟล์บวม/grep ช้าตอนหลายคนใช้พร้อมกัน
11
- // (view อ่านไม่รู้เรื่องเป็นปัญหาคนละอันที่ CAP ใน write.ts จัดการอยู่แล้ว)
12
- // 3000 กะจาก solo 1 สัปดาห์ = ~2k แถวปรับได้ด้วย MEM_ROTATE_THRESHOLD ถ้า pace ต่างจากนี้มาก
10
+ // ponytail: threshold = total row count, not just open — the fear is a bloated file / slow grep when many people use it at once
11
+ // (the view being unreadable is a separate problem the CAP in write.ts already handles)
12
+ // 3000 estimated from one solo week = ~2k rowstune with MEM_ROTATE_THRESHOLD if the pace differs a lot from this
13
13
  export const THRESHOLD = Number(process.env.MEM_ROTATE_THRESHOLD) || 3000;
14
14
 
15
15
  export const cmdRotate = (a: string[]) => {
@@ -40,7 +40,7 @@ export const cmdRotate = (a: string[]) => {
40
40
  process.exit(1);
41
41
  }
42
42
 
43
- // เหมือน plan-sweep: stage ก่อนกัน git mv fail เงียบถ้าไฟล์ยังไม่ track (exit 128)
43
+ // like plan-sweep: stage first to stop git mv failing silently if the file is untracked (exit 128)
44
44
  Bun.spawnSync(["git", "add", LOG]);
45
45
  const mv = Bun.spawnSync(["git", "mv", LOG, archived]);
46
46
  if (mv.exitCode !== 0) {
@@ -37,7 +37,7 @@ const runSelectorTests = () => {
37
37
  { id: "a2", kind: "next", text: "work B", ts: "", agent: "" },
38
38
  { kind: "close", ref: "a2", text: "done", ts: "", agent: "" },
39
39
  { kind: "claim", ref: "a1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
40
- // (1) claim เดียว → active
40
+ // (1) single claim → active
41
41
  { id: "a3", kind: "bug", text: "work C", ts: "", agent: "" },
42
42
  { kind: "claim", ref: "a3", ts: "2026-01-01T01:00:00Z", agent: "agent-1" },
43
43
  {
@@ -55,7 +55,7 @@ const runSelectorTests = () => {
55
55
  { id: "a5", kind: "next", text: "work E", ts: "", agent: "" },
56
56
  { kind: "claim", ref: "a5", ts: "2026-01-01T04:00:00Z", agent: "agent-1" },
57
57
  { kind: "claim", ref: "a5", ts: "2026-01-01T05:00:00Z", agent: "agent-3" },
58
- // (4) double claim → หลังชนะ (agent-3)
58
+ // (4) double claim → the later one wins (agent-3)
59
59
  ];
60
60
 
61
61
  const claims = claimsOf(t);
@@ -70,12 +70,12 @@ const runSelectorTests = () => {
70
70
  assert(!claims.has("a3"), "release did not void the claim");
71
71
  // (3) a4 close → inactive
72
72
  assert(!claims.has("a4"), "close void claim");
73
- // (4) a5 double claim → agent-3 ชนะ
73
+ // (4) a5 double claim → agent-3 wins
74
74
  assert(
75
75
  claims.has("a5") && claims.get("a5")?.agent === "agent-3",
76
76
  "double claim after the winner",
77
77
  );
78
- // a2 closed → ไม่ควรอยู่ใน open
78
+ // a2 closed → should not appear in open
79
79
  assert(!open.some((r) => r.id === "a2"), "tombstone broken");
80
80
  // open = a1, a3 (released but still open), a5
81
81
  assert(
@@ -91,12 +91,12 @@ const runRotateTests = () => {
91
91
  const t: LogRow[] = [
92
92
  { id: "r1", kind: "next", text: "keep me", ts: "", agent: "" },
93
93
  { kind: "claim", ref: "r1", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
94
- // r1: open + claimed → work row + claim row ต้องเก็บทั้งคู่
94
+ // r1: open + claimed → both the work row and the claim row must be kept
95
95
 
96
96
  { id: "r2", kind: "bug", text: "closed already", ts: "", agent: "" },
97
97
  { kind: "claim", ref: "r2", ts: "2026-01-01T00:00:00Z", agent: "agent-1" },
98
98
  { kind: "close", ref: "r2", text: "shipped", ts: "", agent: "" },
99
- // r2: ปิดแล้ว → work row, claim row, close tombstone ทิ้งหมด
99
+ // r2: closed → work row, claim row, close tombstone all dropped
100
100
 
101
101
  {
102
102
  id: "r3",
@@ -112,7 +112,7 @@ const runRotateTests = () => {
112
112
  ts: "2026-01-02T00:00:00Z",
113
113
  agent: "",
114
114
  },
115
- // r3: spec synced *หลัง* decision นี้ → resolved แล้ว archive ได้
115
+ // r3: spec synced *after* this decision → resolved, can be archived
116
116
 
117
117
  {
118
118
  id: "r4",
@@ -122,10 +122,10 @@ const runRotateTests = () => {
122
122
  agent: "",
123
123
  spec: "PLAN-a.md",
124
124
  },
125
- // r4: decision ใหม่กว่า synced ล่าสุด ยังไม่ resolved เก็บไว้
125
+ // r4: decision newer than the latest synced → not resolved yet, keep it
126
126
 
127
127
  { id: "r5", kind: "note", text: "note no spec", ts: "", agent: "" },
128
- // r5: note ไม่มี spec → ไม่มีทางรู้ resolved หรือยัง เก็บไว้เสมอ
128
+ // r5: a note with no spec → no way to know if resolved, always kept
129
129
  ];
130
130
 
131
131
  const kept = rotateKeep(t);
@@ -167,17 +167,17 @@ const runPlanSweepTests = () => {
167
167
  const tmpDir = mkdtempSync(join(tmpdir(), "mem-test-"));
168
168
  try {
169
169
  // === Case A: rewriteMarkdownLinks + countPlainTextMentions ===
170
- // สร้าง fixture: ไฟล์ที่ reference ไปที่ plan/PLAN-page-style.md หลายแบบ
170
+ // build fixture: a file that references plan/PLAN-page-style.md several ways
171
171
  const planDir = join(tmpDir, "plan");
172
172
  const doneDir = join(tmpDir, "plan", "done");
173
173
  mkdirSync(planDir, { recursive: true });
174
174
  mkdirSync(doneDir, { recursive: true });
175
175
 
176
- // "old" file — จำลอง apps/vela/plan/PLAN-page-style.md (touch only)
176
+ // "old" file — simulates apps/vela/plan/PLAN-page-style.md (touch only)
177
177
  const oldFile = join(planDir, "PLAN-page-style.md");
178
178
  writeFileSync(oldFile, "# PLAN-page-style\n");
179
179
 
180
- // inbound file — จำลอง apps/vela/plan/PLAN-people-style.md
180
+ // inbound file — simulates apps/vela/plan/PLAN-people-style.md
181
181
  const inbound = [
182
182
  "# PLAN-people-style — refs",
183
183
  "",
@@ -361,13 +361,13 @@ const runPlanCheckTests = () => {
361
361
  );
362
362
  writeFileSync(join(planDir, "PLAN-active.md"), "# active plan\n");
363
363
 
364
- // plan ที่เขียนด้วย format ปัจจุบัน: frontmatter + title มาก่อน header ✅ shipped
364
+ // a plan in the current format: frontmatter + title come before the ✅ shipped header
365
365
  writeFileSync(
366
366
  join(planDir, "PLAN-frontmatter.md"),
367
367
  "---\nkind: unit\n---\n\n# shipped with frontmatter\n\n> ✅ **shipped 2026-09-13** (abc1234)\n",
368
368
  );
369
369
 
370
- // ship ไปบาง chunk แล้วติดรอของข้างนอก — frontmatter บอกว่าตั้งใจให้อยู่ต่อ ห้ามนับว่าลืมย้าย
370
+ // shipped some chunks and waiting on externals — frontmatter says it is meant to stay, do not count as forgotten
371
371
  writeFileSync(
372
372
  join(planDir, "PLAN-blocked.md"),
373
373
  "---\nstatus: blocked\nblocked_by: VPS#2\n---\n\n# partly shipped\n\n> ✅ **chunk 1 shipped 2026-09-13** (abc1234)\n",