fapony 0.2.1 → 0.3.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/README.md +9 -8
- package/fapony.ts +10 -3
- package/package.json +1 -1
- package/skill/define-convention/SKILL.md +77 -0
- package/skill/lookup-before-edit/SKILL.md +48 -0
- package/skill/review-pony/SKILL.md +11 -3
- package/src/debt/cli.ts +193 -0
- package/src/debt/format.ts +107 -0
- package/src/debt/index.ts +19 -0
- package/src/debt/load.ts +92 -0
- package/src/debt/promotion.ts +152 -0
- package/src/debt/scan.ts +214 -0
- package/src/debt/types.ts +79 -0
- package/src/detect.ts +92 -0
- package/src/hook.ts +168 -6
- package/src/install/claude.ts +14 -0
- package/src/install/codex.ts +58 -19
- package/src/install/opencode.ts +98 -0
- package/src/lint-baseline.ts +2 -2
- package/src/mcp/tools/index.ts +13 -28
- package/src/mcp/tools/mem.ts +71 -0
- package/src/mcp/transport.ts +12 -63
- package/src/mem/commands/read.ts +18 -1
- package/src/memory.ts +17 -8
- package/src/session/helpers.ts +1 -1
- package/src/session/registry.ts +3 -6
- package/src/setup.ts +1 -1
- package/src/debt.ts +0 -811
- package/src/mcp/tools/usage.ts +0 -211
package/src/mcp/tools/mem.ts
CHANGED
|
@@ -235,3 +235,74 @@ export function toolMemAdd(args: Record<string, unknown>): ToolResult {
|
|
|
235
235
|
return errorResult(e instanceof Error ? e.message : String(e));
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
|
+
|
|
239
|
+
// --- mem_close ---
|
|
240
|
+
//
|
|
241
|
+
// A separate tool on purpose, not kind:"close" inside mem_add: a close row
|
|
242
|
+
// carries {ref, text} with no files[] and no spec, while mem_add requires a
|
|
243
|
+
// non-empty files[] (and a spec for hold) — folding them into one schema
|
|
244
|
+
// would make required fields depend on the value of another field, the shape
|
|
245
|
+
// models call wrong most often. Mirrors CLI `mem close <id> "<msg>"`
|
|
246
|
+
// (commands/write.ts cmdClose): the id must exist; the tombstone voids the
|
|
247
|
+
// claim by itself.
|
|
248
|
+
|
|
249
|
+
export interface MemCloseResult {
|
|
250
|
+
ref: string;
|
|
251
|
+
text: string;
|
|
252
|
+
ts: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function memClose(args: {
|
|
256
|
+
worktree: string;
|
|
257
|
+
id: string;
|
|
258
|
+
text: string;
|
|
259
|
+
}): MemCloseResult {
|
|
260
|
+
if (!args.id.trim()) {
|
|
261
|
+
throw new Error("id is required");
|
|
262
|
+
}
|
|
263
|
+
if (!args.text.trim()) {
|
|
264
|
+
throw new Error("text is required and must not be empty");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
initStore(args.worktree);
|
|
268
|
+
const all = rows();
|
|
269
|
+
if (!all.some((r) => "id" in r && r.id === args.id)) {
|
|
270
|
+
throw new Error(`no id "${args.id}" in the log`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const ts = new Date().toISOString();
|
|
274
|
+
put({ kind: "close", ref: args.id, text: args.text });
|
|
275
|
+
|
|
276
|
+
return { ref: args.id, text: args.text, ts };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function toolMemClose(args: Record<string, unknown>): ToolResult {
|
|
280
|
+
const worktree =
|
|
281
|
+
typeof args.worktree === "string" ? args.worktree.trim() : "";
|
|
282
|
+
if (!worktree) {
|
|
283
|
+
return errorResult(
|
|
284
|
+
"worktree is required and must be an absolute path " +
|
|
285
|
+
"(git rev-parse --show-toplevel)",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (!worktree.startsWith("/")) {
|
|
289
|
+
return errorResult(`worktree must be an absolute path, got: ${worktree}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
293
|
+
if (!id) {
|
|
294
|
+
return errorResult("id is required — the row id to close");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const text = typeof args.text === "string" ? args.text.trim() : "";
|
|
298
|
+
if (!text) {
|
|
299
|
+
return errorResult("text is required and must not be empty");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const result = memClose({ worktree, id, text });
|
|
304
|
+
return jsonResult(result);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
return errorResult(e instanceof Error ? e.message : String(e));
|
|
307
|
+
}
|
|
308
|
+
}
|
package/src/mcp/transport.ts
CHANGED
|
@@ -4,13 +4,12 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
|
-
import { mergeBytesByTool, type UsageDetail } from "../session/index.js";
|
|
8
7
|
import { getServerSha } from "./primitives.js";
|
|
9
8
|
import {
|
|
10
9
|
TOOLS,
|
|
11
10
|
toolMemAdd,
|
|
11
|
+
toolMemClose,
|
|
12
12
|
toolMemFind,
|
|
13
|
-
toolPassiveUsage,
|
|
14
13
|
toolVerdictSubmit,
|
|
15
14
|
} from "./tools/index.js";
|
|
16
15
|
import { errorResult, type ToolResult } from "./types.js";
|
|
@@ -39,68 +38,18 @@ Skip it and every tool still answers correctly, on a thinner history.`;
|
|
|
39
38
|
//
|
|
40
39
|
// Written after every MCP tool call. The Claude Code statusline script reads
|
|
41
40
|
// this file (< 1ms, no spawn, no db). Format: single line of text.
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
45
|
|
|
46
46
|
const STATUSLINE_PATH = join(homedir(), ".config", "fapony", "statusline");
|
|
47
47
|
|
|
48
|
-
function writeStatuslineCache(
|
|
48
|
+
function writeStatuslineCache(): void {
|
|
49
49
|
try {
|
|
50
|
-
// Extract bytes_by_tool from fapony_usage detail JSON response.
|
|
51
|
-
let line = "fapony";
|
|
52
|
-
if (
|
|
53
|
-
toolResult &&
|
|
54
|
-
typeof toolResult === "object" &&
|
|
55
|
-
"content" in toolResult &&
|
|
56
|
-
Array.isArray(toolResult.content)
|
|
57
|
-
) {
|
|
58
|
-
for (const c of toolResult.content) {
|
|
59
|
-
if (
|
|
60
|
-
c &&
|
|
61
|
-
typeof c === "object" &&
|
|
62
|
-
c.type === "text" &&
|
|
63
|
-
typeof c.text === "string"
|
|
64
|
-
) {
|
|
65
|
-
// Try to extract bytes_by_tool from JSON text response.
|
|
66
|
-
// Aggregated across all clients: the bytes live on the Claude Code
|
|
67
|
-
// sub-object (claude_code.detail), never on the top-level detail,
|
|
68
|
-
// so reading top-level alone would always miss.
|
|
69
|
-
try {
|
|
70
|
-
const parsed = JSON.parse(c.text) as {
|
|
71
|
-
detail?: UsageDetail | null;
|
|
72
|
-
zcode?: { detail?: UsageDetail | null } | null;
|
|
73
|
-
claude_code?: { detail?: UsageDetail | null } | null;
|
|
74
|
-
codex?: { detail?: UsageDetail | null } | null;
|
|
75
|
-
};
|
|
76
|
-
const bbt = mergeBytesByTool(
|
|
77
|
-
parsed?.detail,
|
|
78
|
-
parsed?.zcode?.detail,
|
|
79
|
-
parsed?.claude_code?.detail,
|
|
80
|
-
parsed?.codex?.detail,
|
|
81
|
-
);
|
|
82
|
-
const entries = Object.entries(bbt).sort((a, b) => b[1] - a[1]);
|
|
83
|
-
const total = entries.reduce((s, e) => s + e[1], 0);
|
|
84
|
-
if (total > 0) {
|
|
85
|
-
// Format: "84.2k" for total, or "Read 42k · Grep 31k" for top tools.
|
|
86
|
-
const fmt = (n: number) =>
|
|
87
|
-
n >= 1024 ? `${(n / 1024).toFixed(1)}k` : `${Math.round(n)}`;
|
|
88
|
-
if (entries.length <= 3) {
|
|
89
|
-
line = `fapony ${entries.map((e) => `${e[0]} ${fmt(e[1])}`).join(" · ")}`;
|
|
90
|
-
} else {
|
|
91
|
-
line = `fapony ${fmt(total)}`;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
} catch {
|
|
95
|
-
// Not JSON — that's fine, use default "fapony" marker.
|
|
96
|
-
}
|
|
97
|
-
break;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
50
|
const dir = join(homedir(), ".config", "fapony");
|
|
102
51
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
103
|
-
writeFileSync(STATUSLINE_PATH,
|
|
52
|
+
writeFileSync(STATUSLINE_PATH, "fapony", "utf-8");
|
|
104
53
|
} catch {
|
|
105
54
|
// Cache write is best-effort — never block MCP on it.
|
|
106
55
|
}
|
|
@@ -110,7 +59,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
|
|
|
110
59
|
|
|
111
60
|
const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
112
61
|
const SERVER_NAME = "fapony-handcheck";
|
|
113
|
-
const SERVER_VERSION = "0.
|
|
62
|
+
const SERVER_VERSION = "0.3.0";
|
|
114
63
|
|
|
115
64
|
// --- JSON-RPC dispatch ---
|
|
116
65
|
|
|
@@ -152,20 +101,20 @@ function dispatchToolCall(params: {
|
|
|
152
101
|
case "verdict_submit":
|
|
153
102
|
result = toolVerdictSubmit(args);
|
|
154
103
|
break;
|
|
155
|
-
case "fapony_usage":
|
|
156
|
-
result = toolPassiveUsage(args);
|
|
157
|
-
break;
|
|
158
104
|
case "mem_find":
|
|
159
105
|
result = toolMemFind(args);
|
|
160
106
|
break;
|
|
161
107
|
case "mem_add":
|
|
162
108
|
result = toolMemAdd(args);
|
|
163
109
|
break;
|
|
110
|
+
case "mem_close":
|
|
111
|
+
result = toolMemClose(args);
|
|
112
|
+
break;
|
|
164
113
|
default:
|
|
165
114
|
return errorResult(`unknown tool: ${params.name}`);
|
|
166
115
|
}
|
|
167
116
|
// Write statusline cache after every tool call — best-effort, never blocks.
|
|
168
|
-
writeStatuslineCache(
|
|
117
|
+
writeStatuslineCache();
|
|
169
118
|
return result;
|
|
170
119
|
}
|
|
171
120
|
|
package/src/mem/commands/read.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
|
+
import { baselinePath, readEvidenceLintCmd } from "../../lint-baseline.js";
|
|
5
6
|
import { doneLines, fmtClose, fmtRow, printOpenRows } from "../render.js";
|
|
6
7
|
import { claimsOf, openRows, staleReport } from "../selectors.js";
|
|
7
8
|
import type { CloseRow, WorkRow } from "../store.js";
|
|
8
|
-
import { allRows, app, memCmd, planDir, rows } from "../store.js";
|
|
9
|
+
import { allRows, app, memCmd, planDir, root, rows } from "../store.js";
|
|
9
10
|
import { planSweepCmd, shippedNotMoved } from "./plan.js";
|
|
10
11
|
import { THRESHOLD } from "./rotate.js";
|
|
11
12
|
|
|
@@ -406,6 +407,22 @@ export const cmdKickoff = (a: string[]) => {
|
|
|
406
407
|
});
|
|
407
408
|
}
|
|
408
409
|
|
|
410
|
+
// group 4 — the lint baseline. "Is this red mine or was it already red" is
|
|
411
|
+
// already answered by `lint-baseline`, but only if it was captured before
|
|
412
|
+
// the work started — and nobody remembers at session open, which is what
|
|
413
|
+
// kickoff is. Only offered when the repo declared a lint command; without
|
|
414
|
+
// one the capture would just fail.
|
|
415
|
+
try {
|
|
416
|
+
if (readEvidenceLintCmd(root) && !existsSync(baselinePath(root))) {
|
|
417
|
+
suggestions.push({
|
|
418
|
+
text: "lint baseline ยังไม่ capture — แดงที่มีอยู่ก่อนจะถูกนับเป็นของคุณ",
|
|
419
|
+
run: "fapony lint-baseline --capture",
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
// state dir unreadable — the baseline line is a convenience, never a gate
|
|
424
|
+
}
|
|
425
|
+
|
|
409
426
|
// Print and optionally execute. Only a suggestion with a complete command is
|
|
410
427
|
// runnable — a plan item is context, a bug's close is a template needing a message.
|
|
411
428
|
if (suggestions.length) {
|
package/src/memory.ts
CHANGED
|
@@ -218,7 +218,13 @@ function walkUpForMemDir(fromDir: string, acceptEmpty: boolean): string | null {
|
|
|
218
218
|
export interface MemDirResult {
|
|
219
219
|
dir: string | null;
|
|
220
220
|
step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
|
|
221
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* `.fapony/.memory/` dirs under the repo root that hold logs but are not in
|
|
223
|
+
* scope from `fromDir`. Set for "ambiguous" (two or more, the refusal) and
|
|
224
|
+
* also for "repo-root"/"none" (one) — a caller that reports "nothing
|
|
225
|
+
* recorded here" is lying when a single app-scoped log exists one level
|
|
226
|
+
* down, and the honest line needs the path to say where.
|
|
227
|
+
*/
|
|
222
228
|
candidates?: string[];
|
|
223
229
|
}
|
|
224
230
|
|
|
@@ -320,20 +326,23 @@ function resolveMemDirFrom(
|
|
|
320
326
|
// Guard (SPEC §1 fail example): nothing at/above cwd holds a log, but the repo
|
|
321
327
|
// has two or more app-scoped ones — refuse instead of silently creating a
|
|
322
328
|
// third log at the root that no app-scoped reader will ever see.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
329
|
+
// One candidate is not ambiguous, so resolution is unchanged — but it still
|
|
330
|
+
// travels back with the result: out-of-scope is not the same as absent.
|
|
331
|
+
const candidates = root ? findMemDirsUnder(root) : [];
|
|
332
|
+
if (candidates.length >= 2) {
|
|
333
|
+
return { dir: null, step: "ambiguous", candidates };
|
|
328
334
|
}
|
|
335
|
+
const outOfScope = candidates.length ? { candidates } : {};
|
|
329
336
|
|
|
330
337
|
// Step 4: <repo root>/.fapony/.memory/ — where a new log is created
|
|
331
338
|
if (root) {
|
|
332
339
|
const rootDir = join(root, DEFAULT_MEM_DIR);
|
|
333
|
-
if (existsSync(rootDir))
|
|
340
|
+
if (existsSync(rootDir)) {
|
|
341
|
+
return { dir: rootDir, step: "repo-root", ...outOfScope };
|
|
342
|
+
}
|
|
334
343
|
}
|
|
335
344
|
|
|
336
|
-
return { dir: null, step: "none" };
|
|
345
|
+
return { dir: null, step: "none", ...outOfScope };
|
|
337
346
|
}
|
|
338
347
|
|
|
339
348
|
export function resolveMemDir(
|
package/src/session/helpers.ts
CHANGED
|
@@ -270,7 +270,7 @@ export function readDetailFromDb(
|
|
|
270
270
|
.all(...filter.params) as DetailPerSessionToolRow[];
|
|
271
271
|
|
|
272
272
|
// Context bytes per tool — summed `state.output` length. Same signal the
|
|
273
|
-
// Claude Code/Codex readers derive from tool_result blocks, so
|
|
273
|
+
// Claude Code/Codex readers derive from tool_result blocks, so usage-scan
|
|
274
274
|
// stops reporting zero bytes for the SQLite clients.
|
|
275
275
|
const bytesRows = db
|
|
276
276
|
.prepare(
|
package/src/session/registry.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Adding a client: write its reader (src/session/<name>.ts, same
|
|
4
4
|
// PassiveUsageResult shape as an existing one — SQLite or JSONL, whatever the
|
|
5
|
-
// client actually stores), then add one entry below. usage-scan
|
|
6
|
-
//
|
|
5
|
+
// client actually stores), then add one entry below. usage-scan iterates
|
|
6
|
+
// this list; nothing else needs any other change.
|
|
7
7
|
//
|
|
8
8
|
// This does not remove the real work of a new client (reverse-engineering
|
|
9
9
|
// its storage format) — it only removes the "wire it into 2 call sites"
|
|
@@ -16,12 +16,10 @@ import type { PassiveUsageReader } from "./types.js";
|
|
|
16
16
|
import { readZcodeUsage } from "./zcode.js";
|
|
17
17
|
|
|
18
18
|
export interface ClientAdapter {
|
|
19
|
-
/** cache `client` field and
|
|
19
|
+
/** usage-cache.jsonl `client` field and usage-scan label default. */
|
|
20
20
|
key: string;
|
|
21
21
|
/** usage-scan progress label — defaults to `key`. */
|
|
22
22
|
scanLabel?: string;
|
|
23
|
-
/** fapony_usage text-report section label — defaults to `key`. */
|
|
24
|
-
reportLabel?: string;
|
|
25
23
|
/** The one client whose totals lead the report as the unlabeled top-level summary. */
|
|
26
24
|
primary?: boolean;
|
|
27
25
|
read: PassiveUsageReader;
|
|
@@ -38,7 +36,6 @@ export const CLIENTS: ClientAdapter[] = [
|
|
|
38
36
|
{
|
|
39
37
|
key: "claude_code",
|
|
40
38
|
scanLabel: "claude-code",
|
|
41
|
-
reportLabel: "claude code",
|
|
42
39
|
read: readClaudeCodeUsage,
|
|
43
40
|
},
|
|
44
41
|
{ key: "codex", read: readCodexUsage },
|
package/src/setup.ts
CHANGED
|
@@ -229,7 +229,7 @@ export async function cmdSetup(deps: SetupDeps = {}): Promise<void> {
|
|
|
229
229
|
│ fapony install --platform claude │
|
|
230
230
|
│ │
|
|
231
231
|
│ 2. Ask your agent: │
|
|
232
|
-
│ "
|
|
232
|
+
│ "Find past decisions with mem_find" │
|
|
233
233
|
│ │
|
|
234
234
|
│ 3. Read a run's report: │
|
|
235
235
|
│ fapony report <run-id> │
|