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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/mcp/tools/collect.ts — handoff_collect tool
|
|
2
|
+
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { isTestFile } from "../../analyze.js";
|
|
5
|
+
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
6
|
+
|
|
7
|
+
// --- Git helper ---
|
|
8
|
+
|
|
9
|
+
function execGitSafe(
|
|
10
|
+
cmd: string,
|
|
11
|
+
cwd: string,
|
|
12
|
+
): { ok: boolean; output: string; error?: string } {
|
|
13
|
+
try {
|
|
14
|
+
const output = execSync(cmd, {
|
|
15
|
+
cwd,
|
|
16
|
+
encoding: "utf-8",
|
|
17
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
18
|
+
timeout: 15_000,
|
|
19
|
+
});
|
|
20
|
+
return { ok: true, output: output.trim() };
|
|
21
|
+
} catch (e: unknown) {
|
|
22
|
+
const msg =
|
|
23
|
+
e && typeof e === "object" && ("stderr" in e || "message" in e)
|
|
24
|
+
? String(
|
|
25
|
+
(e as { stderr?: string; message?: string }).stderr ??
|
|
26
|
+
(e as { message?: string }).message ??
|
|
27
|
+
"unknown",
|
|
28
|
+
)
|
|
29
|
+
: "unknown";
|
|
30
|
+
return { ok: false, output: "", error: msg.trim() };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ahead/behind vs the default remote branch, as of the last fetch — no network here.
|
|
35
|
+
// ponytail: origin/main|master only; add upstream/`origin/HEAD` lookup if a repo names it otherwise.
|
|
36
|
+
function aheadBehind(
|
|
37
|
+
worktree: string,
|
|
38
|
+
): { ahead: number; behind: number; ref: string } | null {
|
|
39
|
+
for (const ref of ["origin/main", "origin/master"]) {
|
|
40
|
+
const r = execGitSafe(
|
|
41
|
+
`git rev-list --left-right --count ${ref}...HEAD`,
|
|
42
|
+
worktree,
|
|
43
|
+
);
|
|
44
|
+
if (!r.ok) continue;
|
|
45
|
+
const [behind, ahead] = r.output.split(/\s+/).map((n) => parseInt(n, 10));
|
|
46
|
+
if (Number.isNaN(behind) || Number.isNaN(ahead)) continue;
|
|
47
|
+
return { ahead, behind, ref };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Tool implementation ---
|
|
53
|
+
|
|
54
|
+
export function toolHandoffCollect(args: Record<string, unknown>): ToolResult {
|
|
55
|
+
const { base_sha, head_sha, worktree } = args;
|
|
56
|
+
|
|
57
|
+
if (typeof worktree !== "string") {
|
|
58
|
+
return errorResult("worktree is required as a string");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Auto-detect commit range if not provided
|
|
62
|
+
let resolvedBaseSha = base_sha;
|
|
63
|
+
let resolvedHeadSha = head_sha;
|
|
64
|
+
|
|
65
|
+
if (typeof resolvedBaseSha !== "string") {
|
|
66
|
+
const detected = execGitSafe("git rev-parse HEAD~1", worktree);
|
|
67
|
+
if (!detected.ok) {
|
|
68
|
+
return errorResult(
|
|
69
|
+
`cannot auto-detect base_sha: ${detected.error}. Provide base_sha explicitly.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
resolvedBaseSha = detected.output;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof resolvedHeadSha !== "string") {
|
|
76
|
+
const detected = execGitSafe("git rev-parse HEAD", worktree);
|
|
77
|
+
if (!detected.ok) {
|
|
78
|
+
return errorResult(
|
|
79
|
+
`cannot auto-detect head_sha: ${detected.error}. Provide head_sha explicitly.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
resolvedHeadSha = detected.output;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Diff stat
|
|
86
|
+
let files_changed = 0;
|
|
87
|
+
let lines_changed = 0;
|
|
88
|
+
let insertions = 0;
|
|
89
|
+
let deletions = 0;
|
|
90
|
+
let gitError: string | undefined;
|
|
91
|
+
|
|
92
|
+
const diffResult = execGitSafe(
|
|
93
|
+
`git diff --stat ${resolvedBaseSha}..${resolvedHeadSha} -- .`,
|
|
94
|
+
worktree,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (diffResult.ok) {
|
|
98
|
+
const fileMatch = diffResult.output.match(/(\d+) files? changed/);
|
|
99
|
+
files_changed = fileMatch ? parseInt(fileMatch[1], 10) : 0;
|
|
100
|
+
const insMatch = diffResult.output.match(/(\d+) insertions?\(\+\)/);
|
|
101
|
+
const delMatch = diffResult.output.match(/(\d+) deletions?\(-\)/);
|
|
102
|
+
insertions = insMatch ? parseInt(insMatch[1], 10) : 0;
|
|
103
|
+
deletions = delMatch ? parseInt(delMatch[1], 10) : 0;
|
|
104
|
+
lines_changed = insertions + deletions;
|
|
105
|
+
} else {
|
|
106
|
+
gitError = `git diff failed: ${diffResult.error}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Commits
|
|
110
|
+
const logResult = execGitSafe(
|
|
111
|
+
`git log --oneline ${resolvedBaseSha}..${resolvedHeadSha}`,
|
|
112
|
+
worktree,
|
|
113
|
+
);
|
|
114
|
+
const commits = logResult.ok
|
|
115
|
+
? logResult.output
|
|
116
|
+
.split("\n")
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((l) => l.split(" ")[0])
|
|
119
|
+
: [];
|
|
120
|
+
|
|
121
|
+
// Branch
|
|
122
|
+
const branchResult = execGitSafe("git branch --show-current", worktree);
|
|
123
|
+
const branch = branchResult.ok ? branchResult.output : "";
|
|
124
|
+
|
|
125
|
+
// Check file types
|
|
126
|
+
const nameResult = execGitSafe(
|
|
127
|
+
`git diff --name-only ${resolvedBaseSha}..${resolvedHeadSha}`,
|
|
128
|
+
worktree,
|
|
129
|
+
);
|
|
130
|
+
const names = nameResult.ok
|
|
131
|
+
? nameResult.output.split("\n").filter(Boolean)
|
|
132
|
+
: [];
|
|
133
|
+
const has_test_changes = names.some((n) => isTestFile(n));
|
|
134
|
+
const has_docs_changes = names.some((n) => /\.md$/i.test(n));
|
|
135
|
+
|
|
136
|
+
return jsonResult({
|
|
137
|
+
facts: {
|
|
138
|
+
files_changed,
|
|
139
|
+
lines_changed,
|
|
140
|
+
insertions,
|
|
141
|
+
deletions,
|
|
142
|
+
commits,
|
|
143
|
+
branch,
|
|
144
|
+
files: names,
|
|
145
|
+
ahead_behind: aheadBehind(worktree),
|
|
146
|
+
git_error: gitError ?? null,
|
|
147
|
+
},
|
|
148
|
+
checks: {
|
|
149
|
+
has_test_changes,
|
|
150
|
+
has_docs_changes,
|
|
151
|
+
},
|
|
152
|
+
provenance: {
|
|
153
|
+
verified: true,
|
|
154
|
+
source: "git_cli",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/mcp/tools/context.ts — project_health_context tool
|
|
2
|
+
//
|
|
3
|
+
// Single call, any caller: returns the paste-ready "known patterns" block built
|
|
4
|
+
// from real run history, keyed by files[] (no raw dump, ~15 lines max). Not a
|
|
5
|
+
// pre-edit reflex — most files have no history (see CLAUDE.md rule 8); it earns
|
|
6
|
+
// its call on a file that does. `plan-with-pony` is one caller, not the only one.
|
|
7
|
+
//
|
|
8
|
+
// It also surfaces project decisions from the mem log — that read is the only
|
|
9
|
+
// I/O here; `buildProjectHealthContext` stays pure and just renders.
|
|
10
|
+
|
|
11
|
+
import { blastRadiusForWorktree } from "../../analyze.js";
|
|
12
|
+
import {
|
|
13
|
+
buildProjectHealthContext,
|
|
14
|
+
HUB_DEPENDENTS_MIN,
|
|
15
|
+
type HubEntry,
|
|
16
|
+
} from "../../context/index.js";
|
|
17
|
+
import { readRecentMemDecisions } from "../../memory.js";
|
|
18
|
+
import { getStatsData } from "../../stats/index.js";
|
|
19
|
+
import type { ToolResult } from "../types.js";
|
|
20
|
+
|
|
21
|
+
export function toolProjectHealthContext(
|
|
22
|
+
args: Record<string, unknown>,
|
|
23
|
+
): ToolResult {
|
|
24
|
+
const worktree =
|
|
25
|
+
typeof args.worktree === "string" && args.worktree
|
|
26
|
+
? args.worktree
|
|
27
|
+
: undefined;
|
|
28
|
+
const files =
|
|
29
|
+
Array.isArray(args.files) && args.files.length > 0
|
|
30
|
+
? args.files.filter(
|
|
31
|
+
(f): f is string => typeof f === "string" && f.length > 0,
|
|
32
|
+
)
|
|
33
|
+
: undefined;
|
|
34
|
+
|
|
35
|
+
// No worktree (or no log) → no decisions line, block still returns.
|
|
36
|
+
const memDecisions = worktree
|
|
37
|
+
? readRecentMemDecisions(worktree, 3, files).map((r) => ({
|
|
38
|
+
text: r.text,
|
|
39
|
+
spec: r.spec,
|
|
40
|
+
}))
|
|
41
|
+
: [];
|
|
42
|
+
|
|
43
|
+
// Hub detection needs both worktree (graph root) and files[] (what to map).
|
|
44
|
+
// Same live-graph cost handoff_check already pays; null/unreadable graph →
|
|
45
|
+
// no hub line, never a throw (PLAN-hub-signal §3).
|
|
46
|
+
const hubs: HubEntry[] =
|
|
47
|
+
worktree && files
|
|
48
|
+
? Object.entries(blastRadiusForWorktree(worktree, files) ?? {})
|
|
49
|
+
.filter(([, b]) => b.dependents >= HUB_DEPENDENTS_MIN)
|
|
50
|
+
.sort((a, b) => b[1].dependents - a[1].dependents)
|
|
51
|
+
.map(([file, b]) => ({
|
|
52
|
+
file,
|
|
53
|
+
dependents: b.dependents,
|
|
54
|
+
tested: b.tested,
|
|
55
|
+
transitive: b.transitive,
|
|
56
|
+
}))
|
|
57
|
+
: [];
|
|
58
|
+
|
|
59
|
+
const block = buildProjectHealthContext(getStatsData(), {
|
|
60
|
+
worktree,
|
|
61
|
+
files,
|
|
62
|
+
memDecisions,
|
|
63
|
+
hubs,
|
|
64
|
+
});
|
|
65
|
+
return { content: [{ type: "text", text: block }] };
|
|
66
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// src/mcp/tools/index.ts — barrel + TOOLS array
|
|
2
|
+
|
|
3
|
+
import { VERDICT_GRADES } from "../../parse.js";
|
|
4
|
+
import { REASON_CODES, REGIME_CODES } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export { toolProjectHealthContext } from "./context.js";
|
|
7
|
+
export { toolMemFind } from "./mem.js";
|
|
8
|
+
export { toolPlanList } from "./plans.js";
|
|
9
|
+
export { toolFaponyStats } from "./stats.js";
|
|
10
|
+
export { toolPassiveUsage } from "./usage.js";
|
|
11
|
+
export { toolVerdictSubmit } from "./verdict.js";
|
|
12
|
+
|
|
13
|
+
// --- Tool definitions ---
|
|
14
|
+
|
|
15
|
+
export const TOOLS = [
|
|
16
|
+
{
|
|
17
|
+
name: "verdict_submit",
|
|
18
|
+
description:
|
|
19
|
+
"Grade a finished unit of work. " +
|
|
20
|
+
"WHEN: every unit that ends, including work that went right the first " +
|
|
21
|
+
"time — this is a grade on the work, not a confession, and a model's " +
|
|
22
|
+
"record is only worth the number of graded units behind it. " +
|
|
23
|
+
"If a first attempt was wrong, submit 'fail' the moment you realize it, " +
|
|
24
|
+
"then a pass-family verdict once the fix is verified. " +
|
|
25
|
+
"Never leave a run open — one stuck at running/fixing " +
|
|
26
|
+
"absorbs later unrelated verdicts for that worktree. " +
|
|
27
|
+
"Without run_id, binds to the latest still-open run for the same " +
|
|
28
|
+
"worktree+plan (round keeps counting toward review.maxRounds); " +
|
|
29
|
+
"creates a new run entry only when none is open.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object" as const,
|
|
32
|
+
properties: {
|
|
33
|
+
run_id: {
|
|
34
|
+
type: "number",
|
|
35
|
+
description:
|
|
36
|
+
"Optional run ID from fapony. If omitted, a new run is created automatically.",
|
|
37
|
+
},
|
|
38
|
+
verdict: {
|
|
39
|
+
type: "string",
|
|
40
|
+
enum: [...VERDICT_GRADES],
|
|
41
|
+
description: "Verdict grade",
|
|
42
|
+
},
|
|
43
|
+
reason_code: {
|
|
44
|
+
type: "string",
|
|
45
|
+
enum: [...REASON_CODES],
|
|
46
|
+
description:
|
|
47
|
+
"Standardized failure reason code. Use 'none' for clean passes (not 'other').",
|
|
48
|
+
},
|
|
49
|
+
regime: {
|
|
50
|
+
type: "string",
|
|
51
|
+
enum: [...REGIME_CODES],
|
|
52
|
+
description:
|
|
53
|
+
"Task shape: code=new feature/refactor, fix=debugging an existing defect, " +
|
|
54
|
+
"review=reviewing someone else's work/diff, plan=producing a plan or spec, " +
|
|
55
|
+
"inquiry=asking questions without editing files, test=writing or editing tests as primary work",
|
|
56
|
+
},
|
|
57
|
+
note: {
|
|
58
|
+
type: "string",
|
|
59
|
+
description: "Optional note (required when reason_code = 'other')",
|
|
60
|
+
},
|
|
61
|
+
worktree: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description:
|
|
64
|
+
"Absolute path of the repo/worktree (git rev-parse --show-toplevel), used when run_id is omitted. " +
|
|
65
|
+
"Send it: every fapony query scopes by absolute path, so a bare repo name lands in a bucket no " +
|
|
66
|
+
'query reads, and omitting it files the verdict under "mcp-external" instead of the project. ' +
|
|
67
|
+
"Neither case errors.",
|
|
68
|
+
},
|
|
69
|
+
plan: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description:
|
|
72
|
+
"Optional plan file path for a new run (used only when run_id is omitted)",
|
|
73
|
+
},
|
|
74
|
+
session_id: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description:
|
|
77
|
+
"The client session id — Claude Code/Codex: the transcript .jsonl path; " +
|
|
78
|
+
"OpenCode/ZCode: the session id. It is what attributes this verdict to a model; " +
|
|
79
|
+
"without it fapony infers the model from whichever session is running, which is a " +
|
|
80
|
+
"guess. Send it whenever the client exposes it. Cannot find it → omit, never invent one.",
|
|
81
|
+
},
|
|
82
|
+
files: {
|
|
83
|
+
type: "array",
|
|
84
|
+
items: { type: "string" },
|
|
85
|
+
description:
|
|
86
|
+
"Repo-relative paths of the files this work unit touched. Technically " +
|
|
87
|
+
"optional, but always send them: this is the only input to per-file " +
|
|
88
|
+
"risk history — a verdict with no files[] teaches the next session " +
|
|
89
|
+
"nothing about where the risk was.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ["verdict", "reason_code", "regime"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "fapony_stats",
|
|
97
|
+
description:
|
|
98
|
+
"Query accumulated run statistics: pass/stall rates, quality scores, " +
|
|
99
|
+
"breakdown by model/grade/worktree. Returns StatsData shape. " +
|
|
100
|
+
"With mode='verdict', returns the model to pay for each regime — the " +
|
|
101
|
+
"Pareto frontier of quality vs tokens/pass, one line per regime (or the " +
|
|
102
|
+
"full frontier/dominated/candidates breakdown for one regime). This is " +
|
|
103
|
+
"the answer the ledger exists to give; call it before assuming which " +
|
|
104
|
+
"model fits a task. With group_by='reason_code'|'plan'|'file', returns " +
|
|
105
|
+
"top-N rows for that grouping (recurring failure signatures / per-plan " +
|
|
106
|
+
"totals / per-file gate-vs-fail counts) instead of the full shape.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object" as const,
|
|
109
|
+
properties: {
|
|
110
|
+
mode: {
|
|
111
|
+
type: "string",
|
|
112
|
+
enum: ["verdict"],
|
|
113
|
+
description:
|
|
114
|
+
"'verdict' ranks models by quality vs tokens/pass instead of listing " +
|
|
115
|
+
"raw counts — the leaderboard, not the ledger dump. Models below " +
|
|
116
|
+
"n=5 are shown separately and never picked as the answer; a model " +
|
|
117
|
+
"tried once at top quality does not get to define the frontier.",
|
|
118
|
+
},
|
|
119
|
+
regime: {
|
|
120
|
+
type: "string",
|
|
121
|
+
enum: ["code", "fix", "review", "plan", "inquiry", "test"],
|
|
122
|
+
description:
|
|
123
|
+
"With mode='verdict', narrows to one regime's full frontier " +
|
|
124
|
+
"(dominated models + candidates too). Omitted = one summary line " +
|
|
125
|
+
"per regime.",
|
|
126
|
+
},
|
|
127
|
+
json: {
|
|
128
|
+
type: "boolean",
|
|
129
|
+
description:
|
|
130
|
+
"If true, return raw JSON StatsData. If false (default), return human-readable text. Ignored when mode='verdict' (always text).",
|
|
131
|
+
},
|
|
132
|
+
group_by: {
|
|
133
|
+
type: "string",
|
|
134
|
+
enum: ["reason_code", "plan", "file"],
|
|
135
|
+
description:
|
|
136
|
+
"Optional grouping: top-N reason_code counts, per-plan totals, or " +
|
|
137
|
+
"per-file risk (graded touches vs fails) from real gate events.",
|
|
138
|
+
},
|
|
139
|
+
top: {
|
|
140
|
+
type: "number",
|
|
141
|
+
description: "Max rows returned with group_by (default 10).",
|
|
142
|
+
},
|
|
143
|
+
worktree: {
|
|
144
|
+
type: "string",
|
|
145
|
+
description:
|
|
146
|
+
"Scope a query (verdict mode, group_by, or the default view) to one worktree path (absolute). Defaults to the repo the server is running in.",
|
|
147
|
+
},
|
|
148
|
+
all: {
|
|
149
|
+
type: "boolean",
|
|
150
|
+
description:
|
|
151
|
+
"Ignore the default worktree scope and report every project at once. Off by default: averaging several projects reads as 'in this project' while being no such thing, and it is the largest payload this tool returns.",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
required: [],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "fapony_usage",
|
|
159
|
+
description:
|
|
160
|
+
"Query passive usage across every coding client on this machine — " +
|
|
161
|
+
"OpenCode, Claude Code, Codex, and ZCode — on one ruler: token counts, " +
|
|
162
|
+
"cost, and breakdown by model. No client's own session log can see " +
|
|
163
|
+
"another's, so this is the only way to compare them. Filter by " +
|
|
164
|
+
"worktree and time range.",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
type: "object" as const,
|
|
167
|
+
properties: {
|
|
168
|
+
worktree: {
|
|
169
|
+
type: "string",
|
|
170
|
+
description: "Filter by worktree path (absolute)",
|
|
171
|
+
},
|
|
172
|
+
since: {
|
|
173
|
+
type: "number",
|
|
174
|
+
description:
|
|
175
|
+
"Unix timestamp — include sessions created at or after this time",
|
|
176
|
+
},
|
|
177
|
+
until: {
|
|
178
|
+
type: "number",
|
|
179
|
+
description:
|
|
180
|
+
"Unix timestamp — include sessions created at or before this time",
|
|
181
|
+
},
|
|
182
|
+
detail: {
|
|
183
|
+
type: "boolean",
|
|
184
|
+
description:
|
|
185
|
+
"If true, include tool-call breakdown + step counts per session " +
|
|
186
|
+
"(activity signal, not quality). Default false keeps output compact.",
|
|
187
|
+
},
|
|
188
|
+
json: {
|
|
189
|
+
type: "boolean",
|
|
190
|
+
description:
|
|
191
|
+
"If true, return raw JSON PassiveUsageResult. If false (default), return human-readable text.",
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
required: [],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "project_health_context",
|
|
199
|
+
description:
|
|
200
|
+
"Known-patterns block for files[]: recurring fail reasons, escalated runs, " +
|
|
201
|
+
"and round-1-pass shapes from real run history. Worth a call when you are " +
|
|
202
|
+
"about to touch a file that has history — a long-lived file, one you have " +
|
|
203
|
+
"not seen before, or one a previous attempt already failed on. The unit is " +
|
|
204
|
+
"touched files, not a plan; a bug fix with no plan file still qualifies. " +
|
|
205
|
+
"Returns nothing when there is no history (most files); short plain-text " +
|
|
206
|
+
"block, framed as watch-fors, not constraints.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: "object" as const,
|
|
209
|
+
properties: {
|
|
210
|
+
worktree: {
|
|
211
|
+
type: "string",
|
|
212
|
+
description:
|
|
213
|
+
"Scope to one worktree path (absolute). Global across worktrees when omitted.",
|
|
214
|
+
},
|
|
215
|
+
files: {
|
|
216
|
+
type: "array",
|
|
217
|
+
items: { type: "string" },
|
|
218
|
+
description:
|
|
219
|
+
"Filter findings to only those related to these files. " +
|
|
220
|
+
"Matches against stored gate event files and note text.",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
required: [],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "mem_find",
|
|
228
|
+
description:
|
|
229
|
+
"Search the project's mem log (.fapony/.memory/log*.jsonl — decisions, " +
|
|
230
|
+
"bugs, notes, and bookkeeping kinds alike; NO default kind filter). " +
|
|
231
|
+
"Read-only. Answer 'what was ever decided about this file?' in one call " +
|
|
232
|
+
"BEFORE editing: pass files[] (repo-relative) to match rows mentioning " +
|
|
233
|
+
"them. mem never stored files[], so match is substring over text/spec/ref " +
|
|
234
|
+
"— a row that never names the file cannot be found (limit of the data, " +
|
|
235
|
+
"not the query). In a monorepo only the log of the app guessed from the " +
|
|
236
|
+
"worktree name is read; memDir in the result shows which one. Returns " +
|
|
237
|
+
"{rows, total, filesFound, skipped, memDir}: total is the match count " +
|
|
238
|
+
"before limit, memDir:null means no mem at all (not 'nothing matched').",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: "object" as const,
|
|
241
|
+
properties: {
|
|
242
|
+
worktree: {
|
|
243
|
+
type: "string",
|
|
244
|
+
description:
|
|
245
|
+
"Absolute path (git rev-parse --show-toplevel) — required; " +
|
|
246
|
+
"scope of the mem log to read",
|
|
247
|
+
},
|
|
248
|
+
files: {
|
|
249
|
+
type: "array",
|
|
250
|
+
items: { type: "string" },
|
|
251
|
+
description:
|
|
252
|
+
"Repo-relative paths — match rows whose text/spec/ref mentions them (substring)",
|
|
253
|
+
},
|
|
254
|
+
text: {
|
|
255
|
+
type: "string",
|
|
256
|
+
description: "Substring, case-insensitive",
|
|
257
|
+
},
|
|
258
|
+
kind: {
|
|
259
|
+
type: "array",
|
|
260
|
+
items: { type: "string" },
|
|
261
|
+
description:
|
|
262
|
+
"Filter by kind (decision/note/bug/close/…). Omit = every kind — " +
|
|
263
|
+
"no default filter",
|
|
264
|
+
},
|
|
265
|
+
since: {
|
|
266
|
+
type: "string",
|
|
267
|
+
description: "ISO date — only rows at or after this time",
|
|
268
|
+
},
|
|
269
|
+
limit: {
|
|
270
|
+
type: "number",
|
|
271
|
+
description:
|
|
272
|
+
"Max rows returned (default 20) — total still counts all matches",
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
required: ["worktree"],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "plan_list",
|
|
280
|
+
description:
|
|
281
|
+
"List pending plan files grouped by state — active / blocked / " +
|
|
282
|
+
"untouched / superseded / trackers — each with title and last-run " +
|
|
283
|
+
"status joined from fapony run history, plus a count of archived " +
|
|
284
|
+
"ones. Not a raw directory listing: answers 'what is left, what is " +
|
|
285
|
+
"waiting on what, what should come next'. State comes from optional " +
|
|
286
|
+
"plan frontmatter (kind: tracker | status: active|blocked|superseded " +
|
|
287
|
+
"| blocked_by: | blocks: | superseded_by:); a plan without " +
|
|
288
|
+
"frontmatter is grouped by run history alone (attempted = active, " +
|
|
289
|
+
"never attempted = untouched). Within active, whatever unblocks the " +
|
|
290
|
+
"most other plans is listed first.",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: "object" as const,
|
|
293
|
+
properties: {
|
|
294
|
+
worktree: {
|
|
295
|
+
type: "string",
|
|
296
|
+
description: "Absolute path to the project's git worktree",
|
|
297
|
+
},
|
|
298
|
+
format: {
|
|
299
|
+
type: "string",
|
|
300
|
+
enum: ["json", "markdown"],
|
|
301
|
+
description:
|
|
302
|
+
"'markdown' renders the groups as a checklist to paste or read; " +
|
|
303
|
+
"default 'json'.",
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
required: ["worktree"],
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/mcp/tools/mem.ts — mem_find: read-only search over the project's mem log
|
|
2
|
+
//
|
|
3
|
+
// "เขียนได้ ≠ อ่านกลับได้" — the write side works from the CLI with no MCP
|
|
4
|
+
// (vela: 2,920 rows / 49 days); what was missing is "which rows are about the
|
|
5
|
+
// files I am about to touch". Read-only over readMemLog (PLAN-mem-mcp chunk 2).
|
|
6
|
+
//
|
|
7
|
+
// No default kind filter — chunk 0 chose A: every kind is still live, so
|
|
8
|
+
// filtering `synced`/`next`/`claim` by default would be enforcing prose that
|
|
9
|
+
// was already retracted.
|
|
10
|
+
|
|
11
|
+
import { type MemRow, readMemLog, resolveMemDir } from "../../memory.js";
|
|
12
|
+
import { errorResult, jsonResult, type ToolResult } from "../types.js";
|
|
13
|
+
|
|
14
|
+
export interface MemFindResult {
|
|
15
|
+
rows: MemRow[];
|
|
16
|
+
total: number;
|
|
17
|
+
filesFound: number;
|
|
18
|
+
skipped: number;
|
|
19
|
+
memDir: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function memFind(args: {
|
|
23
|
+
worktree: string;
|
|
24
|
+
files?: string[];
|
|
25
|
+
text?: string;
|
|
26
|
+
kind?: string[];
|
|
27
|
+
since?: string;
|
|
28
|
+
limit?: number;
|
|
29
|
+
}): MemFindResult {
|
|
30
|
+
const read = readMemLog(args.worktree, args.since);
|
|
31
|
+
|
|
32
|
+
let rows = read.rows;
|
|
33
|
+
if (args.kind && args.kind.length > 0) {
|
|
34
|
+
const kinds = new Set(args.kind);
|
|
35
|
+
rows = rows.filter((r) => kinds.has(r.kind));
|
|
36
|
+
}
|
|
37
|
+
if (args.text) {
|
|
38
|
+
const needle = args.text.toLowerCase();
|
|
39
|
+
rows = rows.filter((r) => r.text.toLowerCase().includes(needle));
|
|
40
|
+
}
|
|
41
|
+
if (args.files && args.files.length > 0) {
|
|
42
|
+
// mem never stored files[] — match is substring over text/spec/ref.
|
|
43
|
+
// Low recall by nature (spec §5.4): a row that never names the file
|
|
44
|
+
// cannot be found — a limit of the data, not of the query.
|
|
45
|
+
const paths = args.files.map((f) => f.toLowerCase());
|
|
46
|
+
rows = rows.filter((r) => {
|
|
47
|
+
const hay = `${r.text}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
|
|
48
|
+
return paths.some((p) => hay.includes(p));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const total = rows.length;
|
|
53
|
+
const limit = Math.max(0, args.limit ?? 20);
|
|
54
|
+
return {
|
|
55
|
+
rows: rows.slice(0, limit),
|
|
56
|
+
total,
|
|
57
|
+
filesFound: read.filesFound,
|
|
58
|
+
skipped: read.skipped,
|
|
59
|
+
// memDir separates "no mem at all" from "nothing matched" (spec §3) and
|
|
60
|
+
// makes the monorepo single-log limit visible (spec §5.1).
|
|
61
|
+
memDir: resolveMemDir(args.worktree),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function toolMemFind(args: Record<string, unknown>): ToolResult {
|
|
66
|
+
const worktree =
|
|
67
|
+
typeof args.worktree === "string" ? args.worktree.trim() : "";
|
|
68
|
+
if (!worktree) {
|
|
69
|
+
return errorResult(
|
|
70
|
+
"worktree is required and must be an absolute path " +
|
|
71
|
+
"(git rev-parse --show-toplevel) — an empty result without it would be " +
|
|
72
|
+
"misread as 'no history'",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (!worktree.startsWith("/")) {
|
|
76
|
+
return errorResult(
|
|
77
|
+
`worktree must be an absolute path, got: ${worktree} — a bare name scopes to nothing`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const files = Array.isArray(args.files)
|
|
81
|
+
? args.files.filter(
|
|
82
|
+
(f): f is string => typeof f === "string" && f.length > 0,
|
|
83
|
+
)
|
|
84
|
+
: undefined;
|
|
85
|
+
const kind = Array.isArray(args.kind)
|
|
86
|
+
? args.kind.filter(
|
|
87
|
+
(k): k is string => typeof k === "string" && k.length > 0,
|
|
88
|
+
)
|
|
89
|
+
: undefined;
|
|
90
|
+
const text = typeof args.text === "string" ? args.text : undefined;
|
|
91
|
+
const since = typeof args.since === "string" ? args.since : undefined;
|
|
92
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
93
|
+
|
|
94
|
+
return jsonResult(memFind({ worktree, files, text, kind, since, limit }));
|
|
95
|
+
}
|