fapony 0.1.1 → 0.1.3
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 +22 -36
- 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/package.json +10 -8
- package/skill/plan-with-pony/SKILL.md +1 -1
- package/src/analyze.ts +9 -8
- package/src/conventions-seed.ts +10 -10
- package/src/db/index.ts +1 -1
- package/src/db/store.ts +4 -0
- package/src/debt.ts +26 -22
- package/src/digest/collect.ts +7 -7
- package/src/digest/html.ts +2 -2
- package/src/digest/text.ts +1 -1
- package/src/gate.ts +3 -3
- package/src/hook.ts +119 -21
- package/src/init-mem.ts +3 -3
- package/src/install/opencode.ts +95 -8
- package/src/install/types.ts +1 -1
- package/src/install.ts +5 -1
- package/src/lint-baseline.ts +9 -9
- package/src/mcp/tools/mem.ts +1 -1
- package/src/mcp/tools/stats.ts +2 -2
- package/src/mcp/tools/usage.ts +3 -3
- package/src/mcp/tools/verdict.ts +29 -16
- package/src/mcp/transport.ts +1 -1
- package/src/plan-seed.ts +23 -21
- package/src/price/fetch.ts +19 -19
- package/src/price/resolve.ts +24 -24
- package/src/review-seed.ts +1 -1
- package/src/stats/data.ts +8 -7
- package/src/stats/format.ts +6 -5
- package/src/usage/render.ts +6 -5
- package/templates/PLAN.md +1 -1
- package/templates/mem/commands/plan.ts +44 -44
- package/templates/mem/commands/read.ts +5 -5
- package/templates/mem/commands/rotate.ts +6 -6
- package/templates/mem/commands/selftest.ts +14 -14
- package/templates/mem/commands/write.ts +13 -13
- package/templates/mem/mem.ts +5 -5
- package/templates/mem/selectors.ts +18 -18
- package/templates/mem/store.ts +50 -50
package/src/hook.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
// src/hook.ts — Claude Code / Cursor Stop hook: refuse to end a turn that produced
|
|
2
2
|
// commits but no verdict.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Why a hook and not a message: SERVER_INSTRUCTIONS is a *request* that the
|
|
5
|
+
// agent remember, and it measured as not enough · the hook does not grade in
|
|
6
|
+
// the agent's place (it cannot — it does not see whether the work passed or
|
|
7
|
+
// broke) it merely won't let the turn end until the agent grades itself,
|
|
8
|
+
// separating "who judges" from "who forces judgment"
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
+
// The signal is a commit, not a dirty tree — dirty = still working, commit =
|
|
11
|
+
// the unit of work is done, matching the definition "1 run = 1 measurable
|
|
12
|
+
// unit of work" (rule 7)
|
|
10
13
|
//
|
|
11
|
-
//
|
|
14
|
+
// Two payloads, one decision — field-mapping only:
|
|
12
15
|
// claude {cwd, transcript_path, stop_hook_active} → {"decision":"block"}
|
|
13
16
|
// cursor {workspace_roots, conversation_id, loop_count, status} → {"followup_message"}
|
|
14
|
-
// (cursor: loop_count ≥ 1 = hook
|
|
17
|
+
// (cursor: loop_count ≥ 1 = the hook already fired, status ≠ completed = allow)
|
|
15
18
|
|
|
16
19
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
17
20
|
import { homedir } from "node:os";
|
|
@@ -54,7 +57,7 @@ export function utcStamp(d: Date): string {
|
|
|
54
57
|
*
|
|
55
58
|
* PLAN-mem-mcp chunk 3: the block message now carries the commit list and the
|
|
56
59
|
* mem-log status (last row date). Both are *information*, never conditions —
|
|
57
|
-
* the block condition stays verdict-only (
|
|
60
|
+
* the block condition stays verdict-only (rule 7: the hook does not judge, it
|
|
58
61
|
* reports what is pending so the agent decides what deserves recording).
|
|
59
62
|
*/
|
|
60
63
|
export function decideStop(opts: {
|
|
@@ -224,7 +227,7 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
224
227
|
.get(worktree, since) as { n: number } | null;
|
|
225
228
|
verdicts = row?.n ?? 0;
|
|
226
229
|
// Informational only — read-only, degrade silently (mem status never
|
|
227
|
-
// becomes a block condition,
|
|
230
|
+
// becomes a block condition, rule 7).
|
|
228
231
|
try {
|
|
229
232
|
const mem = readMemLog(worktree);
|
|
230
233
|
memLastTs = mem.rows[0]?.ts ?? null;
|
|
@@ -251,15 +254,17 @@ export async function cmdHookStop(): Promise<void> {
|
|
|
251
254
|
|
|
252
255
|
// --- Read hint (PreToolUse annotate — never block, never dedupe) ---
|
|
253
256
|
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
257
|
+
// Reading a large file in full is where an agent spends tokens without
|
|
258
|
+
// noticing — warnings in a skill were never enough (same principle as the
|
|
259
|
+
// Stop hook: speak while it is spending). But this hook **annotates only**:
|
|
260
|
+
// no permissionDecision, no "already read" dedupe — context compaction makes
|
|
261
|
+
// "already read" false, and a hook that guesses wrong and traps the agent is
|
|
262
|
+
// worse than no hook (the rule from the original hook.ts) — annotate cannot
|
|
263
|
+
// trap by construction, the worst cost of a miss is one unnecessary line
|
|
260
264
|
//
|
|
261
|
-
//
|
|
262
|
-
// estimate
|
|
265
|
+
// The text is facts only (line count + command + a one-time measurement), not
|
|
266
|
+
// a per-file estimate — guessing tokens is dressing up as data, against
|
|
267
|
+
// "facts only"
|
|
263
268
|
|
|
264
269
|
/** Below this size a full read is already cheap — stay silent. */
|
|
265
270
|
export const READ_HINT_MIN_BYTES = 24_000;
|
|
@@ -314,6 +319,98 @@ export function readHintFor(opts: ReadHintInput): string | null {
|
|
|
314
319
|
}
|
|
315
320
|
}
|
|
316
321
|
|
|
322
|
+
// --- Commit hint (tool.execute.after — annotate only, never block) ---
|
|
323
|
+
//
|
|
324
|
+
// OpenCode has no Stop hook (Cursor does — see cursor.ts hook-stop wiring)
|
|
325
|
+
// so it cannot block a turn; instead it appends an annotate to the bash tool
|
|
326
|
+
// output whenever there is a git commit with no verdict pending. It is the
|
|
327
|
+
// same kind of nudge as the read hint: no block, no dedupe, every unknown →
|
|
328
|
+
// silent · called from the opencode plugin by direct import (like
|
|
329
|
+
// readHintFor), no CLI subcommand because no client needs it as a subprocess
|
|
330
|
+
// (Cursor uses its own hook-stop instead)
|
|
331
|
+
//
|
|
332
|
+
// The text is facts only (commit list + verdict status), not an estimate
|
|
333
|
+
|
|
334
|
+
/** Below this number of commits, the hint is unnecessary noise. */
|
|
335
|
+
export const COMMIT_HINT_MIN_COMMITS = 1;
|
|
336
|
+
/** Cap commits shown in the hint message. */
|
|
337
|
+
const COMMIT_HINT_MAX_LIST = 5;
|
|
338
|
+
|
|
339
|
+
export interface CommitHintInput {
|
|
340
|
+
command: unknown;
|
|
341
|
+
cwd: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Nudge for bash commands containing `git commit` that produced
|
|
346
|
+
* ungraded commits. Returns a one-to-two line hint string, or null
|
|
347
|
+
* when there is nothing to nudge about (already graded, no commits,
|
|
348
|
+
* not a git commit command, not a git repo, any failure).
|
|
349
|
+
*
|
|
350
|
+
* Every unknown resolves to null — a hint must never fire on a
|
|
351
|
+
* guess. The work is cheap: one git rev-parse + one git log + one
|
|
352
|
+
* SQLite count.
|
|
353
|
+
*/
|
|
354
|
+
export function commitHintFor(opts: CommitHintInput): string | null {
|
|
355
|
+
try {
|
|
356
|
+
if (typeof opts.command !== "string" || opts.command === "") return null;
|
|
357
|
+
// Only fire on git commit commands — not `git push`, `git pull`, etc.
|
|
358
|
+
if (!/\bgit\s+commit\b/.test(opts.command)) return null;
|
|
359
|
+
|
|
360
|
+
const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
|
|
361
|
+
if (!worktree) return null;
|
|
362
|
+
|
|
363
|
+
// Window = commits since the worktree's last verdict, not "does a
|
|
364
|
+
// verdict exist anywhere in its history" — a worktree that earned one
|
|
365
|
+
// verdict months ago must still nudge on every commit made since, the
|
|
366
|
+
// same way cmdHookStop windows on `e.ts >= since` (session start) rather
|
|
367
|
+
// than "any verdict this worktree has ever had".
|
|
368
|
+
const db = openDb();
|
|
369
|
+
const lastVerdict = db
|
|
370
|
+
.query(
|
|
371
|
+
`SELECT MAX(e.ts) AS ts FROM events e JOIN runs r ON r.id = e.run_id
|
|
372
|
+
WHERE e.kind = 'gate' AND r.worktree = ?`,
|
|
373
|
+
)
|
|
374
|
+
.get(worktree) as { ts: string | null } | null;
|
|
375
|
+
// git's --since is inclusive to the second, and the commit a verdict
|
|
376
|
+
// just graded often lands in the same UTC second as the verdict itself
|
|
377
|
+
// (verdict_submit runs right after the commit) — bump by 1s so that
|
|
378
|
+
// commit isn't re-flagged as ungraded because of its own grade.
|
|
379
|
+
const since = lastVerdict?.ts
|
|
380
|
+
? utcStamp(
|
|
381
|
+
new Date(
|
|
382
|
+
new Date(`${lastVerdict.ts.replace(" ", "T")}Z`).getTime() + 1000,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
: null;
|
|
386
|
+
|
|
387
|
+
const log = since
|
|
388
|
+
? git(["log", "--since", `${since} +0000`, "--format=%h %s"], worktree)
|
|
389
|
+
: git(["log", "--format=%h %s"], worktree);
|
|
390
|
+
const commitList = log ? log.split("\n").filter(Boolean) : [];
|
|
391
|
+
if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
|
|
392
|
+
|
|
393
|
+
const reason = decideStop({
|
|
394
|
+
stopHookActive: false, // annotate-only: never "already blocked"
|
|
395
|
+
worktree,
|
|
396
|
+
commits: commitList.length,
|
|
397
|
+
verdicts: 0, // every commit left in the window is, by construction, ungraded
|
|
398
|
+
commitList: commitList.slice(0, COMMIT_HINT_MAX_LIST),
|
|
399
|
+
});
|
|
400
|
+
if (!reason) return null;
|
|
401
|
+
|
|
402
|
+
// Prefix each line with "fapony:" so it's visually distinct
|
|
403
|
+
// from normal bash output in the agent's context.
|
|
404
|
+
const prefixed = reason
|
|
405
|
+
.split("\n")
|
|
406
|
+
.map((l) => `fapony: ${l}`)
|
|
407
|
+
.join("\n");
|
|
408
|
+
return prefixed;
|
|
409
|
+
} catch {
|
|
410
|
+
return null; // any failure = no hint
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
317
414
|
/** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out.
|
|
318
415
|
* No permissionDecision ever — the tool call always proceeds. */
|
|
319
416
|
export async function cmdHookReadHint(): Promise<void> {
|
|
@@ -355,10 +452,11 @@ export async function cmdHookReadHint(): Promise<void> {
|
|
|
355
452
|
|
|
356
453
|
// --- Debt + mem context (PLAN-convention-debt chunk 4) ---
|
|
357
454
|
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
455
|
+
// The one moment paying down debt is worth tokens is when the file is already
|
|
456
|
+
// open — so hook-read-hint appends two things after the size hint: conventions
|
|
457
|
+
// the file still violates (debt detector, computed live) and mem rows that
|
|
458
|
+
// mention the file (across sessions) · **annotate only**, as before — no
|
|
459
|
+
// block, no dedupe, every unknown → silent · combined cap 5 lines (debt 3 · mem 2)
|
|
362
460
|
|
|
363
461
|
const DEBT_HINT_MAX = 3;
|
|
364
462
|
const MEM_HINT_MAX = 2;
|
package/src/init-mem.ts
CHANGED
|
@@ -31,9 +31,9 @@ export function cmdInitMem(args: string[]): void {
|
|
|
31
31
|
const worktreeKey = args.find((x) => !x.startsWith("-"));
|
|
32
32
|
const config = loadConfig();
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// no key given = the repo you are standing in — so `fapony init-mem --update` runs in anyone's project
|
|
35
|
+
// without registering the worktree first (loadConfig already reads the cwd's fapony.config.json,
|
|
36
|
+
// so it picks up that project's paths.memoryEntry by itself)
|
|
37
37
|
const worktree = worktreeKey ? config.worktrees[worktreeKey] : process.cwd();
|
|
38
38
|
if (!worktree) {
|
|
39
39
|
console.error(`unknown worktree key: ${worktreeKey}`);
|
package/src/install/opencode.ts
CHANGED
|
@@ -112,6 +112,7 @@ export function cmdInstallOpencode(
|
|
|
112
112
|
const skillsDir = claudeSkillsDir(getHome);
|
|
113
113
|
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
114
114
|
installReadHintPlugin(dryRun, getHome);
|
|
115
|
+
installCommitHintPlugin(dryRun, getHome);
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -134,6 +135,7 @@ export function cmdInstallOpencode(
|
|
|
134
135
|
}
|
|
135
136
|
console.log(computeDiff(before, after));
|
|
136
137
|
installReadHintPlugin(dryRun, getHome);
|
|
138
|
+
installCommitHintPlugin(dryRun, getHome);
|
|
137
139
|
return;
|
|
138
140
|
}
|
|
139
141
|
|
|
@@ -154,6 +156,7 @@ export function cmdInstallOpencode(
|
|
|
154
156
|
const skillsDir = claudeSkillsDir(getHome);
|
|
155
157
|
reportSkills(linkSkills(skillsDir, dryRun), skillsDir, dryRun);
|
|
156
158
|
installReadHintPlugin(dryRun, getHome);
|
|
159
|
+
installCommitHintPlugin(dryRun, getHome);
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
/**
|
|
@@ -164,35 +167,86 @@ export function cmdInstallOpencode(
|
|
|
164
167
|
* throws, never dedupes ("you already read this" goes false after context
|
|
165
168
|
* compaction — a hook that guesses wrong must never trap the agent).
|
|
166
169
|
*
|
|
167
|
-
* Logic lives in src/hook.ts (readHintFor) — the plugin
|
|
168
|
-
* install root (path baked at install time), same
|
|
169
|
-
* as the skills symlinks: a git pull in
|
|
170
|
-
*
|
|
171
|
-
*
|
|
170
|
+
* Logic lives in src/hook.ts (readHintFor + readContextLines) — the plugin
|
|
171
|
+
* imports it from the install root (path baked at install time), same
|
|
172
|
+
* one-copy-per-client shape as the skills symlinks: a git pull in
|
|
173
|
+
* INSTALL_ROOT updates every client. Mirrors Claude's cmdHookReadHint
|
|
174
|
+
* (PreToolUse), which appends both the size hint and the debt/mem context —
|
|
175
|
+
* OpenCode was missing the second half until now (only the size hint was
|
|
176
|
+
* wired), so a convention debt on the file you just opened, or a mem-log
|
|
177
|
+
* row about it, never reached OpenCode even though the detector already
|
|
178
|
+
* runs on every read. Best-effort, same policy as the claude hooks: an
|
|
179
|
+
* existing file that isn't fapony's is never overwritten, and a failure
|
|
180
|
+
* never fails the install.
|
|
172
181
|
*/
|
|
173
182
|
export function readHintPluginSource(root: string): string {
|
|
174
183
|
const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
|
|
175
184
|
return `// fapony read hint — annotates full-file reads of large source files with a
|
|
176
|
-
// factual review-seed pointer
|
|
185
|
+
// factual review-seed pointer, plus any convention debt or mem-log row about
|
|
186
|
+
// the file. Annotate only: never blocks, never dedupes.
|
|
177
187
|
// Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
|
|
178
|
-
import { readHintFor } from ${hookModule};
|
|
188
|
+
import { readHintFor, readContextLines } from ${hookModule};
|
|
179
189
|
|
|
180
190
|
export const FaponyReadHint = async ({ directory }) => {
|
|
181
191
|
return {
|
|
182
192
|
"tool.execute.after": async (input, output) => {
|
|
183
193
|
try {
|
|
184
194
|
if (input.tool !== "read") return;
|
|
195
|
+
const parts = [];
|
|
185
196
|
const hint = readHintFor({
|
|
186
197
|
filePath: input.args?.filePath,
|
|
187
198
|
offset: input.args?.offset,
|
|
188
199
|
limit: input.args?.limit,
|
|
189
200
|
cwd: directory,
|
|
190
201
|
});
|
|
202
|
+
if (hint) parts.push(hint);
|
|
203
|
+
for (const line of readContextLines(input.args?.filePath, directory)) {
|
|
204
|
+
parts.push(line);
|
|
205
|
+
}
|
|
206
|
+
if (parts.length > 0 && typeof output.output === "string") {
|
|
207
|
+
output.output = output.output + "\\n" + parts.join("\\n");
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// a hint must never break a read
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* OpenCode plugin — the commit hint's in-process shape. `tool.execute.after`
|
|
220
|
+
* on the bash tool: when the command contains `git commit` and the worktree
|
|
221
|
+
* has ungraded commits, appends a nudge to the command output. Annotate only
|
|
222
|
+
* (no decision:block — OpenCode has no stop-hook equivalent).
|
|
223
|
+
*
|
|
224
|
+
* Logic lives in src/hook.ts (commitHintFor) — same one-copy-per-client shape
|
|
225
|
+
* as the read hint: a git pull in INSTALL_ROOT updates every client.
|
|
226
|
+
*/
|
|
227
|
+
export function commitHintPluginSource(root: string): string {
|
|
228
|
+
const hookModule = JSON.stringify(join(root, "src", "hook.ts"));
|
|
229
|
+
return `// fapony commit hint — annotates git commit bash commands with a
|
|
230
|
+
// verdict reminder when commits have no verdict filed yet.
|
|
231
|
+
// Annotate only: never blocks, never dedupes.
|
|
232
|
+
// Generated by \`fapony install\` — edit src/hook.ts in the fapony checkout.
|
|
233
|
+
import { commitHintFor } from ${hookModule};
|
|
234
|
+
|
|
235
|
+
export const FaponyCommitHint = async ({ directory }) => {
|
|
236
|
+
return {
|
|
237
|
+
"tool.execute.after": async (input, output) => {
|
|
238
|
+
try {
|
|
239
|
+
if (input.tool !== "bash") return;
|
|
240
|
+
const command = input.args?.command;
|
|
241
|
+
const hint = commitHintFor({
|
|
242
|
+
command,
|
|
243
|
+
cwd: directory,
|
|
244
|
+
});
|
|
191
245
|
if (hint && typeof output.output === "string") {
|
|
192
246
|
output.output = output.output + "\\n" + hint;
|
|
193
247
|
}
|
|
194
248
|
} catch {
|
|
195
|
-
// a hint must never break a
|
|
249
|
+
// a hint must never break a bash command
|
|
196
250
|
}
|
|
197
251
|
},
|
|
198
252
|
};
|
|
@@ -232,3 +286,36 @@ function installReadHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
|
232
286
|
` read hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
233
287
|
);
|
|
234
288
|
}
|
|
289
|
+
|
|
290
|
+
function installCommitHintPlugin(dryRun: boolean, getHome: () => string): void {
|
|
291
|
+
const pluginsDir = join(getHome(), ".config", "opencode", "plugins");
|
|
292
|
+
const pluginPath = join(pluginsDir, "fapony-commit-hint.ts");
|
|
293
|
+
if (existsSync(pluginPath)) {
|
|
294
|
+
let current = "";
|
|
295
|
+
try {
|
|
296
|
+
current = readFileSync(pluginPath, "utf-8");
|
|
297
|
+
} catch {
|
|
298
|
+
current = "";
|
|
299
|
+
}
|
|
300
|
+
if (current.includes("commitHintFor")) {
|
|
301
|
+
console.error(` commit hint: already installed — no change`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
console.error(
|
|
305
|
+
` commit hint: ${pluginPath} exists but isn't fapony's — not overwriting.`,
|
|
306
|
+
);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!dryRun) {
|
|
310
|
+
try {
|
|
311
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
312
|
+
writeFileSync(pluginPath, commitHintPluginSource(INSTALL_ROOT), "utf-8");
|
|
313
|
+
} catch (e) {
|
|
314
|
+
console.error(` commit hint: failed to write — ${(e as Error).message}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
console.error(
|
|
319
|
+
` commit hint: ${dryRun ? "would write" : "wrote"} ${pluginPath}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
package/src/install/types.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const MCP_KEY = "fapony";
|
|
|
11
11
|
|
|
12
12
|
export const MCP_CONFIG = {
|
|
13
13
|
type: "local",
|
|
14
|
-
command: ["bun", "run", "fapony.ts", "mcp"],
|
|
14
|
+
command: ["bun", "run", join(INSTALL_ROOT, "fapony.ts"), "mcp"],
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
/** ZCode uses the stdio MCP shape: command is a string, args is an array.
|
package/src/install.ts
CHANGED
|
@@ -31,7 +31,11 @@ export {
|
|
|
31
31
|
export { cmdInstallCodex } from "./install/codex.js";
|
|
32
32
|
export { cmdInstallCursor } from "./install/cursor.js";
|
|
33
33
|
export { detectClients } from "./install/detect.js";
|
|
34
|
-
export {
|
|
34
|
+
export {
|
|
35
|
+
cmdInstallOpencode,
|
|
36
|
+
commitHintPluginSource,
|
|
37
|
+
readHintPluginSource,
|
|
38
|
+
} from "./install/opencode.js";
|
|
35
39
|
export {
|
|
36
40
|
agentsSkillsDir,
|
|
37
41
|
claudeSkillsDir,
|
package/src/lint-baseline.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// src/lint-baseline.ts — `fapony lint-baseline`: separate "was red before" from "I made it red".
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// "138
|
|
6
|
-
// agent
|
|
3
|
+
// warnings and errors already red before the agent arrived force the agent to `fix all` first
|
|
4
|
+
// and then the real work is buried in one large diff (SPEC-convention-debt §4 — from the owner:
|
|
5
|
+
// "138 spots across ~40 files unrelated to the work") the repo's own red count is 212 —
|
|
6
|
+
// an agent that did nothing wrong must see **0**
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// Mechanism — does not know eslint: runs the command the repo declares (--cmd or evidence.json named "lint"),
|
|
9
|
+
// parses into a set of `path:rule-id` (**not line numbers** — lines shift every time a file is edited),
|
|
10
|
+
// stores the baseline at base_sha · results never enter state.db (2 tables, no additions) —
|
|
11
|
+
// it is a temporary file under the state dir and is discarded once --diff reports · report only
|
|
12
|
+
// never blocks — the agent decides whether to fix the old issues too
|
|
13
13
|
|
|
14
14
|
import { execSync } from "node:child_process";
|
|
15
15
|
import {
|
package/src/mcp/tools/mem.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/mcp/tools/mem.ts — mem_find: read-only search over the project's mem log
|
|
2
2
|
//
|
|
3
|
-
// "
|
|
3
|
+
// "writable ≠ readable back" — the write side works from the CLI with no MCP
|
|
4
4
|
// (vela: 2,920 rows / 49 days); what was missing is "which rows are about the
|
|
5
5
|
// files I am about to touch". Read-only over readMemLog (PLAN-mem-mcp chunk 2).
|
|
6
6
|
//
|
package/src/mcp/tools/stats.ts
CHANGED
|
@@ -86,11 +86,11 @@ export function toolFaponyStats(args: Record<string, unknown>): ToolResult {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// json:true → StatsData
|
|
89
|
+
// json:true → pure StatsData (SPEC-verdict-stats) — no other text may be inserted
|
|
90
90
|
if (args.json === true) {
|
|
91
91
|
return jsonResult(data);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// json:false → text
|
|
94
|
+
// json:false → the same text as `fapony stats` — same formatter, raw (not JSON-wrapped)
|
|
95
95
|
return { content: [{ type: "text", text: formatStatsText(data) }] };
|
|
96
96
|
}
|
package/src/mcp/tools/usage.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "../../session/index.js";
|
|
14
14
|
import { jsonResult, type ToolResult } from "../types.js";
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/** list-price summary per client — additive, leaves existing lines untouched */
|
|
17
17
|
function imputationOf(
|
|
18
18
|
result: PassiveUsageResult,
|
|
19
19
|
prices: PriceTable | null,
|
|
@@ -45,7 +45,7 @@ function imputedTextLines(
|
|
|
45
45
|
return [` ${label}list-price equivalent: ${parts.join(" · ")}`];
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
48
|
+
/** list price per model appended to the model name — only rows where the client records no cost */
|
|
49
49
|
function imputedSuffix(
|
|
50
50
|
provider: string,
|
|
51
51
|
model: string,
|
|
@@ -78,7 +78,7 @@ export function toolPassiveUsage(args: Record<string, unknown>): ToolResult {
|
|
|
78
78
|
result: client.read(worktree, since, until, detail),
|
|
79
79
|
}));
|
|
80
80
|
|
|
81
|
-
//
|
|
81
|
+
// list prices from the cache only — read once per call, not per section
|
|
82
82
|
const prices = loadPrices();
|
|
83
83
|
|
|
84
84
|
if (args.json === true) {
|
package/src/mcp/tools/verdict.ts
CHANGED
|
@@ -79,22 +79,35 @@ export function toolVerdictSubmit(args: Record<string, unknown>): ToolResult {
|
|
|
79
79
|
: "mcp-external";
|
|
80
80
|
const resolvedPlan =
|
|
81
81
|
typeof plan === "string" && plan ? plan.trim().toLowerCase() : null;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
// BEGIN IMMEDIATE makes the find-then-create below atomic across
|
|
83
|
+
// concurrent MCP processes: without it, two agents closing the same
|
|
84
|
+
// worktree+plan at once can both see "no open run" and each insert a
|
|
85
|
+
// row. The write lock taken here blocks (busy_timeout, not throws) the
|
|
86
|
+
// second caller until the first commits, so it re-reads and finds the
|
|
87
|
+
// row already there instead of duplicating it.
|
|
88
|
+
db.run("BEGIN IMMEDIATE");
|
|
89
|
+
try {
|
|
90
|
+
let open = resolvedPlan
|
|
91
|
+
? findOpenRun(db, resolvedWorktree, resolvedPlan)
|
|
92
|
+
: null;
|
|
93
|
+
// Fallback: if no exact match and plan is non-null, try any open run with
|
|
94
|
+
// plan=null. This lets a verdict with free-text intent bind to a run that
|
|
95
|
+
// was created without a plan (the common "no PLAN file" flow).
|
|
96
|
+
if (!open && resolvedPlan) {
|
|
97
|
+
open = findOpenRunWithNullPlan(db, resolvedWorktree);
|
|
98
|
+
}
|
|
99
|
+
if (open) {
|
|
100
|
+
resolvedRunId = open.id;
|
|
101
|
+
} else {
|
|
102
|
+
// worktree/plan let callers (e.g. move-to-done) attribute the verdict
|
|
103
|
+
// so byReasonCode/bestPassing aggregate correctly instead of collapsing
|
|
104
|
+
// into "mcp-external".
|
|
105
|
+
resolvedRunId = newRun(db, resolvedWorktree, resolvedPlan, null, "mcp");
|
|
106
|
+
}
|
|
107
|
+
db.run("COMMIT");
|
|
108
|
+
} catch (e) {
|
|
109
|
+
db.run("ROLLBACK");
|
|
110
|
+
throw e;
|
|
98
111
|
}
|
|
99
112
|
}
|
|
100
113
|
|
package/src/mcp/transport.ts
CHANGED
|
@@ -112,7 +112,7 @@ function writeStatuslineCache(toolResult: ToolResult): void {
|
|
|
112
112
|
|
|
113
113
|
const MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
114
114
|
const SERVER_NAME = "fapony-handcheck";
|
|
115
|
-
const SERVER_VERSION = "0.1.
|
|
115
|
+
const SERVER_VERSION = "0.1.3";
|
|
116
116
|
|
|
117
117
|
// --- JSON-RPC dispatch ---
|
|
118
118
|
|