fapony 0.2.1 → 0.3.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.
Files changed (54) hide show
  1. package/README.md +95 -72
  2. package/fapony.ts +12 -5
  3. package/package.json +5 -4
  4. package/skill/define-convention/SKILL.md +77 -0
  5. package/skill/lookup-before-edit/SKILL.md +48 -0
  6. package/skill/move-to-done/SKILL.md +19 -30
  7. package/skill/review-pony/SKILL.md +38 -61
  8. package/src/analyze.ts +1 -1
  9. package/src/debt/cli.ts +193 -0
  10. package/src/debt/format.ts +107 -0
  11. package/src/debt/index.ts +19 -0
  12. package/src/debt/load.ts +92 -0
  13. package/src/debt/promotion.ts +152 -0
  14. package/src/debt/scan.ts +214 -0
  15. package/src/debt/types.ts +79 -0
  16. package/src/detect.ts +92 -0
  17. package/src/gate.ts +3 -3
  18. package/src/hook.ts +349 -100
  19. package/src/init-mem.ts +57 -71
  20. package/src/init.ts +12 -16
  21. package/src/install/antigravity.ts +112 -0
  22. package/src/install/claude.ts +16 -123
  23. package/src/install/codex.ts +58 -19
  24. package/src/install/detect.ts +17 -7
  25. package/src/install/opencode.ts +131 -6
  26. package/src/install.ts +12 -3
  27. package/src/lint-baseline.ts +2 -2
  28. package/src/mcp/primitives.ts +1 -1
  29. package/src/mcp/tools/index.ts +13 -102
  30. package/src/mcp/tools/mem.ts +71 -0
  31. package/src/mcp/transport.ts +8 -94
  32. package/src/mcp/worktree.ts +1 -1
  33. package/src/mem/commands/read.ts +231 -141
  34. package/src/mem/index.ts +4 -13
  35. package/src/memory.ts +17 -8
  36. package/src/{plan-seed.ts → seed/plan-seed.ts} +14 -32
  37. package/src/seed/primitives.ts +66 -0
  38. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  39. package/src/session/helpers.ts +1 -1
  40. package/src/session/registry.ts +3 -6
  41. package/src/setup.ts +1 -1
  42. package/src/stats/data.ts +1 -1
  43. package/src/telemetry.ts +1 -1
  44. package/src/util.ts +61 -0
  45. package/templates/SPEC.md +8 -1
  46. package/images/logo.png +0 -0
  47. package/images/logo.webp +0 -0
  48. package/images/logo@400.webp +0 -0
  49. package/images/sample.webp +0 -0
  50. package/images/summary.webp +0 -0
  51. package/src/debt.ts +0 -811
  52. package/src/math.ts +0 -13
  53. package/src/mcp/tools/usage.ts +0 -211
  54. package/src/mcp/tools/verdict.ts +0 -161
