fapony 0.1.1 → 0.1.2
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/package.json +8 -7
- 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/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.2";
|
|
116
116
|
|
|
117
117
|
// --- JSON-RPC dispatch ---
|
|
118
118
|
|
package/src/plan-seed.ts
CHANGED
|
@@ -88,7 +88,7 @@ function scopeSourceFiles(root: string): string[] {
|
|
|
88
88
|
// is about to plan. Headers only (title + shipped date) — pulling the bodies
|
|
89
89
|
// in would recreate the reading task the plan exists to avoid.
|
|
90
90
|
function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
|
|
91
|
-
const placeholder = "- _(agent
|
|
91
|
+
const placeholder = "- _(agent fills in)_";
|
|
92
92
|
const keys = roots
|
|
93
93
|
.map((r) => relative(cwd, r))
|
|
94
94
|
.filter((r) => r !== "" && r !== ".");
|
|
@@ -124,7 +124,7 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
|
|
|
124
124
|
const shipped = content.match(/shipped\s+(\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
|
|
125
125
|
hits.push({
|
|
126
126
|
shipped,
|
|
127
|
-
line: `- ✅
|
|
127
|
+
line: `- ✅ Already decided: [${n}](../${label}/${n}) — ${title}${shipped ? ` (shipped ${shipped})` : ""} \`(fapony plan-seed)\``,
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
130
|
}
|
|
@@ -137,7 +137,9 @@ function renderPriorArt(cwd: string, config: Config, roots: string[]): string {
|
|
|
137
137
|
);
|
|
138
138
|
const shown = hits.slice(0, MAX_PRIOR_ART).map((h) => h.line);
|
|
139
139
|
if (hits.length > MAX_PRIOR_ART) {
|
|
140
|
-
shown.push(
|
|
140
|
+
shown.push(
|
|
141
|
+
`- … +${hits.length - MAX_PRIOR_ART} more touching the same scope`,
|
|
142
|
+
);
|
|
141
143
|
}
|
|
142
144
|
shown.push(placeholder);
|
|
143
145
|
return shown.join("\n");
|
|
@@ -185,49 +187,49 @@ kind: unit
|
|
|
185
187
|
status: active
|
|
186
188
|
---
|
|
187
189
|
|
|
188
|
-
# PLAN-${name} — (agent
|
|
190
|
+
# PLAN-${name} — (agent fills in a title)
|
|
189
191
|
|
|
190
|
-
> **Status:** 🚧 in-progress · **Created:** (agent
|
|
192
|
+
> **Status:** 🚧 in-progress · **Created:** (agent fills in the date)
|
|
191
193
|
|
|
192
194
|
## TL;DR
|
|
193
|
-
- **What:** (agent
|
|
194
|
-
- **Order:** (agent
|
|
195
|
+
- **What:** (agent fills in) · **Why:** (agent fills in) · **Done when:** (agent fills in)
|
|
196
|
+
- **Order:** (agent fills in)
|
|
195
197
|
- **Progress:**
|
|
196
|
-
- [ ] chunk 1 — (agent
|
|
198
|
+
- [ ] chunk 1 — (agent fills in)
|
|
197
199
|
|
|
198
200
|
## Context (fapony)
|
|
199
201
|
${contextFapony}
|
|
200
202
|
|
|
201
203
|
## 1. Goal (why)
|
|
202
|
-
_(agent
|
|
204
|
+
_(agent fills in)_
|
|
203
205
|
|
|
204
206
|
## 2. Scope (do / don't do)
|
|
205
|
-
_(agent
|
|
207
|
+
_(agent fills in)_
|
|
206
208
|
|
|
207
209
|
## 3. Done criteria (how we know it's finished)
|
|
208
|
-
_(agent
|
|
210
|
+
_(agent fills in — must be verifiable)_
|
|
209
211
|
|
|
210
212
|
## 4. Constraints / Hard rules (must not violate)
|
|
211
|
-
_(agent
|
|
213
|
+
_(agent fills in)_
|
|
212
214
|
|
|
213
215
|
## 5. Risks & Escape hatches (if it fails)
|
|
214
|
-
_(agent
|
|
216
|
+
_(agent fills in)_
|
|
215
217
|
|
|
216
218
|
## 6. Steps (what in which order)
|
|
217
|
-
1. _(agent
|
|
219
|
+
1. _(agent fills in — each step must be verifiable)_
|
|
218
220
|
|
|
219
221
|
## 7. Examples
|
|
220
222
|
${
|
|
221
223
|
specLink
|
|
222
|
-
? `→ ${specLink} (
|
|
223
|
-
: "_(agent
|
|
224
|
+
? `→ ${specLink} (signatures live in the spec, not the plan)`
|
|
225
|
+
: "_(agent fills in — or add a SPEC with plan-seed --spec)_"
|
|
224
226
|
}
|
|
225
227
|
|
|
226
228
|
## 8. References
|
|
227
229
|
${priorArt}
|
|
228
230
|
|
|
229
231
|
## Context (agent)
|
|
230
|
-
_(slot
|
|
232
|
+
_(empty slot — the agent dumps its own graph/code-summary)_
|
|
231
233
|
`;
|
|
232
234
|
}
|
|
233
235
|
|
|
@@ -437,7 +439,7 @@ function specTemplate(
|
|
|
437
439
|
// undershot the cap by that many lines (the 18k-SPEC failure mode). Cap the
|
|
438
440
|
// index separately so the body's capLines has a bounded head to work with.
|
|
439
441
|
const fixedHead = [
|
|
440
|
-
`# SPEC-${name} — (agent
|
|
442
|
+
`# SPEC-${name} — (agent fills in a title)`,
|
|
441
443
|
"",
|
|
442
444
|
`> **Used by:** PLAN-${name} — signatures below come from a live source scan — re-seed after structural changes.`,
|
|
443
445
|
...(scopeEcho ? [`> **Scope:** ${scopeEcho}`] : []),
|
|
@@ -456,7 +458,7 @@ function specTemplate(
|
|
|
456
458
|
);
|
|
457
459
|
const head = [...fixedHead, ...cappedIndex, ""];
|
|
458
460
|
const tail = [
|
|
459
|
-
"## (agent
|
|
461
|
+
"## (agent fills in — wireframes / edge cases / API shapes the plan references)",
|
|
460
462
|
];
|
|
461
463
|
const bodyLines = chunks.flatMap((c) => [
|
|
462
464
|
`## <a id="${c.slug}"></a>${c.title}`,
|
|
@@ -557,7 +559,7 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
557
559
|
const planPath = join(planDirAbs, `PLAN-${name}.md`);
|
|
558
560
|
if (existsSync(planPath)) {
|
|
559
561
|
console.error(
|
|
560
|
-
`${planPath} already exists — not overwriting.
|
|
562
|
+
`${planPath} already exists — not overwriting. Use a new name, e.g. PLAN-${name}-v2`,
|
|
561
563
|
);
|
|
562
564
|
process.exit(1);
|
|
563
565
|
}
|
|
@@ -571,7 +573,7 @@ export function cmdPlanSeed(args: string[]): void {
|
|
|
571
573
|
const specPath = join(specDirAbs, `SPEC-${name}.md`);
|
|
572
574
|
if (existsSync(specPath)) {
|
|
573
575
|
console.error(
|
|
574
|
-
`${specPath} already exists — not overwriting.
|
|
576
|
+
`${specPath} already exists — not overwriting. Use a new name, e.g. SPEC-${name}-v2`,
|
|
575
577
|
);
|
|
576
578
|
process.exit(1);
|
|
577
579
|
}
|
package/src/price/fetch.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// src/price/fetch.ts —
|
|
1
|
+
// src/price/fetch.ts — fetch OpenRouter's price table + cache it as prices.json
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// FAPONY_STATE_DIR)
|
|
5
|
-
// refresh
|
|
6
|
-
// (
|
|
3
|
+
// prices are cache, not state: stored at ~/.config/fapony/prices.json (honors
|
|
4
|
+
// FAPONY_STATE_DIR) no new SQLite tables (DB Schema rule: 2 tables only)
|
|
5
|
+
// refresh happens only when someone runs `fapony price-scan` — a query never fetches on its own
|
|
6
|
+
// (same discipline as usage-scan) offline, read the existing cache
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
@@ -19,12 +19,12 @@ import type { Config } from "../db/types.js";
|
|
|
19
19
|
const PRICES_FILENAME = "prices.json";
|
|
20
20
|
const MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** per-token rates (dollars) — every rate comes straight from OpenRouter, no guessing */
|
|
23
23
|
export interface ModelRates {
|
|
24
24
|
input: number;
|
|
25
25
|
output: number;
|
|
26
26
|
cacheRead: number;
|
|
27
|
-
/** null =
|
|
27
|
+
/** null = the table does not provide it → use the input rate instead (see calcCost) */
|
|
28
28
|
cacheWrite: number | null;
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -37,7 +37,7 @@ export function pricesPath(config?: Config): string {
|
|
|
37
37
|
return join(faponyDir(config), PRICES_FILENAME);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/** Read the cache — null when the file is missing or broken (caller shows "—" + hint) */
|
|
41
41
|
export function loadPrices(config?: Config): PriceTable | null {
|
|
42
42
|
const p = pricesPath(config);
|
|
43
43
|
if (!existsSync(p)) return null;
|
|
@@ -53,7 +53,7 @@ export function loadPrices(config?: Config): PriceTable | null {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/** Write atomically (tmp + rename), create the state dir when absent */
|
|
57
57
|
export function writePrices(table: PriceTable, config?: Config): void {
|
|
58
58
|
const p = pricesPath(config);
|
|
59
59
|
mkdirSync(dirname(p), { recursive: true });
|
|
@@ -72,14 +72,14 @@ function toRate(v: unknown): number | null {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
75
|
+
* Turn OpenRouter's raw response into a rate table (pure — testable without hitting the network)
|
|
76
76
|
*
|
|
77
|
-
*
|
|
77
|
+
* Real fields (confirmed 2026-09-14, 445 models): pricing.prompt / .completion /
|
|
78
78
|
* .input_cache_read / .input_cache_write (optional) / .input_cache_write_1h
|
|
79
|
-
* (
|
|
80
|
-
*
|
|
81
|
-
* utc_days —
|
|
82
|
-
*
|
|
79
|
+
* (the 1h TTL rate of some models — the standard 5m rate suffices because logs do not report TTL)
|
|
80
|
+
* ignore pricing.web_search / pricing.overrides (tiers based on min_prompt_tokens /
|
|
81
|
+
* utc_days — use the base rate and state the limitation) · ids starting with ~ (alias) are dropped
|
|
82
|
+
* ids ending :free / :batch are kept as-is (they are their own price rows)
|
|
83
83
|
*/
|
|
84
84
|
export function parsePricesResponse(json: unknown): Record<string, ModelRates> {
|
|
85
85
|
const out: Record<string, ModelRates> = {};
|
|
@@ -102,8 +102,8 @@ export function parsePricesResponse(json: unknown): Record<string, ModelRates> {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
106
|
-
* response
|
|
105
|
+
* Merge the new table into the old cache — merge, not replace: an id missing from
|
|
106
|
+
* this response (an old model dropped from the table) must still be priced with its old rate
|
|
107
107
|
*/
|
|
108
108
|
export function mergePriceTables(
|
|
109
109
|
old: PriceTable | null,
|
|
@@ -115,7 +115,7 @@ export function mergePriceTables(
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/**
|
|
118
|
+
/** Fetch the table from OpenRouter (public, no auth) — fetcher is injectable for tests */
|
|
119
119
|
export async function fetchPriceTable(
|
|
120
120
|
fetcher: typeof fetch = fetch,
|
|
121
121
|
): Promise<Record<string, ModelRates>> {
|
|
@@ -134,7 +134,7 @@ export async function cmdPriceScan(rawArgs: string[]): Promise<void> {
|
|
|
134
134
|
fresh = await fetchPriceTable();
|
|
135
135
|
} catch (err) {
|
|
136
136
|
console.error(
|
|
137
|
-
`fapony price-scan: fetch failed (${String(err)}) — cache
|
|
137
|
+
`fapony price-scan: fetch failed (${String(err)}) — the existing cache is still here, use the prices you have`,
|
|
138
138
|
);
|
|
139
139
|
process.exit(1);
|
|
140
140
|
}
|