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.
- package/LICENSE +21 -0
- package/README.md +473 -0
- package/fapony.ts +78 -0
- package/package.json +42 -0
- package/skill/git-commit-conventional/SKILL.md +68 -0
- package/skill/git-ship/SKILL.md +144 -0
- package/skill/move-to-done/SKILL.md +126 -0
- package/skill/plan-with-pony/SKILL.md +263 -0
- package/skill/review-pony/SKILL.md +254 -0
- package/src/analyze.ts +517 -0
- package/src/context/index.ts +11 -0
- package/src/context/projectHealth.ts +359 -0
- package/src/conventions-seed.ts +420 -0
- package/src/db/defaults.ts +26 -0
- package/src/db/getters.ts +33 -0
- package/src/db/index.ts +7 -0
- package/src/db/load.ts +57 -0
- package/src/db/store.ts +286 -0
- package/src/db/types.ts +79 -0
- package/src/debt.ts +667 -0
- package/src/digest/cli.ts +75 -0
- package/src/digest/collect.ts +625 -0
- package/src/digest/html.ts +208 -0
- package/src/digest/text.ts +191 -0
- package/src/gate.ts +153 -0
- package/src/gates.ts +194 -0
- package/src/hook.ts +436 -0
- package/src/init-mem.ts +71 -0
- package/src/init.ts +237 -0
- package/src/install/claude.ts +361 -0
- package/src/install/codex.ts +61 -0
- package/src/install/cursor.ts +167 -0
- package/src/install/detect.ts +78 -0
- package/src/install/opencode.ts +234 -0
- package/src/install/skills.ts +106 -0
- package/src/install/types.ts +69 -0
- package/src/install/utils.ts +29 -0
- package/src/install/zcode.ts +120 -0
- package/src/install.ts +176 -0
- package/src/lint-baseline.ts +260 -0
- package/src/map.ts +320 -0
- package/src/math.ts +13 -0
- package/src/mcp/evidence.ts +332 -0
- package/src/mcp/primitives.ts +316 -0
- package/src/mcp/tools/check.ts +243 -0
- package/src/mcp/tools/collect.ts +157 -0
- package/src/mcp/tools/context.ts +66 -0
- package/src/mcp/tools/index.ts +309 -0
- package/src/mcp/tools/mem.ts +95 -0
- package/src/mcp/tools/plans.ts +255 -0
- package/src/mcp/tools/report.ts +285 -0
- package/src/mcp/tools/stats.ts +96 -0
- package/src/mcp/tools/usage.ts +211 -0
- package/src/mcp/tools/verdict.ts +148 -0
- package/src/mcp/transport.ts +241 -0
- package/src/mcp/types.ts +54 -0
- package/src/mcp/worktree.ts +27 -0
- package/src/memory.ts +264 -0
- package/src/parse.ts +71 -0
- package/src/plan-seed.ts +599 -0
- package/src/price/fetch.ts +146 -0
- package/src/price/index.ts +8 -0
- package/src/price/resolve.ts +213 -0
- package/src/report/cli.ts +92 -0
- package/src/report/format.ts +37 -0
- package/src/report/index.ts +4 -0
- package/src/report/render.ts +206 -0
- package/src/review-seed.ts +932 -0
- package/src/safety.ts +18 -0
- package/src/session/activeSession.ts +153 -0
- package/src/session/claude-code.ts +412 -0
- package/src/session/codex.ts +347 -0
- package/src/session/findModel.ts +376 -0
- package/src/session/helpers.ts +640 -0
- package/src/session/index.ts +31 -0
- package/src/session/opencode.ts +167 -0
- package/src/session/registry.ts +45 -0
- package/src/session/types.ts +128 -0
- package/src/session/zcode.ts +151 -0
- package/src/setup.ts +242 -0
- package/src/stats/cli.ts +44 -0
- package/src/stats/data.ts +1019 -0
- package/src/stats/format.ts +584 -0
- package/src/stats/index.ts +19 -0
- package/src/telemetry.ts +364 -0
- package/src/test.ts +2 -0
- package/src/update.ts +212 -0
- package/src/usage/cache.ts +125 -0
- package/src/usage/cli.ts +120 -0
- package/src/usage/format.ts +29 -0
- package/src/usage/index.ts +4 -0
- package/src/usage/render.ts +523 -0
- package/src/usage/scan.ts +161 -0
- package/src/util.ts +32 -0
- package/src/web/html.ts +33 -0
- package/templates/PLAN.md +90 -0
- package/templates/SPEC.md +30 -0
- package/templates/mem/commands/plan.ts +360 -0
- package/templates/mem/commands/read.ts +194 -0
- package/templates/mem/commands/rotate.ts +59 -0
- package/templates/mem/commands/selftest.ts +450 -0
- package/templates/mem/commands/write.ts +214 -0
- package/templates/mem/mem.ts +68 -0
- package/templates/mem/render.ts +63 -0
- package/templates/mem/selectors.ts +144 -0
- package/templates/mem/store.ts +285 -0
package/src/gates.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// src/gates.ts — shared per-round gate enrichment (single implementation)
|
|
2
|
+
//
|
|
3
|
+
// Pairs each gate event with the spawn events in its own round window and
|
|
4
|
+
// derives model + canonical quality. Used by stats, telemetry, and
|
|
5
|
+
// report-html — never reimplement this pairing elsewhere.
|
|
6
|
+
//
|
|
7
|
+
// Window rule (read-time join): per gate, model comes only from kind='spawn'
|
|
8
|
+
// events in (prevGateId, gateId) of the same run — per-round, never
|
|
9
|
+
// cumulative. Events arrive sorted (run_id, id), so one bucketing pass plus a
|
|
10
|
+
// forward spawn pointer over the disjoint windows is O(events) total.
|
|
11
|
+
|
|
12
|
+
import type { Event } from "./db/index.js";
|
|
13
|
+
import { qualityScore, VERDICT_GRADES, type VerdictGrade } from "./parse.js";
|
|
14
|
+
import {
|
|
15
|
+
findSessionAt,
|
|
16
|
+
findSessionModel,
|
|
17
|
+
loadSessionSpans,
|
|
18
|
+
type SessionClient,
|
|
19
|
+
type SessionSpan,
|
|
20
|
+
} from "./session/index.js";
|
|
21
|
+
|
|
22
|
+
export interface GateWindow {
|
|
23
|
+
runId: number;
|
|
24
|
+
/** Raw verdict string ("" when the gate event has none). */
|
|
25
|
+
verdict: string;
|
|
26
|
+
/** Canonical quality via qualityScore(), or null when grade unknown. */
|
|
27
|
+
quality: number | null;
|
|
28
|
+
/** Executor model from the window's spawns, or null when unknown. */
|
|
29
|
+
model: string | null;
|
|
30
|
+
/** Provider from the gate's session_id (null when unknown or spawn-based). */
|
|
31
|
+
provider: string | null;
|
|
32
|
+
/** Client owning the session log (null when unknown or spawn-based). */
|
|
33
|
+
client: SessionClient | null;
|
|
34
|
+
/** Subagent name, ZCode only (null otherwise, or when spawn-based). */
|
|
35
|
+
agent: string | null;
|
|
36
|
+
/** Round number from the gate event data (defaults to 1). */
|
|
37
|
+
round: number;
|
|
38
|
+
/**
|
|
39
|
+
* Where `model` came from: a spawn event, the gate's own session_id, or
|
|
40
|
+
* inferred from which client session was live in that worktree at that
|
|
41
|
+
* moment. "inferred" is a guess — never present it as declared.
|
|
42
|
+
*/
|
|
43
|
+
modelSource: "spawn" | "session_id" | "inferred" | null;
|
|
44
|
+
/**
|
|
45
|
+
* Session the tokens below belong to (null when unknown or spawn-based).
|
|
46
|
+
* Callers that sum tokens MUST dedupe on this: token totals are per session
|
|
47
|
+
* and one session routinely produces several gates.
|
|
48
|
+
*/
|
|
49
|
+
sessionId: string | null;
|
|
50
|
+
/** Total input tokens for the session (null when unknown or spawn-based). */
|
|
51
|
+
tokensInput: number | null;
|
|
52
|
+
/** Total output tokens for the session (null when unknown or spawn-based). */
|
|
53
|
+
tokensOutput: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** SQLite `datetime('now')` output is UTC without a zone marker. */
|
|
57
|
+
function eventTimeMs(ts: string): number | null {
|
|
58
|
+
const direct = Date.parse(ts);
|
|
59
|
+
if (!Number.isNaN(direct) && /[zZ]|[+-]\d\d:?\d\d$/.test(ts)) return direct;
|
|
60
|
+
const utc = Date.parse(`${ts.replace(" ", "T")}Z`);
|
|
61
|
+
return Number.isNaN(utc) ? null : utc;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseEventData(data: string | null): Record<string, unknown> {
|
|
65
|
+
if (!data) return {};
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(data);
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Enrich every gate event with its per-round window.
|
|
75
|
+
* One entry per gate event, in (run_id, id) order.
|
|
76
|
+
*/
|
|
77
|
+
export function enrichGateWindows(
|
|
78
|
+
events: Event[],
|
|
79
|
+
worktreeByRun?: Map<number, string>,
|
|
80
|
+
): GateWindow[] {
|
|
81
|
+
// Spans are loaded per worktree, once, and only when a gate actually needs
|
|
82
|
+
// them — most callers pass no worktree map and pay nothing.
|
|
83
|
+
const spanCache = new Map<string, SessionSpan[]>();
|
|
84
|
+
const spansFor = (worktree: string): SessionSpan[] => {
|
|
85
|
+
let cached = spanCache.get(worktree);
|
|
86
|
+
if (!cached) {
|
|
87
|
+
cached = loadSessionSpans(worktree);
|
|
88
|
+
spanCache.set(worktree, cached);
|
|
89
|
+
}
|
|
90
|
+
return cached;
|
|
91
|
+
};
|
|
92
|
+
const byRun = new Map<number, { gates: Event[]; spawns: Event[] }>();
|
|
93
|
+
for (const e of events) {
|
|
94
|
+
let b = byRun.get(e.run_id);
|
|
95
|
+
if (!b) {
|
|
96
|
+
b = { gates: [], spawns: [] };
|
|
97
|
+
byRun.set(e.run_id, b);
|
|
98
|
+
}
|
|
99
|
+
if (e.kind === "gate") b.gates.push(e);
|
|
100
|
+
else if (e.kind === "spawn") b.spawns.push(e);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const out: GateWindow[] = [];
|
|
104
|
+
for (const [runId, b] of byRun) {
|
|
105
|
+
let sp = 0;
|
|
106
|
+
for (const g of b.gates) {
|
|
107
|
+
// Pointer only moves forward; everything unconsumed below g.id belongs
|
|
108
|
+
// to this gate's window (prevGateId is implicitly the last consumed id).
|
|
109
|
+
const window: Event[] = [];
|
|
110
|
+
while (sp < b.spawns.length && b.spawns[sp].id < g.id) {
|
|
111
|
+
window.push(b.spawns[sp]);
|
|
112
|
+
sp++;
|
|
113
|
+
}
|
|
114
|
+
const d = parseEventData(g.data);
|
|
115
|
+
const verdict = typeof d.verdict === "string" ? d.verdict : "";
|
|
116
|
+
let model: string | null = null;
|
|
117
|
+
for (const s of window) {
|
|
118
|
+
const sd = parseEventData(s.data);
|
|
119
|
+
if (
|
|
120
|
+
sd.role === "executor" &&
|
|
121
|
+
typeof sd.model === "string" &&
|
|
122
|
+
sd.model
|
|
123
|
+
) {
|
|
124
|
+
model = sd.model;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// No spawn in window (the execute→review loop that wrote them is gone):
|
|
129
|
+
// fall back to session_id on the gate event — resolve model + provider
|
|
130
|
+
// + client + agent + tokens from the client's own session log. Spawn-based
|
|
131
|
+
// model always wins when present (the new fields stay null then).
|
|
132
|
+
let provider: string | null = null;
|
|
133
|
+
let client: SessionClient | null = null;
|
|
134
|
+
let agent: string | null = null;
|
|
135
|
+
let sessionId: string | null = null;
|
|
136
|
+
let tokensInput: number | null = null;
|
|
137
|
+
let tokensOutput: number | null = null;
|
|
138
|
+
let modelSource: GateWindow["modelSource"] = model ? "spawn" : null;
|
|
139
|
+
if (model === null && typeof d.session_id === "string" && d.session_id) {
|
|
140
|
+
const resolved = findSessionModel(d.session_id);
|
|
141
|
+
if (resolved) {
|
|
142
|
+
model = resolved.model;
|
|
143
|
+
provider = resolved.provider;
|
|
144
|
+
client = resolved.client;
|
|
145
|
+
agent = resolved.agent;
|
|
146
|
+
sessionId = d.session_id;
|
|
147
|
+
tokensInput = resolved.tokensInput;
|
|
148
|
+
tokensOutput = resolved.tokensOutput;
|
|
149
|
+
modelSource = "session_id";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Still nothing: ask which client session was live in this worktree
|
|
153
|
+
// when the verdict landed. Costs the caller no new field and works on
|
|
154
|
+
// rows already stored — see session/activeSession.ts.
|
|
155
|
+
const worktree = worktreeByRun?.get(runId);
|
|
156
|
+
if (model === null && worktree) {
|
|
157
|
+
const atMs = eventTimeMs(g.ts);
|
|
158
|
+
const span =
|
|
159
|
+
atMs === null ? null : findSessionAt(spansFor(worktree), atMs);
|
|
160
|
+
const resolved = span ? findSessionModel(span.sessionId) : null;
|
|
161
|
+
if (resolved) {
|
|
162
|
+
model = resolved.model;
|
|
163
|
+
provider = resolved.provider;
|
|
164
|
+
client = resolved.client;
|
|
165
|
+
agent = resolved.agent;
|
|
166
|
+
sessionId = span ? span.sessionId : null;
|
|
167
|
+
tokensInput = resolved.tokensInput;
|
|
168
|
+
tokensOutput = resolved.tokensOutput;
|
|
169
|
+
modelSource = "inferred";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const grade = verdict as VerdictGrade;
|
|
174
|
+
const quality = VERDICT_GRADES.has(grade) ? qualityScore(grade) : null;
|
|
175
|
+
const round = typeof d.round === "number" ? d.round : 1;
|
|
176
|
+
|
|
177
|
+
out.push({
|
|
178
|
+
runId,
|
|
179
|
+
verdict,
|
|
180
|
+
quality,
|
|
181
|
+
model,
|
|
182
|
+
round,
|
|
183
|
+
provider,
|
|
184
|
+
client,
|
|
185
|
+
agent,
|
|
186
|
+
modelSource,
|
|
187
|
+
sessionId,
|
|
188
|
+
tokensInput,
|
|
189
|
+
tokensOutput,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// src/hook.ts — Claude Code / Cursor Stop hook: refuse to end a turn that produced
|
|
2
|
+
// commits but no verdict.
|
|
3
|
+
//
|
|
4
|
+
// ทำไมต้องเป็น hook ไม่ใช่ข้อความ: SERVER_INSTRUCTIONS เป็นการ *ขอ* ให้ agent จำ
|
|
5
|
+
// วัดแล้วว่าไม่พอ · hook ไม่ได้ตัดสินเกรดแทน (ตัดสินไม่ได้ — มันไม่เห็นว่างานผ่านหรือพัง)
|
|
6
|
+
// มันแค่ไม่ให้จบเทิร์นจนกว่า agent จะตัดสินเอง แยก "ใครตัดสิน" ออกจาก "ใครบังคับให้ตัดสิน"
|
|
7
|
+
//
|
|
8
|
+
// สัญญาณคือ commit ไม่ใช่ dirty tree — dirty = กำลังทำอยู่, commit = หน่วยงานจบแล้ว
|
|
9
|
+
// ตรงกับนิยาม "1 run = 1 หน่วยงานที่วัดได้" (กฎ 7)
|
|
10
|
+
//
|
|
11
|
+
// สอง payload หนึ่งการตัดสิน — field-mapping เท่านั้น:
|
|
12
|
+
// claude {cwd, transcript_path, stop_hook_active} → {"decision":"block"}
|
|
13
|
+
// cursor {workspace_roots, conversation_id, loop_count, status} → {"followup_message"}
|
|
14
|
+
// (cursor: loop_count ≥ 1 = hook เคยยิงแล้ว, status ≠ completed = ปล่อยผ่าน)
|
|
15
|
+
|
|
16
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, join, relative } from "node:path";
|
|
19
|
+
import { collectSourceFiles, SCAN_EXTS } from "./analyze.js";
|
|
20
|
+
import { openDb } from "./db/index.js";
|
|
21
|
+
import { debtForFile, loadConventions } from "./debt.js";
|
|
22
|
+
import { readMemLog } from "./memory.js";
|
|
23
|
+
|
|
24
|
+
export interface RawStopPayload {
|
|
25
|
+
// Claude Code
|
|
26
|
+
cwd?: string;
|
|
27
|
+
transcript_path?: string | null;
|
|
28
|
+
stop_hook_active?: boolean;
|
|
29
|
+
// Cursor — common schema (https://cursor.com/docs/agent/hooks)
|
|
30
|
+
workspace_roots?: string[];
|
|
31
|
+
conversation_id?: string;
|
|
32
|
+
loop_count?: number;
|
|
33
|
+
status?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type StopClient = "claude" | "cursor";
|
|
37
|
+
|
|
38
|
+
export interface NormalizedStopInput {
|
|
39
|
+
client: StopClient;
|
|
40
|
+
cwd: string;
|
|
41
|
+
transcriptPath: string | null;
|
|
42
|
+
stopHookActive: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** UTC 'YYYY-MM-DD HH:MM:SS' — the format events.ts is written in. */
|
|
46
|
+
export function utcStamp(d: Date): string {
|
|
47
|
+
return d.toISOString().slice(0, 19).replace("T", " ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pure decision: block only when this session produced commits and none of
|
|
52
|
+
* them got graded. Every unknown (no git, no transcript, hook already fired)
|
|
53
|
+
* resolves to "allow" — a hook that guesses wrong must never trap the agent.
|
|
54
|
+
*
|
|
55
|
+
* PLAN-mem-mcp chunk 3: the block message now carries the commit list and the
|
|
56
|
+
* 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
|
|
58
|
+
* reports what is pending so the agent decides what deserves recording).
|
|
59
|
+
*/
|
|
60
|
+
export function decideStop(opts: {
|
|
61
|
+
stopHookActive: boolean;
|
|
62
|
+
worktree: string | null;
|
|
63
|
+
commits: number;
|
|
64
|
+
verdicts: number;
|
|
65
|
+
commitList?: string[];
|
|
66
|
+
memLastTs?: string | null;
|
|
67
|
+
}): string | null {
|
|
68
|
+
if (opts.stopHookActive) return null; // already blocked once — let it end
|
|
69
|
+
if (!opts.worktree) return null;
|
|
70
|
+
if (opts.commits < 1) return null;
|
|
71
|
+
if (opts.verdicts > 0) return null;
|
|
72
|
+
|
|
73
|
+
const lines: string[] = [
|
|
74
|
+
`${opts.commits} commit(s) landed in ${opts.worktree} this session with no verdict filed.`,
|
|
75
|
+
];
|
|
76
|
+
// ≤ 5 commits listed, rest folded into "… +N more" (spec §6: ≤ 12 lines).
|
|
77
|
+
const list = opts.commitList ?? [];
|
|
78
|
+
for (const c of list.slice(0, 5)) lines.push(` ${c}`);
|
|
79
|
+
if (list.length > 5) lines.push(` … +${list.length - 5} more`);
|
|
80
|
+
if (opts.memLastTs) {
|
|
81
|
+
lines.push(
|
|
82
|
+
`mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
|
|
83
|
+
);
|
|
84
|
+
} else {
|
|
85
|
+
lines.push("mem: no rows at all — nothing recorded in this project yet");
|
|
86
|
+
}
|
|
87
|
+
lines.push(
|
|
88
|
+
`Call verdict_submit before ending: worktree must be the absolute path above, ` +
|
|
89
|
+
`regime is one of code|fix|review|plan|inquiry|test, and the note must stand alone ` +
|
|
90
|
+
`(it is read months from now with no access to this conversation). ` +
|
|
91
|
+
`Grade what actually happened — pass-family when it held up, fail if the first ` +
|
|
92
|
+
`attempt was wrong, uncertain when you could not verify it. What deserves a mem ` +
|
|
93
|
+
`row (decision/bug/note) is your call — not every unit needs one.`,
|
|
94
|
+
);
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function git(args: string[], cwd: string): string | null {
|
|
99
|
+
try {
|
|
100
|
+
const p = Bun.spawnSync(["git", ...args], {
|
|
101
|
+
cwd,
|
|
102
|
+
stdout: "pipe",
|
|
103
|
+
stderr: "pipe",
|
|
104
|
+
});
|
|
105
|
+
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Cursor transcript location derived from the conversation id —
|
|
113
|
+
* ~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl where slug is
|
|
114
|
+
* the workspace path minus its leading "/", "/" → "-" (Claude Code's slug
|
|
115
|
+
* convention). Fallback only: a real transcript_path in the payload wins.
|
|
116
|
+
*/
|
|
117
|
+
export function cursorTranscriptPath(
|
|
118
|
+
home: string,
|
|
119
|
+
cwd: string,
|
|
120
|
+
conversationId: string,
|
|
121
|
+
): string {
|
|
122
|
+
const slug = cwd.replace(/^\//, "").replace(/\//g, "-");
|
|
123
|
+
return join(
|
|
124
|
+
home,
|
|
125
|
+
".cursor",
|
|
126
|
+
"projects",
|
|
127
|
+
slug,
|
|
128
|
+
"agent-transcripts",
|
|
129
|
+
conversationId,
|
|
130
|
+
`${conversationId}.jsonl`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isCursorPayload(raw: RawStopPayload): boolean {
|
|
135
|
+
return (
|
|
136
|
+
Array.isArray(raw.workspace_roots) ||
|
|
137
|
+
typeof raw.conversation_id === "string"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Field-mapping only — both clients feed the same decideStop below. */
|
|
142
|
+
export function normalizeStopInput(
|
|
143
|
+
raw: RawStopPayload,
|
|
144
|
+
home: string,
|
|
145
|
+
): NormalizedStopInput {
|
|
146
|
+
if (isCursorPayload(raw)) {
|
|
147
|
+
const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
|
|
148
|
+
let transcriptPath =
|
|
149
|
+
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
150
|
+
? raw.transcript_path
|
|
151
|
+
: null;
|
|
152
|
+
if (!transcriptPath && raw.conversation_id) {
|
|
153
|
+
transcriptPath = cursorTranscriptPath(home, cwd, raw.conversation_id);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
client: "cursor",
|
|
157
|
+
cwd,
|
|
158
|
+
transcriptPath,
|
|
159
|
+
// loop_count counts follow-ups this hook already triggered — ≥ 1 means
|
|
160
|
+
// we already blocked once (Cursor's stop_hook_active).
|
|
161
|
+
stopHookActive: (raw.loop_count ?? 0) > 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
client: "claude",
|
|
166
|
+
cwd: raw.cwd ?? process.cwd(),
|
|
167
|
+
transcriptPath: raw.transcript_path ?? null,
|
|
168
|
+
stopHookActive: raw.stop_hook_active === true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Claude blocks with decision:block; Cursor's stop hook "blocks" by
|
|
173
|
+
* auto-submitting the reason as the next user message. */
|
|
174
|
+
export function stopOutput(client: StopClient, reason: string): string {
|
|
175
|
+
return client === "cursor"
|
|
176
|
+
? JSON.stringify({ followup_message: reason })
|
|
177
|
+
: JSON.stringify({ decision: "block", reason });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
181
|
+
export async function cmdHookStop(): Promise<void> {
|
|
182
|
+
let reason: string | null = null;
|
|
183
|
+
let client: StopClient = "claude";
|
|
184
|
+
try {
|
|
185
|
+
const raw = JSON.parse(await Bun.stdin.text()) as RawStopPayload;
|
|
186
|
+
const norm = normalizeStopInput(raw, homedir());
|
|
187
|
+
client = norm.client;
|
|
188
|
+
// Cursor aborted/errored turns pass: the user said stop, or the loop
|
|
189
|
+
// died — commits from those turns are still caught at the next completed
|
|
190
|
+
// stop (the window is the conversation transcript's birthtime).
|
|
191
|
+
if (client === "cursor" && raw.status !== "completed") return;
|
|
192
|
+
|
|
193
|
+
const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
|
|
194
|
+
|
|
195
|
+
// Session start = when the transcript file was created. No transcript,
|
|
196
|
+
// no window to measure — allow.
|
|
197
|
+
let since: string | null = null;
|
|
198
|
+
if (norm.transcriptPath) {
|
|
199
|
+
try {
|
|
200
|
+
since = utcStamp(statSync(norm.transcriptPath).birthtime);
|
|
201
|
+
} catch {
|
|
202
|
+
since = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let commits = 0;
|
|
207
|
+
let commitList: string[] = [];
|
|
208
|
+
let verdicts = 0;
|
|
209
|
+
let memLastTs: string | null = null;
|
|
210
|
+
if (worktree && since) {
|
|
211
|
+
const log = git(
|
|
212
|
+
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
213
|
+
norm.cwd,
|
|
214
|
+
);
|
|
215
|
+
commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
216
|
+
commits = commitList.length;
|
|
217
|
+
if (commits > 0) {
|
|
218
|
+
const db = openDb();
|
|
219
|
+
const row = db
|
|
220
|
+
.query(
|
|
221
|
+
`SELECT COUNT(*) AS n FROM events e JOIN runs r ON r.id = e.run_id
|
|
222
|
+
WHERE e.kind = 'gate' AND r.worktree = ? AND e.ts >= ?`,
|
|
223
|
+
)
|
|
224
|
+
.get(worktree, since) as { n: number } | null;
|
|
225
|
+
verdicts = row?.n ?? 0;
|
|
226
|
+
// Informational only — read-only, degrade silently (mem status never
|
|
227
|
+
// becomes a block condition, กฎ 7).
|
|
228
|
+
try {
|
|
229
|
+
const mem = readMemLog(worktree);
|
|
230
|
+
memLastTs = mem.rows[0]?.ts ?? null;
|
|
231
|
+
} catch {
|
|
232
|
+
memLastTs = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
reason = decideStop({
|
|
238
|
+
stopHookActive: norm.stopHookActive,
|
|
239
|
+
worktree: since ? worktree : null,
|
|
240
|
+
commits,
|
|
241
|
+
verdicts,
|
|
242
|
+
commitList,
|
|
243
|
+
memLastTs,
|
|
244
|
+
});
|
|
245
|
+
} catch {
|
|
246
|
+
reason = null; // any failure = allow the turn to end
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (reason) console.log(stopOutput(client, reason));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- Read hint (PreToolUse annotate — never block, never dedupe) ---
|
|
253
|
+
//
|
|
254
|
+
// การอ่านไฟล์ใหญ่ทั้งไฟล์เป็นจุดที่ agent จ่าย token โดยไม่รู้ตัว — เสียงเตือน
|
|
255
|
+
// ใน skill ไม่เคยพอ (หลักเดียวกับ Stop hook: พูดตอนมันกำลังจ่าย) แต่ hook นี้
|
|
256
|
+
// **annotate เท่านั้น**: ไม่มี permissionDecision, ไม่มี "อ่านไปแล้ว" dedupe —
|
|
257
|
+
// context compaction ทำให้ "อ่านไปแล้ว" กลายเป็นเท็จ และ hook ที่เดาผิดแล้วขัง
|
|
258
|
+
// agent แย่กว่าไม่มี hook (กฎของ hook.ts เดิม) — annotate ขังไม่ได้ด้วย
|
|
259
|
+
// construction, ต้นทุนพลาดสูงสุดคือบรรทัดเดียวที่ไม่จำเป็น
|
|
260
|
+
//
|
|
261
|
+
// ข้อความเป็น fact ล้วน (จำนวนบรรทัด + คำสั่ง + ค่าที่วัดครั้งเดียว) ไม่ใช่
|
|
262
|
+
// estimate ต่อไฟล์ — เดา token เป็นการแต่งตัวเป็นข้อมูล ขัด "facts only"
|
|
263
|
+
|
|
264
|
+
/** Below this size a full read is already cheap — stay silent. */
|
|
265
|
+
export const READ_HINT_MIN_BYTES = 24_000;
|
|
266
|
+
/** A caller-chosen limit below this is a bounded read — already cheap. */
|
|
267
|
+
export const READ_HINT_MIN_LIMIT = 300;
|
|
268
|
+
/** One-time measurement (2026-09-17, this repo): 5 files / 2,146 lines ≈ 3.7KB out. */
|
|
269
|
+
const READ_HINT_MEASURED = "measured ~3.7KB output on a 2,146-line file";
|
|
270
|
+
|
|
271
|
+
export interface ReadHintInput {
|
|
272
|
+
filePath: unknown;
|
|
273
|
+
offset?: unknown;
|
|
274
|
+
limit?: unknown;
|
|
275
|
+
cwd: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Factual one-liner for a full-file read of a large source file, or null.
|
|
280
|
+
* Every unknown (no path, non-source ext, small file, bounded read, no git
|
|
281
|
+
* repo, stat/read failure) resolves to null — a hint must never fire on a
|
|
282
|
+
* guess. Fast path is statSync only; the file is read just to count lines,
|
|
283
|
+
* and only after the size threshold passed.
|
|
284
|
+
*/
|
|
285
|
+
export function readHintFor(opts: ReadHintInput): string | null {
|
|
286
|
+
try {
|
|
287
|
+
if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
|
|
288
|
+
const dot = opts.filePath.lastIndexOf(".");
|
|
289
|
+
// SCAN_EXTS keys carry the dot (".ts") — slice from the dot itself.
|
|
290
|
+
if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
|
|
291
|
+
const limit = typeof opts.limit === "number" ? opts.limit : null;
|
|
292
|
+
if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
|
|
293
|
+
const st = statSync(opts.filePath);
|
|
294
|
+
if (!st.isFile() || st.size < READ_HINT_MIN_BYTES) return null;
|
|
295
|
+
// review-seed is a git command — outside a repo the hint would lie.
|
|
296
|
+
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
297
|
+
cwd: opts.cwd,
|
|
298
|
+
stdout: "pipe",
|
|
299
|
+
stderr: "pipe",
|
|
300
|
+
});
|
|
301
|
+
if (git.exitCode !== 0) return null;
|
|
302
|
+
const lines = readFileSync(opts.filePath, "utf-8").split("\n").length;
|
|
303
|
+
// The hint feeds a command line — inside the worktree show the clean
|
|
304
|
+
// relative path, outside it relative() climbs dots, show absolute.
|
|
305
|
+
const rel = relative(opts.cwd, opts.filePath);
|
|
306
|
+
const shown = rel.startsWith("..") ? opts.filePath : rel;
|
|
307
|
+
return (
|
|
308
|
+
`fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
|
|
309
|
+
`returns exports with line numbers, importers, and signatures first ` +
|
|
310
|
+
`(${READ_HINT_MEASURED})`
|
|
311
|
+
);
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out.
|
|
318
|
+
* No permissionDecision ever — the tool call always proceeds. */
|
|
319
|
+
export async function cmdHookReadHint(): Promise<void> {
|
|
320
|
+
try {
|
|
321
|
+
const raw = JSON.parse(await Bun.stdin.text()) as {
|
|
322
|
+
cwd?: string;
|
|
323
|
+
tool_input?: {
|
|
324
|
+
file_path?: unknown;
|
|
325
|
+
offset?: unknown;
|
|
326
|
+
limit?: unknown;
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
const cwd = raw.cwd ?? process.cwd();
|
|
330
|
+
const parts: string[] = [];
|
|
331
|
+
const hint = readHintFor({
|
|
332
|
+
filePath: raw.tool_input?.file_path,
|
|
333
|
+
offset: raw.tool_input?.offset,
|
|
334
|
+
limit: raw.tool_input?.limit,
|
|
335
|
+
cwd,
|
|
336
|
+
});
|
|
337
|
+
if (hint) parts.push(hint);
|
|
338
|
+
for (const line of readContextLines(raw.tool_input?.file_path, cwd)) {
|
|
339
|
+
parts.push(line);
|
|
340
|
+
}
|
|
341
|
+
if (parts.length > 0) {
|
|
342
|
+
console.log(
|
|
343
|
+
JSON.stringify({
|
|
344
|
+
hookSpecificOutput: {
|
|
345
|
+
hookEventName: "PreToolUse",
|
|
346
|
+
additionalContext: parts.join("\n"),
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// any failure = no hint; a hook must never block a read over a hint
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- Debt + mem context (PLAN-convention-debt chunk 4) ---
|
|
357
|
+
//
|
|
358
|
+
// จังหวะเดียวที่การแก้หนี้คุ้ม token คือตอนที่เปิดไฟล์นั้นอยู่แล้ว — hook-read-hint
|
|
359
|
+
// จึงแนบสองอย่างต่อท้าย size hint: convention ที่ไฟล์ยังค้าง (debt detector, คำนวณสด)
|
|
360
|
+
// และแถว mem ที่เอ่ยถึงไฟล์นั้น (ข้าม session) · **annotate เท่านั้น** เหมือนเดิม —
|
|
361
|
+
// ไม่ block, ไม่ dedupe, ทุก unknown → เงียบ · cap รวม 5 บรรทัด (หนี้ 3 · mem 2)
|
|
362
|
+
|
|
363
|
+
const DEBT_HINT_MAX = 3;
|
|
364
|
+
const MEM_HINT_MAX = 2;
|
|
365
|
+
const MEM_TEXT_MAX = 120;
|
|
366
|
+
|
|
367
|
+
export function readContextLines(filePath: unknown, cwd: string): string[] {
|
|
368
|
+
try {
|
|
369
|
+
if (typeof filePath !== "string" || filePath === "") return [];
|
|
370
|
+
const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
371
|
+
cwd,
|
|
372
|
+
stdout: "pipe",
|
|
373
|
+
stderr: "pipe",
|
|
374
|
+
});
|
|
375
|
+
if (git.exitCode !== 0) return [];
|
|
376
|
+
// macOS /var → /private/var: git reports the resolved root while callers
|
|
377
|
+
// pass unresolved tmp paths — normalize both sides before comparing.
|
|
378
|
+
const worktree = realpathSync(git.stdout.toString().trim());
|
|
379
|
+
const abs = realpathSync(
|
|
380
|
+
filePath.startsWith("/") ? filePath : join(worktree, filePath),
|
|
381
|
+
);
|
|
382
|
+
const rel = relative(worktree, abs).split("\\").join("/");
|
|
383
|
+
if (rel.startsWith("..") || rel === "") return [];
|
|
384
|
+
|
|
385
|
+
const lines: string[] = [];
|
|
386
|
+
|
|
387
|
+
// convention debt — source files only, fresh from the repo
|
|
388
|
+
const dot = rel.lastIndexOf(".");
|
|
389
|
+
if (dot >= 0 && SCAN_EXTS.has(rel.slice(dot))) {
|
|
390
|
+
for (const c of debtForFile(
|
|
391
|
+
worktree,
|
|
392
|
+
abs,
|
|
393
|
+
loadConventions(worktree),
|
|
394
|
+
).slice(0, DEBT_HINT_MAX)) {
|
|
395
|
+
lines.push(`fapony debt: [${c.id}] ${c.rule}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// mem rows that are about this file
|
|
400
|
+
const mem = readMemLog(worktree);
|
|
401
|
+
if (mem.rows.length > 0) {
|
|
402
|
+
const base = basename(rel);
|
|
403
|
+
const direct: typeof mem.rows = [];
|
|
404
|
+
const baseOnly: typeof mem.rows = [];
|
|
405
|
+
for (const r of mem.rows) {
|
|
406
|
+
if (r.kind === "claim" || r.kind === "release") continue;
|
|
407
|
+
const hay = `${r.text}\n${r.spec ?? ""}\n${(r.files ?? []).join(",")}`;
|
|
408
|
+
if ((r.files ?? []).includes(rel) || hay.includes(rel)) {
|
|
409
|
+
direct.push(r);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (base && hay.includes(base)) baseOnly.push(r);
|
|
413
|
+
}
|
|
414
|
+
// A bare-basename hit is only usable when that name is unique in the
|
|
415
|
+
// repo (24% of files share a basename — guessing would attach a row
|
|
416
|
+
// about a DIFFERENT index.ts). The walk is paid only when a hit exists.
|
|
417
|
+
let usableBase = baseOnly;
|
|
418
|
+
if (baseOnly.length > 0) {
|
|
419
|
+
const sameName = collectSourceFiles(worktree).filter(
|
|
420
|
+
(f) => basename(f) === base,
|
|
421
|
+
).length;
|
|
422
|
+
if (sameName !== 1) usableBase = [];
|
|
423
|
+
}
|
|
424
|
+
// direct hits (files[] / full path) outrank bare-basename hits
|
|
425
|
+
const memLines = [...direct, ...usableBase].slice(0, MEM_HINT_MAX);
|
|
426
|
+
for (const r of memLines) {
|
|
427
|
+
lines.push(
|
|
428
|
+
`fapony mem: ${r.ts.slice(0, 10)} ${r.kind} — ${r.text.slice(0, MEM_TEXT_MAX)}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return lines.slice(0, DEBT_HINT_MAX + MEM_HINT_MAX);
|
|
433
|
+
} catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
}
|