package/src/math.ts DELETED
@@ -1,13 +0,0 @@
1
- // src/math.ts — pure numeric helpers shared across modules
2
-
3
- /** Minutes between two ISO-like timestamps (space separator, UTC assumed). */
4
- export function minutesBetween(a: string, b: string): number {
5
- const t0 = new Date(`${a.replace(" ", "T")}Z`).getTime();
6
- const t1 = new Date(`${b.replace(" ", "T")}Z`).getTime();
7
- return (t1 - t0) / 60000;
8
- }
9
-
10
- /** Arithmetic mean of a numeric array. 0 for empty arrays. */
11
- export function avg(xs: number[]): number {
12
- return xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0;
13
- }
@@ -1,211 +0,0 @@
1
- // src/mcp/tools/usage.ts — fapony_usage tool
2
-
3
- import {
4
- type ImputeSummary,
5
- imputeResult,
6
- loadPrices,
7
- type PriceTable,
8
- } from "../../price/index.js";
9
- import {
10
- CLIENTS,
11
- mergeBytesByTool,
12
- type PassiveUsageResult,
13
- } from "../../session/index.js";
14
- import { jsonResult, type ToolResult } from "../types.js";
15
-
16
- /** list-price summary per client — additive, leaves existing lines untouched */
17
- function imputationOf(
18
- result: PassiveUsageResult,
19
- prices: PriceTable | null,
20
- ): (ImputeSummary & { prices_fetched_at: string }) | null {
21
- if (result.session_count === 0 || !prices) return null;
22
- return {
23
- ...imputeResult(result, prices),
24
- prices_fetched_at: prices.fetched_at,
25
- };
26
- }
27
-
28
- function imputedTextLines(
29
- label: string,
30
- result: PassiveUsageResult,
31
- prices: PriceTable | null,
32
- ): string[] {
33
- if (result.session_count === 0) return [];
34
- const imp = imputationOf(result, prices);
35
- if (!imp)
36
- return [` ${label}list-price equivalent: — (run \`fapony price-scan\`)`];
37
- const parts = [
38
- `~$${imp.total_imputed.toFixed(4)} over ${imp.priced_sessions} priced sessions`,
39
- ];
40
- if (imp.free_sessions > 0) parts.push(`${imp.free_sessions} free`);
41
- if (imp.unpriced_sessions > 0)
42
- parts.push(
43
- `unpriced: ${imp.unpriced_sessions} sessions / ${imp.unpriced_tokens.toLocaleString()} tokens`,
44
- );
45
- return [` ${label}list-price equivalent: ${parts.join(" · ")}`];
46
- }
47
-
48
- /** list price per model appended to the model name — only rows where the client records no cost */
49
- function imputedSuffix(
50
- provider: string,
51
- model: string,
52
- imp: (ImputeSummary & { prices_fetched_at: string }) | null,
53
- ): string {
54
- if (!imp) return "";
55
- const m = imp.by_model.find(
56
- (b) => b.provider === provider && b.model === model,
57
- );
58
- if (m && m.status === "priced")
59
- return ` (~$${m.imputed_cost.toFixed(4)} list-price)`;
60
- return "";
61
- }
62
-
63
- export function toolPassiveUsage(args: Record<string, unknown>): ToolResult {
64
- const worktree =
65
- typeof args.worktree === "string" ? args.worktree : undefined;
66
- const since = typeof args.since === "number" ? args.since : undefined;
67
- const until = typeof args.until === "number" ? args.until : undefined;
68
- const detail = args.detail === true;
69
-
70
- const primary = CLIENTS.find((c) => c.primary)!;
71
- const others = CLIENTS.filter((c) => !c.primary);
72
-
73
- const data: PassiveUsageResult = primary.read(worktree, since, until, detail);
74
-
75
- // Every non-primary client is always fetched when its own log exists.
76
- const otherResults = others.map((client) => ({
77
- client,
78
- result: client.read(worktree, since, until, detail),
79
- }));
80
-
81
- // list prices from the cache only — read once per call, not per section
82
- const prices = loadPrices();
83
-
84
- if (args.json === true) {
85
- const json: Record<string, unknown> = {
86
- ...data,
87
- imputation: imputationOf(data, prices),
88
- };
89
- for (const { client, result } of otherResults) {
90
- json[client.key] =
91
- result.session_count > 0
92
- ? { ...result, imputation: imputationOf(result, prices) }
93
- : null;
94
- }
95
- return jsonResult(json);
96
- }
97
-
98
- const lines: string[] = [];
99
- lines.push("Passive Usage Report");
100
- lines.push("====================");
101
- lines.push(`Sessions: ${data.session_count}`);
102
- lines.push(`Total Input Tokens: ${data.total_tokens_input.toLocaleString()}`);
103
- lines.push(
104
- `Total Output Tokens: ${data.total_tokens_output.toLocaleString()}`,
105
- );
106
- lines.push(
107
- `Total Reasoning Tokens: ${data.total_tokens_reasoning.toLocaleString()}`,
108
- );
109
- lines.push(`Cache Read: ${data.total_tokens_cache_read.toLocaleString()}`);
110
- lines.push(`Cache Write: ${data.total_tokens_cache_write.toLocaleString()}`);
111
- lines.push(`Total Cost: $${data.total_cost.toFixed(4)}`);
112
- lines.push(...imputedTextLines("", data, prices));
113
-
114
- if (data.by_model.length > 0) {
115
- lines.push("");
116
- lines.push("By Model:");
117
- lines.push("--------");
118
- for (const m of data.by_model) {
119
- const prefix = m.provider ? `${m.provider}/` : "";
120
- lines.push(
121
- ` ${prefix}${m.model}: ${m.session_count} sessions, ` +
122
- `${m.tokens_input.toLocaleString()} in / ${m.tokens_output.toLocaleString()} out, ` +
123
- `$${m.cost.toFixed(4)}`,
124
- );
125
- }
126
- }
127
-
128
- // detail:true is opt-in — default text above is byte-identical to before.
129
- if (detail && data.detail) {
130
- lines.push("");
131
- lines.push("Detail (tool activity — signal, not quality):");
132
- lines.push(` steps: ${data.detail.steps.toLocaleString()}`);
133
- const tools = Object.entries(data.detail.tool_breakdown).sort(
134
- (a, b) => b[1] - a[1],
135
- );
136
- if (tools.length > 0) {
137
- lines.push(" by tool:");
138
- for (const [tool, count] of tools.slice(0, 20)) {
139
- lines.push(` ${tool}: ${count.toLocaleString()}`);
140
- }
141
- if (tools.length > 20) {
142
- lines.push(` ... +${tools.length - 20} more (see json:true)`);
143
- }
144
- }
145
- // Context bytes by tool — proportion of context window consumed per tool.
146
- // Bytes are a proxy for tokens; shown as % of total, never as "tokens".
147
- // Merged across all clients: OpenCode/ZCode (state.output), Claude Code
148
- // and Codex (tool_result) each populate bytes_by_tool, so reading the
149
- // top-level (opencode) detail alone would miss the other clients.
150
- const bytes = mergeBytesByTool(
151
- data.detail,
152
- ...otherResults.map((r) => r.result.detail),
153
- );
154
- if (bytes) {
155
- const entries = Object.entries(bytes).sort((a, b) => b[1] - a[1]);
156
- const total = entries.reduce((s, e) => s + e[1], 0);
157
- if (entries.length > 0 && total > 0) {
158
- lines.push(" context bytes by tool (% of total):");
159
- for (const [tool, b] of entries.slice(0, 10)) {
160
- const pct = ((b / total) * 100).toFixed(1);
161
- lines.push(` ${tool}: ${pct}%`);
162
- }
163
- if (entries.length > 10) {
164
- lines.push(` ... +${entries.length - 10} more (see json:true)`);
165
- }
166
- }
167
- }
168
- lines.push(` sessions with activity: ${data.detail.by_session.length}`);
169
- const top = data.detail.by_session.slice(0, 10);
170
- for (const s of top) {
171
- const topTool = Object.entries(s.tools).sort((a, b) => b[1] - a[1])[0];
172
- lines.push(
173
- ` ${s.session_id}: ${s.steps} steps` +
174
- (topTool ? `, top tool ${topTool[0]}×${topTool[1]}` : ""),
175
- );
176
- }
177
- if (data.detail.by_session.length > 10) {
178
- lines.push(
179
- ` ... +${data.detail.by_session.length - 10} more (see json:true)`,
180
- );
181
- }
182
- }
183
-
184
- // One text section per non-primary client — same shape for every client,
185
- // new ones need no new formatting code, only a registry.ts entry.
186
- for (const { client, result } of otherResults) {
187
- if (result.session_count === 0) continue;
188
- const imp = imputationOf(result, prices);
189
- const label = client.reportLabel ?? client.key;
190
- lines.push("");
191
- lines.push(`${label} usage:`);
192
- lines.push(
193
- ` total: ${result.total_tokens_input.toLocaleString()} in / ${result.total_tokens_output.toLocaleString()} out / ${result.total_tokens_reasoning.toLocaleString()} reasoning tokens over ${result.session_count} sessions`,
194
- );
195
- lines.push(
196
- ` cache: ${result.total_tokens_cache_read.toLocaleString()} read / ${result.total_tokens_cache_write.toLocaleString()} write`,
197
- );
198
- if (result.by_model.length > 0) {
199
- lines.push(" by model:");
200
- for (const m of result.by_model) {
201
- const prefix = m.provider ? `${m.provider}/` : "";
202
- lines.push(
203
- ` ${prefix}${m.model}: ${m.tokens_input.toLocaleString()} in / ${m.tokens_output.toLocaleString()} out (cache r/w: ${(m.tokens_cache_read ?? 0).toLocaleString()} / ${(m.tokens_cache_write ?? 0).toLocaleString()})${imputedSuffix(m.provider, m.model, imp)}`,
204
- );
205
- }
206
- }
207
- lines.push(...imputedTextLines("", result, prices));
208
- }
209
-
210
- return { content: [{ type: "text", text: lines.join("\n") }] };
211
- }
@@ -1,161 +0,0 @@
1
- // src/mcp/tools/verdict.ts — verdict_submit tool
2
-
3
- import {
4
- findOpenRun,
5
- findOpenRunWithNullPlan,
6
- getRun,
7
- newRun,
8
- openDb,
9
- patchLastGateEvent,
10
- } from "../../db/index.js";
11
- import { gateOnce } from "../../gate.js";
12
- import { VERDICT_GRADES, type VerdictGrade } from "../../parse.js";
13
- import {
14
- errorResult,
15
- jsonResult,
16
- REASON_CODES,
17
- REGIME_CODES,
18
- type ReasonCode,
19
- type RegimeCode,
20
- type ToolResult,
21
- } from "../types.js";
22
- import { resolveWorktreeArg } from "../worktree.js";
23
-
24
- // --- Tool implementation ---
25
-
26
- export function toolVerdictSubmit(args: Record<string, unknown>): ToolResult {
27
- const {
28
- run_id,
29
- verdict,
30
- reason_code,
31
- regime,
32
- note,
33
- worktree,
34
- plan,
35
- session_id,
36
- files,
37
- } = args;
38
-
39
- if (typeof verdict !== "string" || !VERDICT_GRADES.has(verdict)) {
40
- return errorResult(
41
- `verdict must be one of: ${[...VERDICT_GRADES].join(", ")}`,
42
- );
43
- }
44
- const grade = verdict as VerdictGrade;
45
- if (!REASON_CODES.includes(reason_code as ReasonCode)) {
46
- return errorResult(
47
- `reason_code must be one of: ${REASON_CODES.join(", ")}`,
48
- );
49
- }
50
- if (reason_code === "other" && (!note || typeof note !== "string")) {
51
- return errorResult("reason_code 'other' requires a note");
52
- }
53
- if (!REGIME_CODES.includes(regime as RegimeCode)) {
54
- return errorResult(`regime must be one of: ${REGIME_CODES.join(", ")}`);
55
- }
56
- const regimeCode = regime as RegimeCode;
57
-
58
- const db = openDb();
59
-
60
- // Resolve or create run_id
61
- let resolvedRunId: number;
62
- if (typeof run_id === "number" && Number.isInteger(run_id)) {
63
- const run = getRun(db, run_id);
64
- if (!run) {
65
- return jsonResult({ stored: false, error: "run not found" });
66
- }
67
- resolvedRunId = run_id;
68
- } else {
69
- // Bind to the latest still-open run for the same worktree+plan so a
70
- // round-2+ verdict lands on the original row (round keeps counting and
71
- // review.maxRounds can actually trigger). Only when no open run matches
72
- // is a fresh row created. plan=null always opens a new run (no guess).
73
- // Free-text plans are normalized (trim+lowercase) so "Fix Login" and
74
- // "fix login" bind to the same run. When no exact match exists, falls
75
- // back to any open run with plan=null (the "no PLAN file" flow).
76
- const resolvedWorktree =
77
- typeof worktree === "string" && worktree
78
- ? resolveWorktreeArg(worktree)
79
- : "mcp-external";
80
- const resolvedPlan =
81
- typeof plan === "string" && plan ? plan.trim().toLowerCase() : null;
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;
111
- }
112
- }
113
-
114
- // Normalize files: must be a non-empty array of strings.
115
- const resolvedFiles =
116
- Array.isArray(files) && files.length > 0
117
- ? files.filter((f): f is string => typeof f === "string" && f.length > 0)
118
- : undefined;
119
-
120
- // Route through gateOnce for consistent status/round/memory handling.
121
- const mcpNote =
122
- typeof note === "string" && note
123
- ? `[${reason_code}] ${note}`
124
- : `[${reason_code}]`;
125
- const result = gateOnce(resolvedRunId, grade, mcpNote, resolvedFiles);
126
-
127
- if (result.error) {
128
- return jsonResult({
129
- stored: false,
130
- error: result.error,
131
- run_id: resolvedRunId,
132
- status: result.status,
133
- round: result.round,
134
- });
135
- }
136
-
137
- // Patch the gate event with MCP-specific fields (reason_code, source).
138
- // session_id lets gates.ts resolve model from client session logs when the
139
- // window has no spawn events (the old execute→review loop that wrote spawns
140
- // is gone). Optional — agents that can't expose it just omit it.
141
- const patch: Record<string, unknown> = {
142
- reason_code,
143
- regime: regimeCode,
144
- source: "mcp",
145
- };
146
- if (typeof session_id === "string" && session_id) {
147
- patch.session_id = session_id;
148
- }
149
- patchLastGateEvent(db, resolvedRunId, patch);
150
-
151
- return jsonResult({
152
- stored: true,
153
- run_id: resolvedRunId,
154
- verdict: grade,
155
- reason_code,
156
- regime: regimeCode,
157
- status: result.status,
158
- round: result.round,
159
- ...(resolvedFiles ? { files: resolvedFiles } : {}),
160
- });
161
- }