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.
Files changed (96) hide show
  1. package/README.md +87 -65
  2. package/fapony.ts +3 -116
  3. package/package.json +6 -5
  4. package/skill/move-to-done/SKILL.md +22 -32
  5. package/skill/review-pony/SKILL.md +27 -58
  6. package/src/adapters/cli.ts +123 -0
  7. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  8. package/src/adapters/hooks/context-data.ts +102 -0
  9. package/src/adapters/hooks/edit-hint.ts +195 -0
  10. package/src/adapters/hooks/git-autonomy.ts +178 -0
  11. package/src/adapters/hooks/index.ts +79 -0
  12. package/src/adapters/hooks/mv-guard.ts +52 -0
  13. package/src/adapters/hooks/read-hint.ts +383 -0
  14. package/src/adapters/hooks/session-start.ts +101 -0
  15. package/src/adapters/hooks/stop.ts +302 -0
  16. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/primitives.ts +3 -3
  18. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  20. package/src/{mcp → adapters/mcp}/tools/index.ts +0 -74
  21. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  22. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  23. package/src/{mcp → adapters/mcp}/transport.ts +4 -39
  24. package/src/adapters/mcp/types.ts +18 -0
  25. package/src/{mcp → adapters/mcp}/worktree.ts +2 -2
  26. package/src/analyze.ts +2 -2
  27. package/src/conventions-seed.ts +1 -1
  28. package/src/core/config.ts +199 -0
  29. package/src/core/debt-format.ts +107 -0
  30. package/src/core/debt-types.ts +79 -0
  31. package/src/core/defaults.ts +8 -0
  32. package/src/core/enums.ts +34 -0
  33. package/src/core/format.ts +33 -0
  34. package/src/core/hint-log.ts +68 -0
  35. package/src/core/hook-helpers.ts +31 -0
  36. package/src/core/mem-log.ts +357 -0
  37. package/src/core/parse.ts +71 -0
  38. package/src/core/pricing.ts +217 -0
  39. package/src/core/safety.ts +18 -0
  40. package/src/core/types.ts +142 -0
  41. package/src/core/util.ts +105 -0
  42. package/src/db/store.ts +2 -2
  43. package/src/debt/cli.ts +1 -1
  44. package/src/debt/format.ts +2 -107
  45. package/src/debt/load.ts +1 -1
  46. package/src/debt/promotion.ts +1 -1
  47. package/src/debt/types.ts +14 -79
  48. package/src/digest/collect.ts +4 -3
  49. package/src/gate.ts +5 -5
  50. package/src/gates.ts +1 -1
  51. package/src/hook.ts +69 -1337
  52. package/src/init-mem.ts +58 -72
  53. package/src/init.ts +13 -17
  54. package/src/install/antigravity.ts +112 -0
  55. package/src/install/claude.ts +19 -123
  56. package/src/install/detect.ts +17 -7
  57. package/src/install/opencode.ts +167 -26
  58. package/src/install.ts +23 -7
  59. package/src/lint-baseline.ts +1 -2
  60. package/src/map.ts +29 -8
  61. package/src/mem/commands/plan.ts +70 -42
  62. package/src/mem/commands/read.ts +219 -146
  63. package/src/mem/index.ts +4 -13
  64. package/src/mem/store.ts +5 -1
  65. package/src/memory.ts +24 -387
  66. package/src/parse.ts +9 -71
  67. package/src/price/fetch.ts +4 -16
  68. package/src/price/resolve.ts +12 -213
  69. package/src/report/cli.ts +3 -3
  70. package/src/safety.ts +2 -18
  71. package/src/{plan-seed.ts → seed/plan-seed.ts} +18 -32
  72. package/src/seed/primitives.ts +60 -0
  73. package/src/{review-seed.ts → seed/review-seed.ts} +7 -54
  74. package/src/session/types.ts +14 -128
  75. package/src/setup.ts +1 -1
  76. package/src/stats/data.ts +4 -4
  77. package/src/telemetry.ts +3 -3
  78. package/src/usage/cache.ts +1 -2
  79. package/src/usage/cli.ts +1 -1
  80. package/src/usage/scan.ts +2 -1
  81. package/src/util.ts +10 -32
  82. package/src/web/html.ts +2 -33
  83. package/templates/SPEC.md +8 -1
  84. package/images/logo.png +0 -0
  85. package/images/logo.webp +0 -0
  86. package/images/logo@400.webp +0 -0
  87. package/images/sample.webp +0 -0
  88. package/images/summary.webp +0 -0
  89. package/src/db/defaults.ts +0 -34
  90. package/src/db/getters.ts +0 -35
  91. package/src/db/index.ts +0 -7
  92. package/src/db/load.ts +0 -57
  93. package/src/db/types.ts +0 -77
  94. package/src/math.ts +0 -13
  95. package/src/mcp/tools/verdict.ts +0 -161
  96. 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
+ }