fapony 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,208 @@
1
+ // src/digest/html.ts — render digest as a single HTML page
2
+ //
3
+ // คอลัมน์เดียว max-width: 68rem · ไม่มี tab ไม่มีปุ่มพับ ไม่มี JS
4
+ // ใช้ DARK_THEME_CSS + TABLE_CSS จาก src/web/html.ts
5
+
6
+ import { DARK_THEME_CSS, esc, TABLE_CSS } from "../web/html.js";
7
+ import type {
8
+ CostRow,
9
+ DigestData,
10
+ MemRow,
11
+ PlanRow,
12
+ RegimeModelRow,
13
+ Tally,
14
+ } from "./collect.js";
15
+
16
+ function fmtDate(iso: string): string {
17
+ return iso.slice(0, 10);
18
+ }
19
+
20
+ function fmtTokens(n: number): string {
21
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
22
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
23
+ return String(Math.round(n));
24
+ }
25
+
26
+ function memRow(r: MemRow): string {
27
+ const date = r.ts.slice(5, 10);
28
+ return `<tr>
29
+ <td class="muted">${esc(date)}</td>
30
+ <td>${esc(r.agent)}</td>
31
+ <td>${esc(r.text.replace(/\n/g, " ").slice(0, 120))}</td>
32
+ </tr>`;
33
+ }
34
+
35
+ function planRow(p: PlanRow): string {
36
+ const name = esc(p.file.replace(/\.md$/, ""));
37
+ const status = esc(p.status ?? "active");
38
+ const progress = p.total > 0 ? `${p.done}/${p.total}` : "\u2014";
39
+ return `<tr>
40
+ <td>${name}</td>
41
+ <td class="muted">${status}</td>
42
+ <td class="muted">${progress}</td>
43
+ </tr>`;
44
+ }
45
+
46
+ function costRow(c: CostRow): string {
47
+ const name = c.provider ? `${esc(c.provider)}/${esc(c.model)}` : esc(c.model);
48
+ const costStr =
49
+ c.imputed > 0
50
+ ? `~$${c.imputed.toFixed(4)} imputed`
51
+ : c.cost > 0
52
+ ? `$${c.cost.toFixed(4)} recorded`
53
+ : "unpriced";
54
+ return `<tr>
55
+ <td>${name}</td>
56
+ <td class="muted">${c.sessions}</td>
57
+ <td>${fmtTokens(c.input)}</td>
58
+ <td>${fmtTokens(c.output)}</td>
59
+ <td>${costStr}</td>
60
+ </tr>`;
61
+ }
62
+
63
+ function tallyRow(t: Tally): string {
64
+ return `<tr><td>${esc(t.key)}</td><td>${t.count}</td></tr>`;
65
+ }
66
+
67
+ function regimeModelRow(r: RegimeModelRow): string {
68
+ return `<tr>
69
+ <td class="muted">${esc(r.regime)}</td>
70
+ <td>${esc(r.model)}</td>
71
+ <td>${r.gates}</td>
72
+ <td>${r.fails}</td>
73
+ </tr>`;
74
+ }
75
+
76
+ function section(title: string, content: string, cls = ""): string {
77
+ return `<h2 class="${cls}">${esc(title)}</h2>\n${content}`;
78
+ }
79
+
80
+ export function renderDigestHtml(d: DigestData): string {
81
+ const units = d.verdicts.units_graded;
82
+ const costBits: string[] = [];
83
+ if (d.cost.imputed_usd > 0)
84
+ costBits.push(`~$${d.cost.imputed_usd.toFixed(4)} (imputed)`);
85
+ if (d.cost.total_usd > 0)
86
+ costBits.push(`$${d.cost.total_usd.toFixed(4)} (recorded)`);
87
+ const costLabel = costBits.length > 0 ? costBits.join(" · ") : "$0";
88
+
89
+ // decisions table
90
+ const decisionsHtml =
91
+ d.decisions.length > 0
92
+ ? `<table><thead><tr><th>Date</th><th>Agent</th><th>Decision</th></tr></thead><tbody>${d.decisions.map(memRow).join("")}</tbody></table>`
93
+ : '<p class="muted">(none this period)</p>';
94
+
95
+ // open bugs
96
+ const bugsOpenHtml =
97
+ d.bugs.open.length > 0
98
+ ? `<table><thead><tr><th>Date</th><th>Agent</th><th>Bug</th></tr></thead><tbody>${d.bugs.open.map(memRow).join("")}</tbody></table>`
99
+ : '<p class="muted">(none)</p>';
100
+ const closedNote =
101
+ d.bugs.closed.length > 0
102
+ ? `<p class="muted">${d.bugs.closed.length} closed this period</p>`
103
+ : "";
104
+
105
+ // plans
106
+ const inFlight = d.plans.pending.filter(
107
+ (p) => p.status === "active" || !p.status,
108
+ );
109
+ const plansHtml =
110
+ inFlight.length > 0
111
+ ? `<table><thead><tr><th>Plan</th><th>Status</th><th>Progress</th></tr></thead><tbody>${inFlight.map(planRow).join("")}</tbody></table>`
112
+ : '<p class="muted">(none)</p>';
113
+ const shippedNote =
114
+ d.plans.shipped.length > 0
115
+ ? `<p class="muted">${d.plans.shipped.length} shipped this period</p>`
116
+ : "";
117
+
118
+ // cost
119
+ const costHtml =
120
+ d.cost.by_model.length > 0
121
+ ? `<div style="overflow-x:auto"><table><thead><tr><th>Model</th><th>Sessions</th><th>In</th><th>Out</th><th>Cost</th></tr></thead><tbody>${d.cost.by_model.map(costRow).join("")}</tbody></table></div>` +
122
+ (d.cost.unpriced_sessions > 0
123
+ ? `<p class="muted">${d.cost.unpriced_sessions} sessions unpriced (no rate found — not counted as free)</p>`
124
+ : "")
125
+ : '<p class="muted">(no usage data)</p>';
126
+
127
+ // verdicts — by_grade
128
+ const gradeHtml =
129
+ d.verdicts.by_grade.length > 0
130
+ ? `<table><thead><tr><th>Grade</th><th>Count</th></tr></thead><tbody>${d.verdicts.by_grade.map(tallyRow).join("")}</tbody></table>`
131
+ : "";
132
+
133
+ // verdicts — by_reason_code
134
+ const reasonHtml =
135
+ d.verdicts.by_reason_code.length > 0
136
+ ? `<table><thead><tr><th>Reason</th><th>Count</th></tr></thead><tbody>${d.verdicts.by_reason_code.map(tallyRow).join("")}</tbody></table>`
137
+ : '<p class="muted">no graded fails in this period</p>';
138
+
139
+ // verdicts — by_regime_model
140
+ const regimeHtml =
141
+ d.verdicts.by_regime_model.length > 0
142
+ ? `<div style="overflow-x:auto"><table><thead><tr><th>Regime</th><th>Model</th><th>Gates</th><th>Fails</th></tr></thead><tbody>${d.verdicts.by_regime_model.map(regimeModelRow).join("")}</tbody></table></div>`
143
+ : "";
144
+
145
+ // sources
146
+ const sourcesHtml = `<table><thead><tr><th>Source</th><th>Status</th><th>Detail</th></tr></thead><tbody>${d.sources
147
+ .map(
148
+ (s) =>
149
+ `<tr><td>${esc(s.name)}</td><td class="${s.ok ? "pass" : "fail"}">${s.ok ? "ok" : "!!"}</td><td class="muted">${esc(s.detail)}</td></tr>`,
150
+ )
151
+ .join("")}</tbody></table>`;
152
+
153
+ const malformedNote =
154
+ d.skipped_malformed > 0
155
+ ? `<p class="muted">${d.skipped_malformed} malformed log lines skipped</p>`
156
+ : "";
157
+
158
+ return `<!DOCTYPE html>
159
+ <html lang="en">
160
+ <head>
161
+ <meta charset="utf-8">
162
+ <meta name="viewport" content="width=device-width, initial-scale=1">
163
+ <title>fapony digest — ${fmtDate(d.since)} → ${fmtDate(d.generated_at)}</title>
164
+ <style>
165
+ ${DARK_THEME_CSS}
166
+ ${TABLE_CSS}
167
+ body { max-width: 68rem; }
168
+ .headline { font-size: 1.1rem; margin: 1rem 0 1.5rem; }
169
+ .scope-note { color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem; }
170
+ table { margin-bottom: 1.5rem; }
171
+ td:first-child { font-weight: 600; }
172
+ </style>
173
+ </head>
174
+ <body>
175
+
176
+ <h1>fapony digest — ${esc(fmtDate(d.since))} → ${esc(fmtDate(d.generated_at))}</h1>
177
+ <div class="scope-note">${esc(d.worktree)}<br>${esc(d.scope_note)}</div>
178
+
179
+ <div class="headline">
180
+ <strong>${units}</strong> unit${units === 1 ? "" : "s"} graded · <strong>${d.verdicts.round1_pct}%</strong> passed round 1 · <strong>${esc(costLabel)}</strong>
181
+ </div>
182
+
183
+ ${section("Decisions", decisionsHtml)}
184
+
185
+ ${section(
186
+ `Notes (${d.notes.length})`,
187
+ d.notes.length > 0
188
+ ? `<table><thead><tr><th>Date</th><th>Agent</th><th>Note</th></tr></thead><tbody>${d.notes.map(memRow).join("")}</tbody></table>`
189
+ : '<p class="muted">(none this period)</p>',
190
+ )}
191
+
192
+ ${section(`Open Bugs (${d.bugs.open.length})`, bugsOpenHtml + closedNote)}
193
+
194
+ ${section(`In Flight (${inFlight.length})`, plansHtml + shippedNote)}
195
+
196
+ ${section("Cost", costHtml)}
197
+
198
+ ${section("How Work Failed", reasonHtml)}
199
+ ${gradeHtml ? `\n${section("By Grade", gradeHtml)}` : ""}
200
+ ${regimeHtml ? `\n${section("By Regime × Model", regimeHtml)}` : ""}
201
+
202
+ ${section("Sources", sourcesHtml)}
203
+
204
+ ${malformedNote}
205
+
206
+ </body>
207
+ </html>`;
208
+ }
@@ -0,0 +1,191 @@
1
+ // src/digest/text.ts — render digest as terminal text
2
+ //
3
+ // ลำดับ section ตาม SPEC §3 (เล่าเรื่อง ไม่ใช่ dashboard)
4
+
5
+ import type { CostRow, DigestData, MemRow, PlanRow } from "./collect.js";
6
+
7
+ function fmtDate(iso: string): string {
8
+ return iso.slice(0, 10);
9
+ }
10
+
11
+ function fmtTime(iso: string): string {
12
+ return iso.slice(5, 10); // MM-DD
13
+ }
14
+
15
+ function section(name: string, lines: string[]): string {
16
+ return `${name}\n${lines.map((l) => ` ${l}`).join("\n")}`;
17
+ }
18
+
19
+ function memLine(r: MemRow): string {
20
+ const date = fmtTime(r.ts);
21
+ return `${date} ${r.agent} ${r.text.replace(/\n/g, " ").slice(0, 80)}`;
22
+ }
23
+
24
+ function planLine(p: PlanRow): string {
25
+ const name = p.file.replace(/\.md$/, "");
26
+ const status = p.status ?? "active";
27
+ const progress = p.total > 0 ? `${p.done}/${p.total}` : "—";
28
+ return `${name.padEnd(30)} ${status.padEnd(12)} ${progress}`;
29
+ }
30
+
31
+ function fmtTokens(n: number): string {
32
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
33
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
34
+ return String(Math.round(n));
35
+ }
36
+
37
+ function costLine(c: CostRow): string {
38
+ const name = c.provider ? `${c.provider}/${c.model}` : c.model;
39
+ const costStr =
40
+ c.imputed > 0
41
+ ? `$${c.imputed.toFixed(4)} imputed`
42
+ : c.cost > 0
43
+ ? `$${c.cost.toFixed(4)} recorded`
44
+ : "unpriced";
45
+ return `${name.padEnd(45)} ${String(c.sessions).padStart(5)} sessions ${fmtTokens(c.input).padStart(7)} in ${fmtTokens(c.output).padStart(7)} out ${costStr}`;
46
+ }
47
+
48
+ export function renderDigestText(d: DigestData): string {
49
+ const lines: string[] = [];
50
+
51
+ // 1. Header
52
+ lines.push(
53
+ `fapony digest — ${fmtDate(d.since)} → ${fmtDate(d.generated_at)}`,
54
+ );
55
+ lines.push(` ${d.worktree}`);
56
+ lines.push(` scope: ${d.scope_note}`);
57
+ lines.push("");
58
+
59
+ // 2. Headline — run-based counts from collect (gate tallies are not units)
60
+ const units = d.verdicts.units_graded;
61
+ const costBits: string[] = [];
62
+ if (d.cost.imputed_usd > 0)
63
+ costBits.push(`$${d.cost.imputed_usd.toFixed(4)} (imputed)`);
64
+ if (d.cost.total_usd > 0)
65
+ costBits.push(`$${d.cost.total_usd.toFixed(4)} (recorded)`);
66
+ const costLabel = costBits.length > 0 ? costBits.join(" · ") : "$0";
67
+
68
+ lines.push(
69
+ ` ${units} unit${units === 1 ? "" : "s"} graded · ${d.verdicts.round1_pct}% passed round 1 · ${costLabel}`,
70
+ );
71
+ lines.push("");
72
+
73
+ // 3. Decisions
74
+ lines.push(
75
+ section(`DECISIONS (${d.decisions.length})`, [
76
+ ...d.decisions.map(memLine),
77
+ ...(d.decisions.length === 0 ? ["(none this period)"] : []),
78
+ ]),
79
+ );
80
+ lines.push("");
81
+
82
+ // 3b. Notes — mem rows like decisions; the sync payload must stay visible
83
+ lines.push(
84
+ section(`NOTES (${d.notes.length})`, [
85
+ ...d.notes.map(memLine),
86
+ ...(d.notes.length === 0 ? ["(none this period)"] : []),
87
+ ]),
88
+ );
89
+ lines.push("");
90
+
91
+ // 4. Open bugs
92
+ const openBugLines = d.bugs.open.map(memLine);
93
+ const closedCount = d.bugs.closed.length;
94
+ lines.push(
95
+ section(`OPEN BUGS (${d.bugs.open.length})`, [
96
+ ...openBugLines,
97
+ ...(d.bugs.open.length === 0 ? ["(none)"] : []),
98
+ ...(closedCount > 0 ? [`(${closedCount} closed this period)`] : []),
99
+ ]),
100
+ );
101
+ lines.push("");
102
+
103
+ // 5. In flight
104
+ const inFlight = d.plans.pending.filter(
105
+ (p) => p.status === "active" || !p.status,
106
+ );
107
+ const shipped = d.plans.shipped;
108
+ lines.push(
109
+ section(`IN FLIGHT (${inFlight.length})`, [
110
+ ...inFlight.map(planLine),
111
+ ...(inFlight.length === 0 ? ["(none)"] : []),
112
+ ...(shipped.length > 0 ? [`${shipped.length} shipped this period`] : []),
113
+ ]),
114
+ );
115
+ lines.push("");
116
+
117
+ // 6. Cost
118
+ if (d.cost.by_model.length > 0) {
119
+ lines.push(
120
+ section("COST", [
121
+ "model".padEnd(45) +
122
+ "sessions".padStart(5) +
123
+ " in".padStart(9) +
124
+ " out".padStart(9) +
125
+ " cost",
126
+ ...d.cost.by_model.map(costLine),
127
+ ...(d.cost.unpriced_sessions > 0
128
+ ? [
129
+ `${d.cost.unpriced_sessions} sessions unpriced (no rate found — not counted as free)`,
130
+ ]
131
+ : []),
132
+ ]),
133
+ );
134
+ } else {
135
+ lines.push(section("COST", ["(no usage data)"]));
136
+ }
137
+ lines.push("");
138
+
139
+ // 7. How work failed — by_grade first (every payload field needs a place)
140
+ if (d.verdicts.by_grade.length > 0) {
141
+ lines.push(
142
+ section("BY GRADE", [
143
+ ...d.verdicts.by_grade.map((t) => `${t.key.padEnd(20)} ${t.count}`),
144
+ ]),
145
+ );
146
+ lines.push("");
147
+ }
148
+
149
+ if (d.verdicts.by_reason_code.length > 0) {
150
+ lines.push(
151
+ section("HOW WORK FAILED", [
152
+ ...d.verdicts.by_reason_code.map(
153
+ (t) => `${t.key.padEnd(20)} ${t.count}`,
154
+ ),
155
+ ]),
156
+ );
157
+ } else {
158
+ lines.push(section("HOW WORK FAILED", ["no graded fails in this period"]));
159
+ }
160
+
161
+ if (d.verdicts.by_regime_model.length > 0) {
162
+ lines.push("");
163
+ lines.push(
164
+ section("BY REGIME × MODEL", [
165
+ `${"regime".padEnd(10)}${"model".padEnd(40)}gates fails`,
166
+ ...d.verdicts.by_regime_model.map(
167
+ (r) =>
168
+ `${r.regime.padEnd(10)}${r.model.padEnd(40)}${String(r.gates).padStart(5)} ${String(r.fails).padStart(5)}`,
169
+ ),
170
+ ]),
171
+ );
172
+ }
173
+ lines.push("");
174
+
175
+ // 8. Sources
176
+ lines.push(
177
+ section(
178
+ "SOURCES",
179
+ d.sources.map(
180
+ (s) => `${s.name.padEnd(10)} ${s.ok ? "ok" : "!!"} ${s.detail}`,
181
+ ),
182
+ ),
183
+ );
184
+
185
+ if (d.skipped_malformed > 0) {
186
+ lines.push("");
187
+ lines.push(` (${d.skipped_malformed} malformed log lines skipped)`);
188
+ }
189
+
190
+ return lines.join("\n");
191
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,153 @@
1
+ // gateOnce is the core review-verdict logic — still live, called by the MCP
2
+ // verdict_submit tool (src/mcp/tools/verdict.ts). The CLI wrapper (cmdGate)
3
+ // was removed with the rest of the execute→review→fix loop (Wave 2).
4
+ import {
5
+ addEvent,
6
+ getRun,
7
+ incrementRound,
8
+ loadConfig,
9
+ openDb,
10
+ setStatus,
11
+ } from "./db/index.js";
12
+ import { closeMemory, kickoffMemory } from "./memory.js";
13
+ import { isPassFamily, type VerdictGrade } from "./parse.js";
14
+
15
+ export interface GateResult {
16
+ runId: number;
17
+ status: "passed" | "fixing" | "stopped" | "stalled";
18
+ round?: number;
19
+ error?: string;
20
+ }
21
+
22
+ /**
23
+ * Core gate logic — does NOT call process.exit.
24
+ * CLI wrapper (cmdGate) is responsible for exit codes.
25
+ *
26
+ * Verdict routing (3 groups):
27
+ * pass-family (pass-excellent|pass-good|pass-adequate|pass) → passed
28
+ * fail → fixing (+ round cap check → stalled)
29
+ * uncertain → stopped (same shape as round-cap path)
30
+ */
31
+ export function gateOnce(
32
+ runId: number,
33
+ verdict: VerdictGrade,
34
+ note: string,
35
+ files?: string[],
36
+ ): GateResult {
37
+ const db = openDb();
38
+ const run = getRun(db, runId);
39
+ if (!run) {
40
+ return { runId, status: "stopped", error: `run ${runId} not found` };
41
+ }
42
+ if (
43
+ run.status === "passed" ||
44
+ run.status === "stopped" ||
45
+ run.status === "stalled"
46
+ ) {
47
+ return {
48
+ runId,
49
+ status: run.status,
50
+ round: run.round,
51
+ error: `run ${runId} is already ${run.status}`,
52
+ };
53
+ }
54
+
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
59
+ const worktree = config.worktrees[run.worktree] ?? run.worktree;
60
+
61
+ // --- pass family (4 grades) ---
62
+ if (isPassFamily(verdict)) {
63
+ setStatus(db, runId, "passed");
64
+ // round = run's current round at review time (pre-increment for fail).
65
+ addEvent(db, runId, "gate", {
66
+ verdict,
67
+ note,
68
+ round: run.round,
69
+ ...(files && files.length > 0 ? { files } : {}),
70
+ });
71
+
72
+ if (run.mem_id && config.memory) {
73
+ closeMemory(config, worktree, run.mem_id, note || `run ${runId} passed`);
74
+ addEvent(db, runId, "memory_claim_closed", { mem_id: run.mem_id });
75
+ }
76
+
77
+ const kickoff = kickoffMemory(config, worktree);
78
+ if (kickoff) console.log(kickoff);
79
+
80
+ return { runId, status: "passed", round: run.round };
81
+ }
82
+
83
+ // --- uncertain → stop (plan problem, same as round-cap) ---
84
+ if (verdict === "uncertain") {
85
+ setStatus(db, runId, "stopped");
86
+ addEvent(db, runId, "gate", {
87
+ verdict,
88
+ note,
89
+ round: run.round,
90
+ ...(files && files.length > 0 ? { files } : {}),
91
+ });
92
+ addEvent(db, runId, "stop", { reason: "verdict_uncertain" });
93
+
94
+ if (run.mem_id) {
95
+ closeMemory(
96
+ config,
97
+ worktree,
98
+ run.mem_id,
99
+ `run ${runId} stopped — uncertain verdict`,
100
+ );
101
+ addEvent(db, runId, "memory_claim_closed", { mem_id: run.mem_id });
102
+ }
103
+ return { runId, status: "stopped", round: run.round };
104
+ }
105
+
106
+ // --- fail → back to executor, one more round ---
107
+ incrementRound(db, runId);
108
+ setStatus(db, runId, "fixing");
109
+ addEvent(db, runId, "gate", {
110
+ verdict,
111
+ note,
112
+ round: run.round,
113
+ ...(files && files.length > 0 ? { files } : {}),
114
+ });
115
+
116
+ const updated = getRun(db, runId);
117
+ if (!updated) {
118
+ setStatus(db, runId, "stopped");
119
+ return {
120
+ runId,
121
+ status: "stopped",
122
+ error: `run ${runId} disappeared after update`,
123
+ };
124
+ }
125
+
126
+ if (updated.round > config.review.maxRounds) {
127
+ // Round cap reached — the plan is the problem, stall for real. Persist it
128
+ // (else the run sits in 'fixing' forever) and release the memory claim.
129
+ // stalled (not stopped): tells the caller to take it back to the human
130
+ // instead of sending the agent for another fix round.
131
+ setStatus(db, runId, "stalled");
132
+ addEvent(db, runId, "stop", {
133
+ reason: `round ${updated.round} > maxRounds ${config.review.maxRounds}`,
134
+ });
135
+ if (run.mem_id) {
136
+ closeMemory(
137
+ config,
138
+ worktree,
139
+ run.mem_id,
140
+ `run ${runId} stalled at round cap`,
141
+ );
142
+ addEvent(db, runId, "memory_claim_closed", { mem_id: run.mem_id });
143
+ }
144
+ return {
145
+ runId,
146
+ status: "stalled",
147
+ round: updated.round,
148
+ error: `round ${updated.round} > maxRounds ${config.review.maxRounds} — stop fixing and take it back to the human, the plan likely has a problem`,
149
+ };
150
+ }
151
+
152
+ return { runId, status: "fixing", round: updated.round };
153
+ }