fapony 0.3.0 → 0.3.4
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 +87 -65
- package/fapony.ts +3 -116
- package/package.json +6 -5
- package/skill/move-to-done/SKILL.md +22 -32
- package/skill/review-pony/SKILL.md +27 -58
- package/src/adapters/cli.ts +123 -0
- package/src/adapters/hooks/compute-hint-impact.ts +107 -0
- package/src/adapters/hooks/context-data.ts +102 -0
- package/src/adapters/hooks/edit-hint.ts +195 -0
- package/src/adapters/hooks/git-autonomy.ts +178 -0
- package/src/adapters/hooks/index.ts +79 -0
- package/src/adapters/hooks/mv-guard.ts +52 -0
- package/src/adapters/hooks/read-hint.ts +383 -0
- package/src/adapters/hooks/session-start.ts +101 -0
- package/src/adapters/hooks/stop.ts +302 -0
- package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
- package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
- package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
- package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
- package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
- package/src/{mcp → adapters/mcp}/transport.ts +4 -39
- package/src/adapters/mcp/types.ts +18 -0
- package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
- package/src/analyze.ts +2 -2
- package/src/conventions-seed.ts +1 -1
- package/src/core/config.ts +199 -0
- package/src/core/debt-format.ts +107 -0
- package/src/core/debt-types.ts +79 -0
- package/src/core/defaults.ts +8 -0
- package/src/core/enums.ts +34 -0
- package/src/core/format.ts +33 -0
- package/src/core/hint-log.ts +68 -0
- package/src/core/hook-helpers.ts +31 -0
- package/src/core/mem-log.ts +357 -0
- package/src/core/parse.ts +71 -0
- package/src/core/pricing.ts +217 -0
- package/src/core/safety.ts +18 -0
- package/src/core/types.ts +142 -0
- package/src/core/util.ts +105 -0
- package/src/db/store.ts +2 -2
- package/src/debt/cli.ts +1 -1
- package/src/debt/format.ts +2 -107
- package/src/debt/load.ts +1 -1
- package/src/debt/promotion.ts +1 -1
- package/src/debt/types.ts +14 -79
- package/src/digest/collect.ts +4 -3
- package/src/gate.ts +5 -5
- package/src/gates.ts +1 -1
- package/src/hook.ts +69 -1337
- package/src/init-mem.ts +58 -72
- package/src/init.ts +13 -17
- package/src/install/antigravity.ts +112 -0
- package/src/install/claude.ts +19 -123
- package/src/install/detect.ts +17 -7
- package/src/install/opencode.ts +167 -26
- package/src/install.ts +23 -7
- package/src/lint-baseline.ts +1 -2
- package/src/map.ts +29 -8
- package/src/mem/commands/plan.ts +70 -42
- package/src/mem/commands/read.ts +219 -146
- package/src/mem/index.ts +4 -13
- package/src/mem/store.ts +5 -1
- package/src/memory.ts +24 -387
- package/src/parse.ts +9 -71
- package/src/price/fetch.ts +4 -16
- package/src/price/resolve.ts +12 -213
- package/src/report/cli.ts +3 -3
- package/src/safety.ts +2 -18
- package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
- package/src/seed/primitives.ts +60 -0
- package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
- package/src/session/types.ts +14 -128
- package/src/setup.ts +1 -1
- package/src/stats/data.ts +4 -4
- package/src/telemetry.ts +3 -3
- package/src/usage/cache.ts +1 -2
- package/src/usage/cli.ts +1 -1
- package/src/usage/scan.ts +2 -1
- package/src/util.ts +10 -32
- package/src/web/html.ts +2 -33
- package/templates/SPEC.md +8 -1
- 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/src/db/defaults.ts +0 -34
- package/src/db/getters.ts +0 -35
- package/src/db/index.ts +0 -7
- package/src/db/load.ts +0 -57
- package/src/db/types.ts +0 -77
- package/src/math.ts +0 -13
- package/src/mcp/tools/verdict.ts +0 -161
- package/src/mcp/types.ts +0 -54
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// src/core/mem-log.ts — mem-log reader: directory discovery + JSONL parsing (pure, no feature imports)
|
|
2
|
+
//
|
|
3
|
+
// Extracted from src/memory.ts (PLAN-lib-layer chunk 2b). Shell adapter
|
|
4
|
+
// helpers (closeMemory, kickoffMemory, claimMemory) stay in memory.ts.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
CONFIG_FILENAME,
|
|
10
|
+
DEFAULT_MEM_DIR,
|
|
11
|
+
FAPONY_DIR,
|
|
12
|
+
loadConfig,
|
|
13
|
+
} from "./config.js";
|
|
14
|
+
|
|
15
|
+
export interface MemRow {
|
|
16
|
+
ts: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
kind: string;
|
|
19
|
+
text: string;
|
|
20
|
+
spec?: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
ref?: string;
|
|
23
|
+
/** Files the row is about — written by `mem add --files` (PLAN-convention-debt chunk 3). */
|
|
24
|
+
files?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RawMemRow {
|
|
28
|
+
ts?: string;
|
|
29
|
+
agent?: string;
|
|
30
|
+
kind?: string;
|
|
31
|
+
text?: string;
|
|
32
|
+
spec?: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
ref?: string;
|
|
35
|
+
files?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A directory is a mem dir only when it holds a `log.jsonl` or a named
|
|
40
|
+
* `log.<person>.jsonl` (rotated `log.YYYY-MM-DD.jsonl` archives count too).
|
|
41
|
+
* A `.fapony/.memory/` scaffolded empty by `fapony init` is not a candidate —
|
|
42
|
+
* otherwise it would shadow an ancestor that holds the real log (SPEC §1).
|
|
43
|
+
*/
|
|
44
|
+
function hasMemLogs(dir: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
return readdirSync(dir).some(
|
|
47
|
+
(f) => f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f),
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Physical path (symlinks resolved) so start and repo root compare like with
|
|
55
|
+
* like; falls back to a lexical resolve when the path does not exist yet. */
|
|
56
|
+
function physical(p: string): string {
|
|
57
|
+
try {
|
|
58
|
+
return realpathSync(p);
|
|
59
|
+
} catch {
|
|
60
|
+
return resolve(p);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `git rev-parse --show-toplevel` from a directory, or null outside a repo. */
|
|
65
|
+
function gitRootOf(fromDir: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
const { execSync } =
|
|
68
|
+
require("node:child_process") as typeof import("node:child_process");
|
|
69
|
+
const root = execSync("git rev-parse --show-toplevel", {
|
|
70
|
+
cwd: fromDir,
|
|
71
|
+
encoding: "utf-8",
|
|
72
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
73
|
+
}).trim();
|
|
74
|
+
return root || null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The lexical ancestor of `fromDir` that is the git repo root, or null outside
|
|
82
|
+
* a repo. `git rev-parse` returns a physical path (it resolves /var → /private/
|
|
83
|
+
* var on macOS), so the walk compares `physical(dir)` against it and returns the
|
|
84
|
+
* path in the caller's own lexical form — the returned dir must match what the
|
|
85
|
+
* caller passed in, not a canonicalized stranger.
|
|
86
|
+
*/
|
|
87
|
+
function repoRootOf(fromDir: string): string | null {
|
|
88
|
+
const start = resolve(fromDir);
|
|
89
|
+
const gitRoot = gitRootOf(start);
|
|
90
|
+
if (!gitRoot) return null;
|
|
91
|
+
const physicalRoot = physical(gitRoot);
|
|
92
|
+
let dir = start;
|
|
93
|
+
while (true) {
|
|
94
|
+
if (physical(dir) === physicalRoot) return dir;
|
|
95
|
+
const parent = dirname(dir);
|
|
96
|
+
if (parent === dir) return null;
|
|
97
|
+
dir = parent;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Locate the nearest `.fapony/.memory/` by walking up from `fromDir`.
|
|
103
|
+
*
|
|
104
|
+
* The walk stops at the git repo root (SPEC §1) — a mem dir in a parent
|
|
105
|
+
* checkout is never ours. Outside a repo it stops at the filesystem root.
|
|
106
|
+
* `acceptEmpty` is for the default-wiring check, which only needs the dir the
|
|
107
|
+
* writer will use (log or not); the resolver itself requires a real log.
|
|
108
|
+
*/
|
|
109
|
+
export function walkUpForMemDir(
|
|
110
|
+
fromDir: string,
|
|
111
|
+
acceptEmpty: boolean,
|
|
112
|
+
): string | null {
|
|
113
|
+
const start = resolve(fromDir);
|
|
114
|
+
const boundary = repoRootOf(start) ?? "/";
|
|
115
|
+
let dir = start;
|
|
116
|
+
while (true) {
|
|
117
|
+
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
118
|
+
if (existsSync(candidate) && (acceptEmpty || hasMemLogs(candidate))) {
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
if (dir === boundary) break;
|
|
122
|
+
const parent = dirname(dir);
|
|
123
|
+
if (parent === dir) break;
|
|
124
|
+
dir = parent;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface MemDirResult {
|
|
130
|
+
dir: string | null;
|
|
131
|
+
step: "flag" | "config" | "walk-up" | "repo-root" | "ambiguous" | "none";
|
|
132
|
+
candidates?: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Dirs that never hold a project mem log and are expensive to walk.
|
|
136
|
+
const SCAN_SKIP = new Set([
|
|
137
|
+
"node_modules",
|
|
138
|
+
".git",
|
|
139
|
+
"dist",
|
|
140
|
+
"build",
|
|
141
|
+
"coverage",
|
|
142
|
+
"vendor",
|
|
143
|
+
"out",
|
|
144
|
+
"target",
|
|
145
|
+
".next",
|
|
146
|
+
".turbo",
|
|
147
|
+
".cache",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every `.fapony/.memory/` under `root` that holds a real log, bounded to a
|
|
152
|
+
* shallow walk. Used only to detect the ambiguous monorepo layout (SPEC §1
|
|
153
|
+
* fail example): if two or more app-scoped logs exist and nothing at/above cwd
|
|
154
|
+
* holds one, the caller must refuse rather than start a third log at the root.
|
|
155
|
+
*/
|
|
156
|
+
function findMemDirsUnder(root: string): string[] {
|
|
157
|
+
const found: string[] = [];
|
|
158
|
+
const walk = (dir: string, depth: number): void => {
|
|
159
|
+
if (depth > 5) return;
|
|
160
|
+
let entries: import("node:fs").Dirent[];
|
|
161
|
+
try {
|
|
162
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
if (!e.isDirectory()) continue;
|
|
168
|
+
if (e.name === FAPONY_DIR) {
|
|
169
|
+
const candidate = join(dir, DEFAULT_MEM_DIR);
|
|
170
|
+
if (hasMemLogs(candidate)) found.push(candidate);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (e.name.startsWith(".") || SCAN_SKIP.has(e.name)) continue;
|
|
174
|
+
walk(join(dir, e.name), depth + 1);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(root, 0);
|
|
178
|
+
return found;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The single resolver: returns the mem dir and which step won.
|
|
183
|
+
*
|
|
184
|
+
* Resolution order (SPEC §1):
|
|
185
|
+
* 1. `--mem-dir <path>` (wins over everything)
|
|
186
|
+
* 2. `paths.memDir` in the **repo-root** `fapony.config.json` (relative to root)
|
|
187
|
+
* 3. walk up from cwd for the first `.fapony/.memory/` that holds a real log
|
|
188
|
+
* 4. `<repo root>/.fapony/.memory/`
|
|
189
|
+
*/
|
|
190
|
+
function resolveMemDirFrom(
|
|
191
|
+
fromDir: string,
|
|
192
|
+
explicitOverride?: string,
|
|
193
|
+
): MemDirResult {
|
|
194
|
+
// Step 1: --mem-dir flag
|
|
195
|
+
if (explicitOverride) {
|
|
196
|
+
return {
|
|
197
|
+
dir: existsSync(explicitOverride) ? resolve(explicitOverride) : null,
|
|
198
|
+
step: "flag",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const cwd = resolve(fromDir);
|
|
203
|
+
const root = repoRootOf(cwd);
|
|
204
|
+
|
|
205
|
+
// Step 2: paths.memDir in the repo-root config
|
|
206
|
+
const configDir = root ?? cwd;
|
|
207
|
+
try {
|
|
208
|
+
const configPath = join(configDir, CONFIG_FILENAME);
|
|
209
|
+
if (existsSync(configPath)) {
|
|
210
|
+
const config = loadConfig(configPath);
|
|
211
|
+
if (config?.paths?.memDir) {
|
|
212
|
+
const absCfg = isAbsolute(config.paths.memDir)
|
|
213
|
+
? config.paths.memDir
|
|
214
|
+
: join(configDir, config.paths.memDir);
|
|
215
|
+
if (existsSync(absCfg)) return { dir: absCfg, step: "config" };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// no config or unreadable — continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Step 3: walk up from cwd, only a dir with a real log counts
|
|
223
|
+
const walked = walkUpForMemDir(cwd, false);
|
|
224
|
+
if (walked) return { dir: walked, step: "walk-up" };
|
|
225
|
+
|
|
226
|
+
// Guard (SPEC §1 fail example)
|
|
227
|
+
const candidates = root ? findMemDirsUnder(root) : [];
|
|
228
|
+
if (candidates.length >= 2) {
|
|
229
|
+
return { dir: null, step: "ambiguous", candidates };
|
|
230
|
+
}
|
|
231
|
+
const outOfScope = candidates.length ? { candidates } : {};
|
|
232
|
+
|
|
233
|
+
// Step 4: <repo root>/.fapony/.memory/
|
|
234
|
+
if (root) {
|
|
235
|
+
const rootDir = join(root, DEFAULT_MEM_DIR);
|
|
236
|
+
if (existsSync(rootDir)) {
|
|
237
|
+
return { dir: rootDir, step: "repo-root", ...outOfScope };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { dir: null, step: "none", ...outOfScope };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function resolveMemDir(
|
|
245
|
+
worktree?: string,
|
|
246
|
+
explicitOverride?: string,
|
|
247
|
+
): string | null {
|
|
248
|
+
return resolveMemDirFrom(worktree ?? process.cwd(), explicitOverride).dir;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Resolve and report which step won — for `fapony mem where`. */
|
|
252
|
+
export function whereMemDir(
|
|
253
|
+
fromDir?: string,
|
|
254
|
+
explicitOverride?: string,
|
|
255
|
+
): MemDirResult {
|
|
256
|
+
return resolveMemDirFrom(fromDir ?? process.cwd(), explicitOverride);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read every `log*.jsonl` row under the worktree's memory dir, newest first.
|
|
261
|
+
* `sinceIso` (exclusive) drops older rows; omit it to read the whole log.
|
|
262
|
+
* Malformed/unreadable rows are counted, never thrown — a corrupt line must
|
|
263
|
+
* not take down the caller.
|
|
264
|
+
*/
|
|
265
|
+
export function readMemLog(
|
|
266
|
+
worktree: string,
|
|
267
|
+
sinceIso?: string,
|
|
268
|
+
): { rows: MemRow[]; skipped: number; filesFound: number } {
|
|
269
|
+
const dir = resolveMemDir(worktree);
|
|
270
|
+
if (!dir) return { rows: [], skipped: 0, filesFound: 0 };
|
|
271
|
+
|
|
272
|
+
const isLogFile = (f: string): boolean =>
|
|
273
|
+
f === "log.jsonl" || /^log\.[A-Za-z0-9._-]+\.jsonl$/.test(f);
|
|
274
|
+
|
|
275
|
+
let files: string[];
|
|
276
|
+
try {
|
|
277
|
+
files = readdirSync(dir)
|
|
278
|
+
.filter(isLogFile)
|
|
279
|
+
.sort()
|
|
280
|
+
.map((f) => join(dir, f));
|
|
281
|
+
} catch {
|
|
282
|
+
return { rows: [], skipped: 0, filesFound: 0 };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (files.length === 0) return { rows: [], skipped: 0, filesFound: 0 };
|
|
286
|
+
|
|
287
|
+
let skipped = 0;
|
|
288
|
+
const all: MemRow[] = [];
|
|
289
|
+
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
let raw: string;
|
|
292
|
+
try {
|
|
293
|
+
raw = readFileSync(file, "utf-8");
|
|
294
|
+
} catch {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
298
|
+
for (const line of lines) {
|
|
299
|
+
let parsed: RawMemRow;
|
|
300
|
+
try {
|
|
301
|
+
parsed = JSON.parse(line) as RawMemRow;
|
|
302
|
+
} catch {
|
|
303
|
+
skipped++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!parsed.ts || !parsed.kind) {
|
|
307
|
+
skipped++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (sinceIso && parsed.ts < sinceIso) continue;
|
|
311
|
+
all.push({
|
|
312
|
+
ts: parsed.ts,
|
|
313
|
+
agent: parsed.agent ?? "unknown",
|
|
314
|
+
kind: parsed.kind,
|
|
315
|
+
text: parsed.text ?? "",
|
|
316
|
+
spec: parsed.spec,
|
|
317
|
+
id: parsed.id,
|
|
318
|
+
ref: parsed.ref,
|
|
319
|
+
...(Array.isArray(parsed.files)
|
|
320
|
+
? { files: parsed.files.filter((f) => typeof f === "string") }
|
|
321
|
+
: {}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
all.sort((a, b) => b.ts.localeCompare(a.ts)); // newest first
|
|
327
|
+
return { rows: all, skipped, filesFound: files.length };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Decisions for the pre-edit context summary — newest first, capped at `limit`.
|
|
332
|
+
*/
|
|
333
|
+
export function readRecentMemDecisions(
|
|
334
|
+
worktree: string,
|
|
335
|
+
limit: number,
|
|
336
|
+
keywords?: string[],
|
|
337
|
+
): MemRow[] {
|
|
338
|
+
let decisions: MemRow[];
|
|
339
|
+
try {
|
|
340
|
+
decisions = readMemLog(worktree).rows.filter((r) => r.kind === "decision");
|
|
341
|
+
} catch {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
if (decisions.length === 0) return [];
|
|
345
|
+
|
|
346
|
+
const kws = (keywords ?? [])
|
|
347
|
+
.map((k) => k.toLowerCase())
|
|
348
|
+
.filter((k) => k.length > 0);
|
|
349
|
+
if (kws.length > 0) {
|
|
350
|
+
const hits = decisions.filter((r) => {
|
|
351
|
+
const hay = `${r.text}\n${r.spec ?? ""}`.toLowerCase();
|
|
352
|
+
return kws.some((k) => hay.includes(k));
|
|
353
|
+
});
|
|
354
|
+
if (hits.length > 0) return hits.slice(0, limit);
|
|
355
|
+
}
|
|
356
|
+
return decisions.slice(0, limit);
|
|
357
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/parse.ts — grades, scores, and gate-event JSON parsing.
|
|
2
|
+
// Marker-text parsers (parseGateVerdict/parsePlanUpdate) were removed with the
|
|
3
|
+
// CLI loop: nothing produces VERDICT:/NEXT-PROMPT/FILE_DONE stdout anymore —
|
|
4
|
+
// gate events are JSON, read via parseGateEventData.
|
|
5
|
+
|
|
6
|
+
/** Locked grade vocabulary — additive-only (append, never rename/remove). */
|
|
7
|
+
export type VerdictGrade =
|
|
8
|
+
| "pass-excellent"
|
|
9
|
+
| "pass-good"
|
|
10
|
+
| "pass-adequate"
|
|
11
|
+
| "pass"
|
|
12
|
+
| "fail"
|
|
13
|
+
| "uncertain";
|
|
14
|
+
|
|
15
|
+
export const VERDICT_GRADES: ReadonlySet<string> = new Set<VerdictGrade>([
|
|
16
|
+
"pass-excellent",
|
|
17
|
+
"pass-good",
|
|
18
|
+
"pass-adequate",
|
|
19
|
+
"pass",
|
|
20
|
+
"fail",
|
|
21
|
+
"uncertain",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** True when verdict belongs to the pass family (any pass-* variant). */
|
|
25
|
+
export function isPassFamily(v: string): v is VerdictGrade {
|
|
26
|
+
return v.startsWith("pass");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Locked qualityScore mapping — read-time only (never written to events).
|
|
31
|
+
* Values from SPEC-verdict-protocol §qualityScore (additive-only, never change).
|
|
32
|
+
*/
|
|
33
|
+
const SCORE_MAP: Record<VerdictGrade, number> = {
|
|
34
|
+
"pass-excellent": 5,
|
|
35
|
+
"pass-good": 4,
|
|
36
|
+
"pass-adequate": 3,
|
|
37
|
+
pass: 3,
|
|
38
|
+
fail: 0,
|
|
39
|
+
uncertain: 1,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function qualityScore(grade: VerdictGrade): number {
|
|
43
|
+
return SCORE_MAP[grade] ?? 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GateVerdict {
|
|
47
|
+
verdict: VerdictGrade;
|
|
48
|
+
note: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read a verdict back from a stored gate event (`kind='gate'`).
|
|
53
|
+
* Gate events are JSON (`{verdict, note, round}` from gateOnce) — NOT
|
|
54
|
+
* reviewer stdout. Returns null for missing/unparseable data or unknown grades.
|
|
55
|
+
*/
|
|
56
|
+
export function parseGateEventData(data: string | null): GateVerdict | null {
|
|
57
|
+
if (!data) return null;
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(data);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
65
|
+
const { verdict, note } = parsed as { verdict?: unknown; note?: unknown };
|
|
66
|
+
if (typeof verdict !== "string" || !VERDICT_GRADES.has(verdict)) return null;
|
|
67
|
+
return {
|
|
68
|
+
verdict: verdict as VerdictGrade,
|
|
69
|
+
note: typeof note === "string" ? note : "",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// src/price/resolve.ts — normalize model id + compute list-price equivalent
|
|
2
|
+
//
|
|
3
|
+
// Hard rule: an unmappable model must be unpriced, never silently counted as 0 (the main failure mode
|
|
4
|
+
// of this feature) · free only applies to things that truly cost 0 (local / :free rows in the table)
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ModelBreakdown,
|
|
8
|
+
ModelRates,
|
|
9
|
+
PassiveUsageResult,
|
|
10
|
+
PriceTable,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
export type PriceStatus = "priced" | "free" | "unpriced";
|
|
14
|
+
|
|
15
|
+
export interface PriceResolution {
|
|
16
|
+
status: PriceStatus;
|
|
17
|
+
rates: ModelRates | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** client prefix prepended to every model id — strip it, then look up again */
|
|
21
|
+
const CLIENT_PREFIXES = ["openrouter/", "opencode-go/", "opencode/"];
|
|
22
|
+
|
|
23
|
+
/** OpenRouter's trailing tier — :free is a genuinely free endpoint, :batch is a discount */
|
|
24
|
+
function stripTier(id: string): string {
|
|
25
|
+
return id.endsWith(":free") || id.endsWith(":batch")
|
|
26
|
+
? id.slice(0, id.lastIndexOf(":"))
|
|
27
|
+
: id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Candidate ids ordered by specificity: exact first, broad later
|
|
32
|
+
* (an exact match always wins — the bare slug is the broadest match and goes last)
|
|
33
|
+
*/
|
|
34
|
+
export function candidateIds(provider: string, model: string): string[] {
|
|
35
|
+
const full = provider ? `${provider}/${model}` : model;
|
|
36
|
+
const out: string[] = [full];
|
|
37
|
+
let rest = full;
|
|
38
|
+
for (const p of CLIENT_PREFIXES) {
|
|
39
|
+
if (rest.startsWith(p)) {
|
|
40
|
+
rest = rest.slice(p.length);
|
|
41
|
+
out.push(rest);
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// opencode appends -free to the slug of a free model (deepseek-v4-flash-free)
|
|
46
|
+
if (rest.endsWith("-free")) out.push(rest.slice(0, -"-free".length));
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find rates for one model — null only for local, which skips the table
|
|
52
|
+
* (local never had a price to begin with, it is not "not found")
|
|
53
|
+
*/
|
|
54
|
+
function isLocalProvider(provider: string): boolean {
|
|
55
|
+
return provider === "lmstudio_local";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolvePrice(
|
|
59
|
+
provider: string,
|
|
60
|
+
model: string,
|
|
61
|
+
table: PriceTable,
|
|
62
|
+
): PriceResolution {
|
|
63
|
+
if (!model || model === "(no model id)" || model === "(unknown)")
|
|
64
|
+
return { status: "unpriced", rates: null };
|
|
65
|
+
if (isLocalProvider(provider)) return { status: "free", rates: null };
|
|
66
|
+
// ends with :free or -free = used a free endpoint / the client's free model
|
|
67
|
+
// the real price is 0 (not list price), whether or not OpenRouter maps it
|
|
68
|
+
if (model.endsWith(":free") || model.endsWith("-free"))
|
|
69
|
+
return { status: "free", rates: null };
|
|
70
|
+
for (const id of candidateIds(provider, model)) {
|
|
71
|
+
const rates = table.models[id] ?? table.models[stripTier(id)];
|
|
72
|
+
if (rates) {
|
|
73
|
+
// an all-zero rate row (:free / free model) = genuinely free, not unpriced
|
|
74
|
+
if (
|
|
75
|
+
rates.input === 0 &&
|
|
76
|
+
rates.output === 0 &&
|
|
77
|
+
rates.cacheRead === 0 &&
|
|
78
|
+
(rates.cacheWrite ?? 0) === 0
|
|
79
|
+
)
|
|
80
|
+
return { status: "free", rates };
|
|
81
|
+
return { status: "priced", rates };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// zcode stores only the slug with no vendor (GLM-5.3-Flash) — compare the suffix / exact
|
|
85
|
+
// case-insensitive match (exact, not fuzzy: the whole string must be equal in length)
|
|
86
|
+
// the table side strips the tier (:free/:batch) before comparing — so a bare slug hits the :free
|
|
87
|
+
// row that is truly 0 (e.g. ling-3.0-flash-fin) and becomes free, not unpriced
|
|
88
|
+
const slug = stripTier(
|
|
89
|
+
candidateIds(provider, model).at(-1) ?? "",
|
|
90
|
+
).toLowerCase();
|
|
91
|
+
if (slug) {
|
|
92
|
+
const ids = Object.keys(table.models).sort();
|
|
93
|
+
for (const id of ids) {
|
|
94
|
+
const rawSuffix = id.includes("/")
|
|
95
|
+
? id.slice(id.lastIndexOf("/") + 1)
|
|
96
|
+
: id;
|
|
97
|
+
if (stripTier(rawSuffix).toLowerCase() === slug) {
|
|
98
|
+
const rates = table.models[id];
|
|
99
|
+
if (
|
|
100
|
+
rates.input === 0 &&
|
|
101
|
+
rates.output === 0 &&
|
|
102
|
+
rates.cacheRead === 0 &&
|
|
103
|
+
(rates.cacheWrite ?? 0) === 0
|
|
104
|
+
)
|
|
105
|
+
return { status: "free", rates };
|
|
106
|
+
return { status: "priced", rates };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { status: "unpriced", rates: null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface TokenCounts {
|
|
114
|
+
input: number;
|
|
115
|
+
cacheRead: number;
|
|
116
|
+
cacheWrite: number;
|
|
117
|
+
output: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pure costing: separate rates for input / cache-read / cache-write — never one rate for all
|
|
122
|
+
*
|
|
123
|
+
* reasoning is not multiplied separately: Anthropic-family thinking is already included in output
|
|
124
|
+
* (the reader keeps it separately just for visibility); multiplying separately = double counting · cache_write absent from the table
|
|
125
|
+
* → fall back to the input rate (writing cache costs more than or equal to a fresh read, never less)
|
|
126
|
+
*/
|
|
127
|
+
export function calcCost(t: TokenCounts, rates: ModelRates): number {
|
|
128
|
+
return (
|
|
129
|
+
t.input * rates.input +
|
|
130
|
+
t.cacheRead * rates.cacheRead +
|
|
131
|
+
t.cacheWrite * (rates.cacheWrite ?? rates.input) +
|
|
132
|
+
t.output * rates.output
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ImputedModel {
|
|
137
|
+
provider: string;
|
|
138
|
+
model: string;
|
|
139
|
+
session_count: number;
|
|
140
|
+
tokens_input: number;
|
|
141
|
+
tokens_output: number;
|
|
142
|
+
tokens_cache_read: number;
|
|
143
|
+
tokens_cache_write: number;
|
|
144
|
+
status: PriceStatus;
|
|
145
|
+
/** dollars at list-price — 0 when free/unpriced (check status, do not read the number alone) */
|
|
146
|
+
imputed_cost: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ImputeSummary {
|
|
150
|
+
total_imputed: number;
|
|
151
|
+
priced_sessions: number;
|
|
152
|
+
free_sessions: number;
|
|
153
|
+
unpriced_sessions: number;
|
|
154
|
+
unpriced_tokens: number;
|
|
155
|
+
by_model: ImputedModel[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Price an entire PassiveUsageResult — works on live results (stats/usage) or cache rows
|
|
160
|
+
* (usage-web) alike, because it only takes tokens per model
|
|
161
|
+
*/
|
|
162
|
+
export function imputeResult(
|
|
163
|
+
result: PassiveUsageResult,
|
|
164
|
+
table: PriceTable,
|
|
165
|
+
): ImputeSummary {
|
|
166
|
+
const by_model: ImputedModel[] = [];
|
|
167
|
+
let total_imputed = 0;
|
|
168
|
+
let priced_sessions = 0;
|
|
169
|
+
let free_sessions = 0;
|
|
170
|
+
let unpriced_sessions = 0;
|
|
171
|
+
let unpriced_tokens = 0;
|
|
172
|
+
for (const m of result.by_model as ModelBreakdown[]) {
|
|
173
|
+
const r = resolvePrice(m.provider, m.model, table);
|
|
174
|
+
let cost = 0;
|
|
175
|
+
if (r.status === "priced" && r.rates) {
|
|
176
|
+
cost = calcCost(
|
|
177
|
+
{
|
|
178
|
+
input: m.tokens_input,
|
|
179
|
+
cacheRead: m.tokens_cache_read,
|
|
180
|
+
cacheWrite: m.tokens_cache_write,
|
|
181
|
+
output: m.tokens_output,
|
|
182
|
+
},
|
|
183
|
+
r.rates,
|
|
184
|
+
);
|
|
185
|
+
total_imputed += cost;
|
|
186
|
+
priced_sessions += m.session_count;
|
|
187
|
+
} else if (r.status === "free") {
|
|
188
|
+
free_sessions += m.session_count;
|
|
189
|
+
} else {
|
|
190
|
+
unpriced_sessions += m.session_count;
|
|
191
|
+
unpriced_tokens +=
|
|
192
|
+
m.tokens_input +
|
|
193
|
+
m.tokens_output +
|
|
194
|
+
m.tokens_cache_read +
|
|
195
|
+
m.tokens_cache_write;
|
|
196
|
+
}
|
|
197
|
+
by_model.push({
|
|
198
|
+
provider: m.provider,
|
|
199
|
+
model: m.model,
|
|
200
|
+
session_count: m.session_count,
|
|
201
|
+
tokens_input: m.tokens_input,
|
|
202
|
+
tokens_output: m.tokens_output,
|
|
203
|
+
tokens_cache_read: m.tokens_cache_read,
|
|
204
|
+
tokens_cache_write: m.tokens_cache_write,
|
|
205
|
+
status: r.status,
|
|
206
|
+
imputed_cost: cost,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
total_imputed,
|
|
211
|
+
priced_sessions,
|
|
212
|
+
free_sessions,
|
|
213
|
+
unpriced_sessions,
|
|
214
|
+
unpriced_tokens,
|
|
215
|
+
by_model,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/safety.ts — deny-list for dangerous git commands.
|
|
2
|
+
// Rule: assertSafe() must be called on every command before spawn, including
|
|
3
|
+
// ones built from config templates (memory claim/close/add, evidence
|
|
4
|
+
// collector commands, install's `claude mcp add`).
|
|
5
|
+
// Patterns come from config.safety.deny (regex sources); built-in default
|
|
6
|
+
// covers the 4 known-destructive git invocations.
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_SAFETY_DENY } from "./defaults.js";
|
|
9
|
+
|
|
10
|
+
export function assertSafe(argv: string[], denySources?: string[]): void {
|
|
11
|
+
const sources = denySources ?? DEFAULT_SAFETY_DENY;
|
|
12
|
+
const joined = argv.join(" ");
|
|
13
|
+
for (const src of sources) {
|
|
14
|
+
if (new RegExp(src).test(joined)) {
|
|
15
|
+
throw new Error(`refusing to run dangerous command: ${joined}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|