fapony 0.3.0 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -65
- package/fapony.ts +3 -116
- package/package.json +6 -5
- package/skill/move-to-done/SKILL.md +22 -32
- package/skill/review-pony/SKILL.md +27 -58
- package/src/adapters/cli.ts +123 -0
- package/src/adapters/hooks/compute-hint-impact.ts +107 -0
- package/src/adapters/hooks/context-data.ts +102 -0
- package/src/adapters/hooks/edit-hint.ts +195 -0
- package/src/adapters/hooks/git-autonomy.ts +178 -0
- package/src/adapters/hooks/index.ts +79 -0
- package/src/adapters/hooks/mv-guard.ts +52 -0
- package/src/adapters/hooks/read-hint.ts +383 -0
- package/src/adapters/hooks/session-start.ts +101 -0
- package/src/adapters/hooks/stop.ts +302 -0
- package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
- package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
- package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
- package/src/{mcp → adapters/mcp}/transport.ts +4 -39
- package/src/adapters/mcp/types.ts +18 -0
- package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
- package/src/analyze.ts +2 -2
- package/src/conventions-seed.ts +1 -1
- package/src/core/config.ts +199 -0
- package/src/core/debt-format.ts +107 -0
- package/src/core/debt-types.ts +79 -0
- package/src/core/defaults.ts +8 -0
- package/src/core/enums.ts +34 -0
- package/src/core/format.ts +33 -0
- package/src/core/hint-log.ts +68 -0
- package/src/core/hook-helpers.ts +31 -0
- package/src/core/mem-log.ts +357 -0
- package/src/core/parse.ts +71 -0
- package/src/core/pricing.ts +217 -0
- package/src/core/safety.ts +18 -0
- package/src/core/types.ts +142 -0
- package/src/core/util.ts +105 -0
- package/src/db/store.ts +2 -2
- package/src/debt/cli.ts +1 -1
- package/src/debt/format.ts +2 -107
- package/src/debt/load.ts +1 -1
- package/src/debt/promotion.ts +1 -1
- package/src/debt/types.ts +14 -79
- package/src/digest/collect.ts +4 -3
- package/src/gate.ts +5 -5
- package/src/gates.ts +1 -1
- package/src/hook.ts +69 -1337
- package/src/init-mem.ts +58 -72
- package/src/init.ts +13 -17
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +19 -123
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +167 -26
- package/src/install.ts +23 -7
- package/src/lint-baseline.ts +1 -2
- package/src/map.ts +29 -8
- package/src/mem/commands/plan.ts +70 -42
- package/src/mem/commands/read.ts +219 -146
- package/src/mem/index.ts +4 -13
- package/src/mem/store.ts +5 -1
- package/src/memory.ts +24 -387
- package/src/parse.ts +9 -71
- package/src/price/fetch.ts +4 -16
- package/src/price/resolve.ts +12 -213
- package/src/report/cli.ts +3 -3
- package/src/safety.ts +2 -18
- package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
- package/src/seed/primitives.ts +60 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/session/types.ts +14 -128
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +4 -4
- package/src/telemetry.ts +3 -3
- package/src/usage/cache.ts +1 -2
- package/src/usage/cli.ts +1 -1
- package/src/usage/scan.ts +2 -1
- package/src/util.ts +10 -32
- package/src/web/html.ts +2 -33
- package/templates/SPEC.md +8 -1
- package/images/logo.png +0 -0
- package/images/logo.webp +0 -0
- package/images/logo@400.webp +0 -0
- package/images/sample.webp +0 -0
- package/images/summary.webp +0 -0
- package/src/db/defaults.ts +0 -34
- package/src/db/getters.ts +0 -35
- package/src/db/index.ts +0 -7
- package/src/db/load.ts +0 -57
- package/src/db/types.ts +0 -77
- package/src/math.ts +0 -13
- package/src/mcp/tools/verdict.ts +0 -161
- package/src/mcp/types.ts +0 -54
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/adapters/hooks/session-start.ts — SessionStart hook: emit mem kickoff
|
|
2
|
+
// as context.
|
|
3
|
+
//
|
|
4
|
+
// Split from src/hook.ts (PLAN-lib-layer chunk 3). sessionStartContext is the
|
|
5
|
+
// one copy shared by Claude/Codex CLI and the generated OpenCode plugin.
|
|
6
|
+
|
|
7
|
+
import { whereMemDir } from "../../memory.js";
|
|
8
|
+
|
|
9
|
+
/** Cap on injected context — kickoff is short, a broken repo's output is not. */
|
|
10
|
+
export const SESSION_START_MAX_CHARS = 4_000;
|
|
11
|
+
|
|
12
|
+
const TRUNCATED = "… truncated — run `fapony mem find <word>` for the rest";
|
|
13
|
+
|
|
14
|
+
/** Trim to whole lines, keeping the marker's line boundary intact. */
|
|
15
|
+
function headLines(text: string, max: number): string {
|
|
16
|
+
const cut = text.slice(0, max);
|
|
17
|
+
const lastNl = cut.lastIndexOf("\n");
|
|
18
|
+
return (lastNl > 0 ? cut.slice(0, lastNl) : cut).trimEnd();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Trim to whole lines within the cap, with an honest truncation marker.
|
|
23
|
+
*
|
|
24
|
+
* Kickoff's ordering is: ranked rows → "## next up" → "## recent". The
|
|
25
|
+
* most actionable content is at the top (bugs, diff-matched) and in "## next
|
|
26
|
+
* up". A plain head-cut drops the suggestions — so we try to keep "## next up"
|
|
27
|
+
* visible. When "## recent" exists, we cut it first; otherwise fall back to
|
|
28
|
+
* keeping "## next up" at the tail.
|
|
29
|
+
*/
|
|
30
|
+
export function capContext(
|
|
31
|
+
text: string,
|
|
32
|
+
max = SESSION_START_MAX_CHARS,
|
|
33
|
+
): string {
|
|
34
|
+
if (text.length <= max) return text;
|
|
35
|
+
const recentIdx = text.indexOf("\n## recent");
|
|
36
|
+
if (recentIdx > 0) {
|
|
37
|
+
const head = text.slice(0, recentIdx).trimEnd();
|
|
38
|
+
if (head.length <= max) return head;
|
|
39
|
+
const nextIdx = head.lastIndexOf("\n## next up");
|
|
40
|
+
if (nextIdx > 0) {
|
|
41
|
+
const beforeNext = head.slice(0, nextIdx).trimEnd();
|
|
42
|
+
const nextTail = head.slice(nextIdx).trimEnd();
|
|
43
|
+
if (nextTail.length < max / 2) {
|
|
44
|
+
return `${headLines(beforeNext, max - nextTail.length)}\n${TRUNCATED}\n${nextTail}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return `${headLines(head, max)}\n${TRUNCATED}`;
|
|
48
|
+
}
|
|
49
|
+
const at = text.lastIndexOf("\n## next up");
|
|
50
|
+
const tail = at > 0 ? text.slice(at).trimEnd() : "";
|
|
51
|
+
if (tail && tail.length < max / 2) {
|
|
52
|
+
return `${headLines(text, max - tail.length)}\n${TRUNCATED}\n${tail}`;
|
|
53
|
+
}
|
|
54
|
+
return `${headLines(text, max)}\n${TRUNCATED}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Kickoff output for `cwd`'s mem log, capped — or null when there is no log in
|
|
59
|
+
* scope, the command fails, or it prints nothing. The single implementation
|
|
60
|
+
* behind both cmdHookSessionStart (Claude/Codex) and the generated OpenCode
|
|
61
|
+
* plugin, so a pull of INSTALL_ROOT updates all three.
|
|
62
|
+
*
|
|
63
|
+
* `faponyTs` is where the CLI lives. The hook defaults to `Bun.main` (itself,
|
|
64
|
+
* when run as `bun <root>/fapony.ts hook-session-start`); the OpenCode plugin
|
|
65
|
+
* must pass its baked path because `Bun.main` inside OpenCode is OpenCode's own
|
|
66
|
+
* entry, not fapony's.
|
|
67
|
+
*/
|
|
68
|
+
export function sessionStartContext(
|
|
69
|
+
cwd: string,
|
|
70
|
+
faponyTs: string = Bun.main,
|
|
71
|
+
): string | null {
|
|
72
|
+
if (!whereMemDir(cwd).dir) return null;
|
|
73
|
+
const p = Bun.spawnSync([process.execPath, faponyTs, "mem", "kickoff"], {
|
|
74
|
+
cwd,
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "pipe",
|
|
77
|
+
});
|
|
78
|
+
const out = p.stdout.toString().trim();
|
|
79
|
+
if (p.exitCode !== 0 || !out) return null;
|
|
80
|
+
return capContext(out);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** SessionStart hook: emit sessionStartContext as Claude/Codex JSON. */
|
|
84
|
+
export async function cmdHookSessionStart(): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
|
|
87
|
+
const cwd = raw.cwd ?? process.cwd();
|
|
88
|
+
const ctx = sessionStartContext(cwd);
|
|
89
|
+
if (!ctx) return;
|
|
90
|
+
console.log(
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
hookSpecificOutput: {
|
|
93
|
+
hookEventName: "SessionStart",
|
|
94
|
+
additionalContext: ctx,
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
// any failure = no context, never a broken session start
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// src/adapters/hooks/stop.ts — Stop hook: refuse to end a turn that produced
|
|
2
|
+
// commits but no mem row.
|
|
3
|
+
//
|
|
4
|
+
// Split from src/hook.ts (PLAN-lib-layer chunk 3). Pure helpers (utcStamp,
|
|
5
|
+
// hookTsMs, sessionKey) live in core/hook-helpers.ts.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
appendFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
statSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { hookTsMs, sessionKey, utcStamp } from "../../core/hook-helpers.js";
|
|
17
|
+
import { readMemLog, whereMemDir } from "../../memory.js";
|
|
18
|
+
|
|
19
|
+
// --- Types ---
|
|
20
|
+
|
|
21
|
+
export interface RawStopPayload {
|
|
22
|
+
// Claude Code
|
|
23
|
+
cwd?: string;
|
|
24
|
+
transcript_path?: string | null;
|
|
25
|
+
stop_hook_active?: boolean;
|
|
26
|
+
// Cursor — common schema (https://cursor.com/docs/agent/hooks)
|
|
27
|
+
workspace_roots?: string[];
|
|
28
|
+
conversation_id?: string;
|
|
29
|
+
loop_count?: number;
|
|
30
|
+
status?: string;
|
|
31
|
+
// Codex — hooks contract (https://learn.chatgpt.com/docs/hooks)
|
|
32
|
+
session_id?: string;
|
|
33
|
+
model?: string;
|
|
34
|
+
permission_mode?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StopClient = "claude" | "cursor" | "codex";
|
|
38
|
+
|
|
39
|
+
export interface NormalizedStopInput {
|
|
40
|
+
client: StopClient;
|
|
41
|
+
cwd: string;
|
|
42
|
+
transcriptPath: string | null;
|
|
43
|
+
stopHookActive: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Pure helpers ---
|
|
47
|
+
|
|
48
|
+
/** Cursor transcript location derived from the conversation id. */
|
|
49
|
+
export function cursorTranscriptPath(
|
|
50
|
+
home: string,
|
|
51
|
+
cwd: string,
|
|
52
|
+
conversationId: string,
|
|
53
|
+
): string {
|
|
54
|
+
const slug = cwd.replace(/^\//, "").replace(/\//g, "-");
|
|
55
|
+
return join(
|
|
56
|
+
home,
|
|
57
|
+
".cursor",
|
|
58
|
+
"projects",
|
|
59
|
+
slug,
|
|
60
|
+
"agent-transcripts",
|
|
61
|
+
conversationId,
|
|
62
|
+
`${conversationId}.jsonl`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isCursorPayload(raw: RawStopPayload): boolean {
|
|
67
|
+
return (
|
|
68
|
+
Array.isArray(raw.workspace_roots) ||
|
|
69
|
+
typeof raw.conversation_id === "string"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Codex sends permission_mode and/or model — fields neither Claude nor Cursor include in Stop. */
|
|
74
|
+
export function isCodexPayload(raw: RawStopPayload): boolean {
|
|
75
|
+
return (
|
|
76
|
+
typeof raw.permission_mode === "string" ||
|
|
77
|
+
(typeof raw.model === "string" && !isCursorPayload(raw))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Field-mapping only — all three clients feed the same decideStop below. */
|
|
82
|
+
export function normalizeStopInput(
|
|
83
|
+
raw: RawStopPayload,
|
|
84
|
+
home: string,
|
|
85
|
+
): NormalizedStopInput {
|
|
86
|
+
if (isCodexPayload(raw)) {
|
|
87
|
+
return {
|
|
88
|
+
client: "codex",
|
|
89
|
+
cwd: raw.cwd ?? process.cwd(),
|
|
90
|
+
transcriptPath:
|
|
91
|
+
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
92
|
+
? raw.transcript_path
|
|
93
|
+
: null,
|
|
94
|
+
stopHookActive: raw.stop_hook_active === true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (isCursorPayload(raw)) {
|
|
98
|
+
const cwd = raw.workspace_roots?.[0] ?? raw.cwd ?? process.cwd();
|
|
99
|
+
let transcriptPath =
|
|
100
|
+
typeof raw.transcript_path === "string" && raw.transcript_path
|
|
101
|
+
? raw.transcript_path
|
|
102
|
+
: null;
|
|
103
|
+
if (!transcriptPath && raw.conversation_id) {
|
|
104
|
+
transcriptPath = cursorTranscriptPath(home, cwd, raw.conversation_id);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
client: "cursor",
|
|
108
|
+
cwd,
|
|
109
|
+
transcriptPath,
|
|
110
|
+
stopHookActive: (raw.loop_count ?? 0) > 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
client: "claude",
|
|
115
|
+
cwd: raw.cwd ?? process.cwd(),
|
|
116
|
+
transcriptPath: raw.transcript_path ?? null,
|
|
117
|
+
stopHookActive: raw.stop_hook_active === true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pure decision: block when this session produced commits but no mem row
|
|
123
|
+
* newer than session start exists.
|
|
124
|
+
*/
|
|
125
|
+
export function decideStop(opts: {
|
|
126
|
+
stopHookActive: boolean;
|
|
127
|
+
worktree: string | null;
|
|
128
|
+
commits: number;
|
|
129
|
+
since?: string | null;
|
|
130
|
+
commitList?: string[];
|
|
131
|
+
memLastTs?: string | null;
|
|
132
|
+
memCandidates?: string[];
|
|
133
|
+
}): string | null {
|
|
134
|
+
if (opts.stopHookActive) return null;
|
|
135
|
+
if (!opts.worktree) return null;
|
|
136
|
+
if (opts.commits < 1) return null;
|
|
137
|
+
if (!opts.memLastTs && !opts.memCandidates?.length) return null;
|
|
138
|
+
if (opts.since && opts.memLastTs) {
|
|
139
|
+
const sinceMs = hookTsMs(opts.since);
|
|
140
|
+
const memMs = hookTsMs(opts.memLastTs);
|
|
141
|
+
if (Number.isNaN(sinceMs) || Number.isNaN(memMs)) return null;
|
|
142
|
+
if (memMs >= sinceMs) return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines: string[] = [
|
|
146
|
+
`${opts.commits} commit(s) landed in ${opts.worktree} this session — no mem row recorded for this work.`,
|
|
147
|
+
];
|
|
148
|
+
const list = opts.commitList ?? [];
|
|
149
|
+
for (const c of list.slice(0, 5)) lines.push(` ${c}`);
|
|
150
|
+
if (list.length > 5) lines.push(` … +${list.length - 5} more`);
|
|
151
|
+
if (opts.memLastTs) {
|
|
152
|
+
lines.push(
|
|
153
|
+
`mem: last row ${opts.memLastTs.slice(0, 10)} — nothing newer this session`,
|
|
154
|
+
);
|
|
155
|
+
} else if (opts.memCandidates?.length) {
|
|
156
|
+
lines.push(
|
|
157
|
+
`mem: no log in scope from ${opts.worktree} — found ` +
|
|
158
|
+
`${opts.memCandidates.join(", ")} (run mem commands from there, or --mem-dir)`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
lines.push(
|
|
163
|
+
`Record a mem row before ending: fapony mem add <decision|bug|note> "what happened" --files <files> ` +
|
|
164
|
+
`${opts.worktree}/.fapony/plan/PLAN.md (or the relevant plan). ` +
|
|
165
|
+
`files[] is required — a row without it is unfindable when you touch that file next session.`,
|
|
166
|
+
);
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Claude blocks with decision:block; Cursor auto-submits as followup_message;
|
|
171
|
+
* Codex continues with decision:block + reason. */
|
|
172
|
+
export function stopOutput(client: StopClient, reason: string): string {
|
|
173
|
+
if (client === "cursor") return JSON.stringify({ followup_message: reason });
|
|
174
|
+
if (client === "codex") return JSON.stringify({ decision: "block", reason });
|
|
175
|
+
return JSON.stringify({ decision: "block", reason });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Block dedupe ---
|
|
179
|
+
|
|
180
|
+
const STOP_BLOCK_DIR = "stop-block";
|
|
181
|
+
|
|
182
|
+
interface StopBlockRow {
|
|
183
|
+
ts: string;
|
|
184
|
+
worktree: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Absolute path of a session's block log — may not exist. */
|
|
188
|
+
export function stopBlockPath(session: string): string {
|
|
189
|
+
const base =
|
|
190
|
+
process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
|
|
191
|
+
return join(base, STOP_BLOCK_DIR, `${sessionKey(session)}.jsonl`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* True when this session already blocked for this worktree — the caller then
|
|
196
|
+
* lets the turn end. Records the block when it has not.
|
|
197
|
+
*/
|
|
198
|
+
export function stopBlockedBefore(
|
|
199
|
+
session: string | null,
|
|
200
|
+
worktree: string,
|
|
201
|
+
): boolean {
|
|
202
|
+
if (!session) return false;
|
|
203
|
+
const path = stopBlockPath(session);
|
|
204
|
+
try {
|
|
205
|
+
if (existsSync(path)) {
|
|
206
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
207
|
+
if (!line) continue;
|
|
208
|
+
try {
|
|
209
|
+
if ((JSON.parse(line) as StopBlockRow).worktree === worktree) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// a torn line must not lose the rest of the log
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const dir = join(path, "..");
|
|
218
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
219
|
+
const row: StopBlockRow = { ts: new Date().toISOString(), worktree };
|
|
220
|
+
appendFileSync(path, `${JSON.stringify(row)}\n`, "utf-8");
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function git(args: string[], cwd: string): string | null {
|
|
228
|
+
try {
|
|
229
|
+
const p = Bun.spawnSync(["git", ...args], {
|
|
230
|
+
cwd,
|
|
231
|
+
stdout: "pipe",
|
|
232
|
+
stderr: "pipe",
|
|
233
|
+
});
|
|
234
|
+
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Reads the Stop-hook JSON on stdin, prints a block decision or nothing. */
|
|
241
|
+
export async function cmdHookStop(): Promise<void> {
|
|
242
|
+
let reason: string | null = null;
|
|
243
|
+
let client: StopClient = "claude";
|
|
244
|
+
try {
|
|
245
|
+
const raw = JSON.parse(await Bun.stdin.text()) as RawStopPayload;
|
|
246
|
+
const norm = normalizeStopInput(raw, homedir());
|
|
247
|
+
client = norm.client;
|
|
248
|
+
if (client === "cursor" && raw.status !== "completed") return;
|
|
249
|
+
|
|
250
|
+
const worktree = git(["rev-parse", "--show-toplevel"], norm.cwd);
|
|
251
|
+
|
|
252
|
+
let since: string | null = null;
|
|
253
|
+
if (norm.transcriptPath) {
|
|
254
|
+
try {
|
|
255
|
+
since = utcStamp(statSync(norm.transcriptPath).birthtime);
|
|
256
|
+
} catch {
|
|
257
|
+
since = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let commits = 0;
|
|
262
|
+
let commitList: string[] = [];
|
|
263
|
+
let memLastTs: string | null = null;
|
|
264
|
+
let memCandidates: string[] = [];
|
|
265
|
+
if (worktree && since) {
|
|
266
|
+
const log = git(
|
|
267
|
+
["log", "--since", `${since} +0000`, "--format=%h %s"],
|
|
268
|
+
norm.cwd,
|
|
269
|
+
);
|
|
270
|
+
commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
271
|
+
commits = commitList.length;
|
|
272
|
+
try {
|
|
273
|
+
const mem = readMemLog(worktree);
|
|
274
|
+
memLastTs = mem.rows[0]?.ts ?? null;
|
|
275
|
+
if (!memLastTs) memCandidates = whereMemDir(worktree).candidates ?? [];
|
|
276
|
+
} catch {
|
|
277
|
+
memLastTs = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
reason = decideStop({
|
|
282
|
+
stopHookActive: norm.stopHookActive,
|
|
283
|
+
worktree: since ? worktree : null,
|
|
284
|
+
commits,
|
|
285
|
+
since,
|
|
286
|
+
commitList,
|
|
287
|
+
memLastTs,
|
|
288
|
+
memCandidates,
|
|
289
|
+
});
|
|
290
|
+
if (
|
|
291
|
+
reason &&
|
|
292
|
+
worktree &&
|
|
293
|
+
stopBlockedBefore(norm.transcriptPath, worktree)
|
|
294
|
+
) {
|
|
295
|
+
reason = null;
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
reason = null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (reason) console.log(stopOutput(client, reason));
|
|
302
|
+
}
|
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
evidenceFile,
|
|
24
24
|
FAPONY_DIR,
|
|
25
25
|
safetyDeny,
|
|
26
|
-
} from "
|
|
27
|
-
import { assertSafe } from "
|
|
26
|
+
} from "../../core/config.js";
|
|
27
|
+
import { assertSafe } from "../../safety.js";
|
|
28
28
|
import type { EvidenceItem, EvidenceStatus } from "./primitives.js";
|
|
29
29
|
|
|
30
30
|
// --- Config shape (.fapony/evidence.json by default, paths.evidenceFile to move it) ---
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// §0 rule: add-only — never remove or rename exported symbols.
|
|
8
8
|
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
|
-
import type { BlastEntry } from "
|
|
11
|
-
import { ROOT } from "
|
|
10
|
+
import type { BlastEntry } from "../../analyze.js";
|
|
11
|
+
import { ROOT } from "../../update.js";
|
|
12
12
|
|
|
13
13
|
// ─── Server build identity ─────────────────────────────────────────────
|
|
14
14
|
|
|
@@ -157,7 +157,7 @@ export interface VerificationReport {
|
|
|
157
157
|
evidence: EvidenceItem[];
|
|
158
158
|
/** Evidence summary (computed from evidence array). */
|
|
159
159
|
evidence_summary: EvidenceSummary;
|
|
160
|
-
/** Verdict grade + note (from gate/
|
|
160
|
+
/** Verdict grade + note (from the frozen gate/verdict ledger, or null if never graded). */
|
|
161
161
|
verdict: {
|
|
162
162
|
grade: string;
|
|
163
163
|
note: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/mcp/tools/check.ts — handoff_check tool
|
|
2
2
|
|
|
3
|
-
import { blastRadiusForWorktree } from "
|
|
3
|
+
import { blastRadiusForWorktree } from "../../../analyze.js";
|
|
4
4
|
import type { CheckResult } from "../primitives.js";
|
|
5
5
|
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
6
6
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/mcp/tools/collect.ts — handoff_collect tool
|
|
2
2
|
|
|
3
3
|
import { execSync } from "node:child_process";
|
|
4
|
-
import { isTestFile } from "
|
|
4
|
+
import { isTestFile } from "../../../analyze.js";
|
|
5
5
|
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
6
6
|
|
|
7
7
|
// --- Git helper ---
|
|
@@ -6,85 +6,11 @@
|
|
|
6
6
|
// saves more than it costs (see CLAUDE.md "จ่าย token อย่างฉลาด"). Keep
|
|
7
7
|
// descriptions imperative — say what to send, not why it matters.
|
|
8
8
|
|
|
9
|
-
import { VERDICT_GRADES } from "../../parse.js";
|
|
10
|
-
import { REASON_CODES, REGIME_CODES } from "../types.js";
|
|
11
|
-
|
|
12
9
|
export { toolMemAdd, toolMemClose, toolMemFind } from "./mem.js";
|
|
13
|
-
export { toolVerdictSubmit } from "./verdict.js";
|
|
14
10
|
|
|
15
11
|
// --- Tool definitions ---
|
|
16
12
|
|
|
17
13
|
export const TOOLS = [
|
|
18
|
-
{
|
|
19
|
-
name: "verdict_submit",
|
|
20
|
-
description:
|
|
21
|
-
"Grade a finished unit of work — every unit that ends, including work " +
|
|
22
|
-
"that went right the first time. Submit 'fail' the moment a first " +
|
|
23
|
-
"attempt turns out wrong, then a pass-family verdict once the fix is " +
|
|
24
|
-
"verified. Never leave a run open: one stuck at running/fixing absorbs " +
|
|
25
|
-
"later unrelated verdicts for that worktree. Without run_id, binds to " +
|
|
26
|
-
"the latest open run for the same worktree+plan, else creates one.",
|
|
27
|
-
inputSchema: {
|
|
28
|
-
type: "object" as const,
|
|
29
|
-
properties: {
|
|
30
|
-
run_id: {
|
|
31
|
-
type: "number",
|
|
32
|
-
description: "Existing run to attach to. Omitted = bind or create.",
|
|
33
|
-
},
|
|
34
|
-
verdict: {
|
|
35
|
-
type: "string",
|
|
36
|
-
enum: [...VERDICT_GRADES],
|
|
37
|
-
description: "Verdict grade",
|
|
38
|
-
},
|
|
39
|
-
reason_code: {
|
|
40
|
-
type: "string",
|
|
41
|
-
enum: [...REASON_CODES],
|
|
42
|
-
description:
|
|
43
|
-
"Failure reason. Clean passes take 'none', never 'other'.",
|
|
44
|
-
},
|
|
45
|
-
regime: {
|
|
46
|
-
type: "string",
|
|
47
|
-
enum: [...REGIME_CODES],
|
|
48
|
-
description:
|
|
49
|
-
"Task shape: code=feature/refactor, fix=debugging a defect, " +
|
|
50
|
-
"review=reviewing a diff, plan=producing a plan or spec, " +
|
|
51
|
-
"inquiry=questions without editing files, test=writing tests",
|
|
52
|
-
},
|
|
53
|
-
note: {
|
|
54
|
-
type: "string",
|
|
55
|
-
description:
|
|
56
|
-
"Standalone note — read months later with no access to this " +
|
|
57
|
-
"conversation. Required when reason_code = 'other'.",
|
|
58
|
-
},
|
|
59
|
-
worktree: {
|
|
60
|
-
type: "string",
|
|
61
|
-
description:
|
|
62
|
-
"Absolute repo path (git rev-parse --show-toplevel). Every query " +
|
|
63
|
-
"scopes by it, so a bare name or an omission files the verdict " +
|
|
64
|
-
"where nothing reads it. Neither case errors.",
|
|
65
|
-
},
|
|
66
|
-
plan: {
|
|
67
|
-
type: "string",
|
|
68
|
-
description: "Plan file path, for a new run",
|
|
69
|
-
},
|
|
70
|
-
session_id: {
|
|
71
|
-
type: "string",
|
|
72
|
-
description:
|
|
73
|
-
"Client session id (Claude Code/Codex: the transcript .jsonl " +
|
|
74
|
-
"path; OpenCode/ZCode: the session id) — this is what attributes " +
|
|
75
|
-
"the verdict to a model. Cannot find it → omit, never invent one.",
|
|
76
|
-
},
|
|
77
|
-
files: {
|
|
78
|
-
type: "array",
|
|
79
|
-
items: { type: "string" },
|
|
80
|
-
description:
|
|
81
|
-
"Repo-relative paths this unit touched. Optional, but always " +
|
|
82
|
-
"send them: nothing else records where the work landed.",
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
required: ["verdict", "reason_code", "regime"],
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
14
|
{
|
|
89
15
|
name: "mem_find",
|
|
90
16
|
description:
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// file is unfindable when you touch that file (fill rate 26% CLI-flag vs 88%
|
|
7
7
|
// MCP-required — schema wins over prose every time, see CLAUDE.md rule 9).
|
|
8
8
|
|
|
9
|
-
import { openRows } from "
|
|
9
|
+
import { openRows } from "../../../mem/selectors.js";
|
|
10
10
|
import {
|
|
11
11
|
initStore,
|
|
12
12
|
KINDS,
|
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
put,
|
|
15
15
|
rows,
|
|
16
16
|
type WorkKind,
|
|
17
|
-
} from "
|
|
18
|
-
import { type MemRow, readMemLog, resolveMemDir } from "
|
|
17
|
+
} from "../../../mem/store.js";
|
|
18
|
+
import { type MemRow, readMemLog, resolveMemDir } from "../../../memory.js";
|
|
19
19
|
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
20
20
|
|
|
21
21
|
export interface MemFindResult {
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// run metrics + verdict into a single VerificationReport.
|
|
5
5
|
// Calls existing primitives — no duplicate parser/conformance logic.
|
|
6
6
|
|
|
7
|
-
import { blastRadiusForWorktree } from "
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { parseGateEventData } from "
|
|
7
|
+
import { blastRadiusForWorktree } from "../../../analyze.js";
|
|
8
|
+
import { loadConfig } from "../../../core/config.js";
|
|
9
|
+
import { getEvents, getRun, openDb } from "../../../db/store.js";
|
|
10
|
+
import { parseGateEventData } from "../../../parse.js";
|
|
11
11
|
import { collectEvidence } from "../evidence.js";
|
|
12
12
|
import type { CheckResult, VerificationReport } from "../primitives.js";
|
|
13
13
|
import {
|
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
// src/mcp/transport.ts — JSON-RPC dispatch + stdio entry point
|
|
2
2
|
|
|
3
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
3
|
import { createInterface } from "node:readline";
|
|
7
4
|
import { getServerSha } from "./primitives.js";
|
|
8
|
-
import {
|
|
9
|
-
TOOLS,
|
|
10
|
-
toolMemAdd,
|
|
11
|
-
toolMemClose,
|
|
12
|
-
toolMemFind,
|
|
13
|
-
toolVerdictSubmit,
|
|
14
|
-
} from "./tools/index.js";
|
|
5
|
+
import { TOOLS, toolMemAdd, toolMemClose, toolMemFind } from "./tools/index.js";
|
|
15
6
|
import { errorResult, type ToolResult } from "./types.js";
|
|
16
7
|
|
|
17
8
|
// --- Server instructions ---
|
|
@@ -26,35 +17,14 @@ import { errorResult, type ToolResult } from "./types.js";
|
|
|
26
17
|
// Both habits degrade silently — an agent that ignores them still gets
|
|
27
18
|
// correct answers from every tool, just a thinner history.
|
|
28
19
|
|
|
29
|
-
const SERVER_INSTRUCTIONS = `fapony
|
|
20
|
+
const SERVER_INSTRUCTIONS = `fapony records decisions, bugs, and notes about this project so the next session (or the next agent) knows what happened and what to watch out for.
|
|
30
21
|
|
|
31
|
-
When a unit of work
|
|
22
|
+
When you finish a unit of work, record a mem row: fapony mem add <decision|bug|note> "what happened" --files <files> <path/to/PLAN.md>. files[] is required — a row without it is unfindable when you touch that file next session.
|
|
32
23
|
|
|
33
|
-
worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the
|
|
24
|
+
worktree must be the absolute path (git rev-parse --show-toplevel): every query scopes by it, so a bare name or none files the row where nothing reads it, and nothing errors to say so. Write the note standalone — it is read months later with no access to this conversation.
|
|
34
25
|
|
|
35
26
|
Skip it and every tool still answers correctly, on a thinner history.`;
|
|
36
27
|
|
|
37
|
-
// --- Statusline cache ---
|
|
38
|
-
//
|
|
39
|
-
// Written after every MCP tool call. The Claude Code statusline script reads
|
|
40
|
-
// this file (< 1ms, no spawn, no db). Format: single line of text.
|
|
41
|
-
// No remaining tool produces bytes_by_tool detail, so every call writes the
|
|
42
|
-
// minimal "fapony" marker — cross-client spend lives on the CLI now
|
|
43
|
-
// (`fapony usage-scan` + `fapony usage-web`), which the statusline cannot
|
|
44
|
-
// spawn (it must stay < 1ms).
|
|
45
|
-
|
|
46
|
-
const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
|
|
47
|
-
|
|
48
|
-
function writeStatuslineCache(): void {
|
|
49
|
-
try {
|
|
50
|
-
const dir = join(homedir(), ".config", "fapony");
|
|
51
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
52
|
-
writeFileSync(STATUSLINE_PATH, "fapony", "utf-8");
|
|
53
|
-
} catch {
|
|
54
|
-
// Cache write is best-effort — never block MCP on it.
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
28
|
// --- MCP protocol constants ---
|
|
59
29
|
|
|
60
30
|
const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
@@ -98,9 +68,6 @@ function dispatchToolCall(params: {
|
|
|
98
68
|
const args = params.arguments ?? {};
|
|
99
69
|
let result: ToolResult;
|
|
100
70
|
switch (params.name) {
|
|
101
|
-
case "verdict_submit":
|
|
102
|
-
result = toolVerdictSubmit(args);
|
|
103
|
-
break;
|
|
104
71
|
case "mem_find":
|
|
105
72
|
result = toolMemFind(args);
|
|
106
73
|
break;
|
|
@@ -113,8 +80,6 @@ function dispatchToolCall(params: {
|
|
|
113
80
|
default:
|
|
114
81
|
return errorResult(`unknown tool: ${params.name}`);
|
|
115
82
|
}
|
|
116
|
-
// Write statusline cache after every tool call — best-effort, never blocks.
|
|
117
|
-
writeStatuslineCache();
|
|
118
83
|
return result;
|
|
119
84
|
}
|
|
120
85
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/mcp/types.ts — ToolResult, helpers
|
|
2
|
+
// ReasonCode + RegimeCode enums now live in core/enums.ts (PLAN-lib-layer chunk 2c).
|
|
3
|
+
|
|
4
|
+
// Re-export enums for existing callers.
|
|
5
|
+
export {
|
|
6
|
+
REASON_CODES,
|
|
7
|
+
REGIME_CODES,
|
|
8
|
+
type ReasonCode,
|
|
9
|
+
type RegimeCode,
|
|
10
|
+
} from "../../core/enums.js";
|
|
11
|
+
|
|
12
|
+
// Re-export ToolResult types for existing callers.
|
|
13
|
+
export {
|
|
14
|
+
errorResult,
|
|
15
|
+
jsonResult,
|
|
16
|
+
parseToolResult,
|
|
17
|
+
type ToolResult,
|
|
18
|
+
} from "../../core/types.js";
|