tokenclinic 0.1.1

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.
@@ -0,0 +1,44 @@
1
+ import { mkdirSync, writeFileSync, appendFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Finding, EOB } from "../types";
4
+ import type { DepProfile } from "../detect/deps";
5
+
6
+ // The Codebase Health Record: a .tokenclinic/ directory written into the scanned
7
+ // repo. v1 persists the dep profile and an append-only run history. v2 adds
8
+ // rules/ (promoted generated checks + fixtures), quarantine/, and routing.json
9
+ // (learned per-class model routing). Every run reads it back, so every run gets
10
+ // cheaper and smarter — this is the compounding asset, not the router.
11
+
12
+ export function writeHealthRecord(
13
+ root: string,
14
+ deps: DepProfile,
15
+ findings: Finding[],
16
+ eob: EOB,
17
+ now: string,
18
+ ): string {
19
+ const dir = join(root, ".tokenclinic");
20
+ mkdirSync(dir, { recursive: true });
21
+
22
+ writeFileSync(
23
+ join(dir, "profile.json"),
24
+ JSON.stringify(
25
+ { updated: now, manager: deps.manager, deps: deps.deps, analyzers: ["tsc"] },
26
+ null,
27
+ 2,
28
+ ),
29
+ );
30
+
31
+ const entry = {
32
+ at: now,
33
+ findings: findings.length,
34
+ fixedLocally: eob.fixedLocally,
35
+ escalated: eob.escalated,
36
+ spend: eob.spend,
37
+ naiveCost: eob.naiveCost,
38
+ saved: eob.saved,
39
+ estimated: eob.estimated,
40
+ };
41
+ appendFileSync(join(dir, "history.jsonl"), `${JSON.stringify(entry)}\n`);
42
+
43
+ return dir;
44
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { Finding, EOB } from "./types";
2
+ import { detectDeps, type DepProfile } from "./detect/deps";
3
+ import { triage } from "./triage";
4
+ import { partition } from "./diagnose/partition";
5
+ import { buildContext } from "./diagnose/context";
6
+ import { DryRunFixer } from "./treat/fixer";
7
+ import { buildEOB } from "./bill/eob";
8
+
9
+ // The read-only scan, assembled once and rendered two ways: a human EOB (CLI) or
10
+ // a machine report (--json, for a host agent). No model is called here — this is
11
+ // the advisory core that runs identically standalone or inside a harness.
12
+
13
+ export interface ScanData {
14
+ deps: DepProfile;
15
+ findings: Finding[];
16
+ eob: EOB;
17
+ }
18
+
19
+ export async function assembleScan(root: string): Promise<ScanData> {
20
+ const deps = detectDeps(root);
21
+ const findings = partition(triage(root));
22
+
23
+ const fixer = new DryRunFixer();
24
+ for (const f of findings) {
25
+ if (f.fixability !== "needs-llm") continue;
26
+ f.context = buildContext(root, f); // the tight packet the host agent fixes from
27
+ f.resolution = (await fixer.fix(f)).resolution; // routed model = the recommendation
28
+ }
29
+
30
+ return { deps, findings, eob: buildEOB(root, findings, true) };
31
+ }
32
+
33
+ // --- machine report (the in-harness integration contract) ---
34
+
35
+ type Lane = "local" | "model" | "ignore";
36
+
37
+ export interface ScanReport {
38
+ version: string;
39
+ root: string;
40
+ prices: string;
41
+ deps: { manager: string; count: number };
42
+ eob: EOB;
43
+ findings: Array<{
44
+ id: string;
45
+ source: string;
46
+ rule: string;
47
+ severity: string;
48
+ message: string;
49
+ file: string;
50
+ line: number;
51
+ col: number;
52
+ lane: Lane;
53
+ difficulty?: string;
54
+ recommendedModel?: string;
55
+ context?: { snippet: string; startLine: number };
56
+ }>;
57
+ // What a host agent should do: apply the local lane cheaply; fix the escalate
58
+ // list with its OWN model, using each finding's context — don't crawl the repo.
59
+ advice: {
60
+ autoApply: string[];
61
+ escalate: Array<{ id: string; file: string; line: number; recommendedModel?: string }>;
62
+ };
63
+ }
64
+
65
+ const laneOf = (f: Finding): Lane => (f.fixability === "autofix" ? "local" : f.fixability === "ignore" ? "ignore" : "model");
66
+
67
+ export function toReport(root: string, prices: string, { deps, findings, eob }: ScanData): ScanReport {
68
+ return {
69
+ version: "0.1",
70
+ root,
71
+ prices,
72
+ deps: { manager: deps.manager, count: Object.keys(deps.deps).length },
73
+ eob,
74
+ findings: findings.map((f) => ({
75
+ id: f.id,
76
+ source: f.source,
77
+ rule: f.rule,
78
+ severity: f.severity,
79
+ message: f.message,
80
+ file: f.file,
81
+ line: f.line,
82
+ col: f.col,
83
+ lane: laneOf(f),
84
+ difficulty: f.difficulty,
85
+ recommendedModel: f.resolution?.model,
86
+ context: f.context ? { snippet: f.context.snippet, startLine: f.context.startLine } : undefined,
87
+ })),
88
+ advice: {
89
+ autoApply: findings.filter((f) => f.fixability === "autofix").map((f) => f.id),
90
+ escalate: findings
91
+ .filter((f) => f.fixability === "needs-llm")
92
+ .map((f) => ({ id: f.id, file: f.file, line: f.line, recommendedModel: f.resolution?.model })),
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,85 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type { Finding } from "../types";
3
+ import { cost } from "../pricing/table";
4
+ import { routeModel } from "./route";
5
+ import type { Fixer, FixResult } from "./fixer";
6
+
7
+ // Live fixer: sends the tight context packet to the routed model, gets back a
8
+ // corrected snippet via structured output, and reports the EXACT token cost from
9
+ // the API's usage. Used by `scan --apply`. The apply loop (in cli.ts) writes the
10
+ // patch and verifies by re-running the source analyzer — a fix is not done until
11
+ // the finding is gone.
12
+ //
13
+ // Reads ANTHROPIC_API_KEY from the environment (standard SDK credential resolution).
14
+
15
+ const SYSTEM =
16
+ "You are a precise code-fixing assistant. You are given a single compiler/linter " +
17
+ "error and the source lines around it. Return a corrected version of EXACTLY those " +
18
+ "lines that resolves the error, changing as little as possible. Preserve indentation " +
19
+ "and surrounding lines verbatim; only change what the error requires.";
20
+
21
+ // Structured-output schema — constrains the model to return the replacement
22
+ // snippet as data, so the apply step never parses prose.
23
+ const SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ fixedSnippet: { type: "string", description: "the corrected replacement for the provided lines" },
27
+ explanation: { type: "string", description: "one sentence on what changed" },
28
+ },
29
+ required: ["fixedSnippet", "explanation"],
30
+ additionalProperties: false,
31
+ };
32
+
33
+ export class AnthropicFixer implements Fixer {
34
+ private client: Anthropic;
35
+
36
+ constructor() {
37
+ this.client = new Anthropic();
38
+ }
39
+
40
+ async fix(finding: Finding): Promise<FixResult> {
41
+ const model = routeModel(finding.difficulty);
42
+ const snippet = finding.context?.snippet ?? "";
43
+ const startLine = finding.context?.startLine ?? finding.line;
44
+
45
+ const user =
46
+ `Error ${finding.rule} at ${finding.file}:${finding.line} — ${finding.message}\n\n` +
47
+ `The lines below start at line ${startLine}. Return a corrected replacement for them.\n` +
48
+ "```\n" +
49
+ snippet +
50
+ "\n```";
51
+
52
+ const res = await this.client.messages.create({
53
+ model,
54
+ max_tokens: 2000,
55
+ system: SYSTEM,
56
+ messages: [{ role: "user", content: user }],
57
+ output_config: { format: { type: "json_schema", schema: SCHEMA } },
58
+ });
59
+
60
+ const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
61
+ let fixedSnippet = snippet;
62
+ try {
63
+ const parsed = JSON.parse(text) as { fixedSnippet?: string };
64
+ if (typeof parsed.fixedSnippet === "string") fixedSnippet = parsed.fixedSnippet;
65
+ } catch {
66
+ // model returned non-JSON despite the schema — leave the snippet unchanged
67
+ }
68
+
69
+ const tokensIn = res.usage.input_tokens;
70
+ const tokensOut = res.usage.output_tokens;
71
+
72
+ return {
73
+ resolution: {
74
+ model,
75
+ tokensIn,
76
+ tokensOut,
77
+ cost: cost(model, tokensIn, tokensOut), // exact, from real usage
78
+ patched: false,
79
+ verified: false,
80
+ patch: fixedSnippet,
81
+ },
82
+ newSnippet: fixedSnippet,
83
+ };
84
+ }
85
+ }
@@ -0,0 +1,74 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Finding } from "../types";
4
+ import type { Fixer } from "./fixer";
5
+ import { triage } from "../triage";
6
+ import { partition } from "../diagnose/partition";
7
+ import { buildContext } from "../diagnose/context";
8
+
9
+ // The live apply loop, decoupled from the CLI and the concrete fixer so it can be
10
+ // driven by a fake fixer in tests (no API key required).
11
+ //
12
+ // Each pass re-triages from scratch, so line shifts introduced by earlier edits
13
+ // are handled automatically. Hardening:
14
+ // - a finding is attempted at most once (by id) → bounded by maxPasses
15
+ // - a no-op patch (unchanged snippet) is skipped, never written
16
+ // - a patch that increases the total finding count is reverted (don't let a
17
+ // "fix" leave the repo worse than it found it)
18
+ // - verified = the finding is gone on the next triage
19
+
20
+ export interface ApplyResult {
21
+ before: Finding[]; // initial snapshot, for the EOB's autofix/ignore buckets
22
+ fixed: Finding[]; // findings the loop escalated, each carrying its resolution
23
+ }
24
+
25
+ export async function runApplyLoop(root: string, fixer: Fixer, maxPasses = 25): Promise<ApplyResult> {
26
+ const before = partition(triage(root));
27
+ const attempted = new Set<string>();
28
+ const fixed: Finding[] = [];
29
+
30
+ for (let pass = 0; pass < maxPasses; pass++) {
31
+ const current = partition(triage(root));
32
+ const totalBefore = current.length;
33
+ const target = current.find((f) => f.fixability === "needs-llm" && !attempted.has(f.id));
34
+ if (!target) break;
35
+ attempted.add(target.id);
36
+
37
+ target.context = buildContext(root, target);
38
+ const { resolution, newSnippet } = await fixer.fix(target);
39
+ target.resolution = resolution;
40
+ fixed.push(target);
41
+
42
+ // No-op patch — nothing to write.
43
+ if (newSnippet === undefined || newSnippet === target.context?.snippet) {
44
+ resolution.patched = false;
45
+ resolution.verified = false;
46
+ continue;
47
+ }
48
+
49
+ const path = join(root, target.file);
50
+ const original = readFileSync(path, "utf8");
51
+ writeSnippet(path, target, newSnippet);
52
+
53
+ const after = partition(triage(root));
54
+ if (after.length > totalBefore) {
55
+ // The patch introduced more problems than it solved — revert.
56
+ writeFileSync(path, original);
57
+ resolution.patched = false;
58
+ resolution.verified = false;
59
+ } else {
60
+ resolution.patched = true;
61
+ resolution.verified = !after.some((f) => f.id === target.id);
62
+ }
63
+ }
64
+
65
+ return { before, fixed };
66
+ }
67
+
68
+ function writeSnippet(path: string, f: Finding, newSnippet: string) {
69
+ const lines = readFileSync(path, "utf8").split("\n");
70
+ const start = (f.context?.startLine ?? f.line) - 1;
71
+ const oldCount = (f.context?.snippet ?? "").split("\n").length;
72
+ lines.splice(start, oldCount, ...newSnippet.split("\n"));
73
+ writeFileSync(path, lines.join("\n"));
74
+ }
@@ -0,0 +1,38 @@
1
+ import type { Finding, Resolution } from "../types";
2
+ import { cost, estimateTokens } from "../pricing/table";
3
+ import { routeModel } from "./route";
4
+
5
+ // The seam where a fix is actually performed. Treat routes each escalated
6
+ // finding through a Fixer.
7
+ export interface FixResult {
8
+ resolution: Resolution;
9
+ newSnippet?: string; // present only when the fix can be applied to the file
10
+ }
11
+
12
+ export interface Fixer {
13
+ fix(finding: Finding): Promise<FixResult>;
14
+ }
15
+
16
+ // Estimating fixer: computes the cost of an escalation from real measured token
17
+ // counts of the real context packet, but does NOT call a model or mutate files.
18
+ // Used by `scan` (the default, read-only path). Every number it produces is
19
+ // honest about being an estimate; the EOB is flagged `estimated`.
20
+ const ASSUMED_OUTPUT_TOKENS = 200; // a typical small patch
21
+
22
+ export class DryRunFixer implements Fixer {
23
+ async fix(finding: Finding): Promise<FixResult> {
24
+ const model = routeModel(finding.difficulty);
25
+ const tokensIn = (finding.context?.tokensEstimate ?? 0) + estimateTokens(finding.message);
26
+ const tokensOut = ASSUMED_OUTPUT_TOKENS;
27
+ return {
28
+ resolution: {
29
+ model,
30
+ tokensIn,
31
+ tokensOut,
32
+ cost: cost(model, tokensIn, tokensOut),
33
+ patched: false,
34
+ verified: false,
35
+ },
36
+ };
37
+ }
38
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { DifficultyClass } from "../types";
4
+
5
+ // Model routing: map a fix's difficulty class to the cheapest model that clears
6
+ // the bar. Declarative and provider-agnostic — the defaults are Anthropic, but a
7
+ // repo can override per class with ANY model id (including OpenRouter-style
8
+ // `openai/…` / `google/…`) via .tokenclinic/routing.json. Pricing resolves
9
+ // whatever id is configured through llm-intel, so other providers work for the
10
+ // cost/EOB/audit layers without touching this file.
11
+
12
+ const DEFAULT: Record<DifficultyClass, string> = {
13
+ mechanical: "claude-haiku-4-5",
14
+ semantic: "claude-sonnet-4-6",
15
+ architectural: "claude-opus-4-8",
16
+ };
17
+
18
+ let routing: Record<DifficultyClass, string> = { ...DEFAULT };
19
+
20
+ export function loadRouting(root: string): void {
21
+ routing = { ...DEFAULT };
22
+ const path = join(root, ".tokenclinic", "routing.json");
23
+ if (!existsSync(path)) return;
24
+ try {
25
+ const cfg = JSON.parse(readFileSync(path, "utf8")) as Partial<Record<DifficultyClass, string>>;
26
+ routing = { ...DEFAULT, ...cfg };
27
+ } catch {
28
+ /* malformed config — keep defaults */
29
+ }
30
+ }
31
+
32
+ export function routeModel(difficulty: DifficultyClass = "semantic"): string {
33
+ return routing[difficulty] ?? DEFAULT[difficulty];
34
+ }
35
+
36
+ // The distinct model ids routing can target — used to warm the price book.
37
+ export function routedModels(): string[] {
38
+ return [...new Set(Object.values(routing))];
39
+ }
@@ -0,0 +1,74 @@
1
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import type { Finding, GeneratedRule } from "../../types";
4
+ import { hash } from "../../util";
5
+ import { runRule } from "../../amortize/sg";
6
+
7
+ // The payoff side of amortization: load the repo's promoted rules and run them
8
+ // on-device. Every match is a $0 finding the model never has to see again.
9
+
10
+ const EXT_BY_LANG: Record<string, string[]> = {
11
+ TypeScript: [".ts"],
12
+ Tsx: [".tsx"],
13
+ JavaScript: [".js", ".mjs", ".cjs"],
14
+ };
15
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".tokenclinic", "dist", "build"]);
16
+
17
+ export function runAstGrep(root: string): Finding[] {
18
+ const rulesDir = join(root, ".tokenclinic", "rules");
19
+ if (!existsSync(rulesDir)) return [];
20
+
21
+ const rules: GeneratedRule[] = [];
22
+ for (const f of readdirSync(rulesDir)) {
23
+ if (!f.endsWith(".json")) continue;
24
+ try {
25
+ rules.push(JSON.parse(readFileSync(join(rulesDir, f), "utf8")) as GeneratedRule);
26
+ } catch {
27
+ /* skip a corrupt rule file */
28
+ }
29
+ }
30
+ if (rules.length === 0) return [];
31
+
32
+ const wantedExts = new Set(rules.flatMap((r) => EXT_BY_LANG[r.language] ?? []));
33
+ const findings: Finding[] = [];
34
+
35
+ for (const file of walk(root)) {
36
+ if (![...wantedExts].some((e) => file.endsWith(e))) continue;
37
+ const code = readFileSync(file, "utf8");
38
+ const rel = relative(root, file);
39
+
40
+ for (const rule of rules) {
41
+ if (!(EXT_BY_LANG[rule.language] ?? []).some((e) => file.endsWith(e))) continue;
42
+ let matches;
43
+ try {
44
+ matches = runRule(rule.language, code, rule.rule);
45
+ } catch {
46
+ continue; // a promoted rule that somehow no longer parses — skip, don't crash a scan
47
+ }
48
+ for (const m of matches) {
49
+ findings.push({
50
+ id: hash("ast-grep", rule.id, rel, m.line, m.col),
51
+ source: `ast-grep:${rule.id}`,
52
+ rule: rule.id,
53
+ severity: rule.severity,
54
+ message: rule.message,
55
+ file: rel,
56
+ line: m.line,
57
+ col: m.col,
58
+ fixability: "autofix", // a promoted rule is, by definition, a $0 on-device check
59
+ });
60
+ }
61
+ }
62
+ }
63
+
64
+ return findings;
65
+ }
66
+
67
+ function* walk(dir: string): Generator<string> {
68
+ for (const entry of readdirSync(dir)) {
69
+ if (SKIP_DIRS.has(entry)) continue;
70
+ const p = join(dir, entry);
71
+ if (statSync(p).isDirectory()) yield* walk(p);
72
+ else yield p;
73
+ }
74
+ }
@@ -0,0 +1,49 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import type { Finding } from "../../types";
4
+ import { hash } from "../../util";
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ // tsc --pretty false emits one diagnostic per line:
9
+ // src/index.ts(3,7): error TS2322: Type 'number' is not assignable to type 'string'.
10
+ const LINE_RE = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$/;
11
+
12
+ // Run the project's type checker and normalize its output into Findings.
13
+ // We resolve tsc from tokenclinic's own node_modules and point it at the target
14
+ // via cwd, so the scanned repo doesn't need typescript installed itself.
15
+ export function runTsc(root: string): Finding[] {
16
+ let tscBin: string;
17
+ try {
18
+ tscBin = require.resolve("typescript/bin/tsc");
19
+ } catch {
20
+ return []; // typescript not installed in tokenclinic — skip this analyzer
21
+ }
22
+
23
+ const res = spawnSync(process.execPath, [tscBin, "--noEmit", "--pretty", "false"], {
24
+ cwd: root,
25
+ encoding: "utf8",
26
+ });
27
+
28
+ const out = `${res.stdout ?? ""}${res.stderr ?? ""}`;
29
+ const findings: Finding[] = [];
30
+
31
+ for (const raw of out.split("\n")) {
32
+ const m = LINE_RE.exec(raw.trim());
33
+ if (!m) continue; // continuation/elaboration lines are skipped in v1
34
+ const [, file, line, col, sev, rule, message] = m;
35
+ findings.push({
36
+ id: hash("tsc", rule, file, line, col),
37
+ source: "tsc",
38
+ rule,
39
+ severity: sev === "error" ? "error" : "warning",
40
+ message,
41
+ file,
42
+ line: Number(line),
43
+ col: Number(col),
44
+ fixability: "needs-llm", // refined in Diagnose/partition
45
+ });
46
+ }
47
+
48
+ return findings;
49
+ }
@@ -0,0 +1,20 @@
1
+ import type { Finding } from "../types";
2
+ import { runTsc } from "./analyzers/tsc";
3
+ import { runAstGrep } from "./analyzers/astgrep";
4
+
5
+ // Triage: run every applicable on-device analyzer, normalize their output into a
6
+ // single Finding[], and rank by signal (errors before warnings).
7
+ //
8
+ // Each analyzer is a (root) => Finding[] function. runTsc is the native type
9
+ // checker; runAstGrep runs the repo's promoted (amortized) rules on-device — the
10
+ // $0 lane that grows as recurring classes get synthesized into local checks.
11
+ const ANALYZERS: Array<(root: string) => Finding[]> = [runTsc, runAstGrep];
12
+
13
+ const SEVERITY_RANK: Record<string, number> = { error: 0, warning: 1, info: 2 };
14
+
15
+ export function triage(root: string): Finding[] {
16
+ const findings = ANALYZERS.flatMap((run) => run(root));
17
+ return findings.sort(
18
+ (a, b) => (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9),
19
+ );
20
+ }
package/src/types.ts ADDED
@@ -0,0 +1,114 @@
1
+ // The single record that flows through every stage of the clinic loop.
2
+ // Triage creates Findings; Diagnose enriches them; Treat resolves them; Bill sums them.
3
+
4
+ export type Severity = "error" | "warning" | "info";
5
+
6
+ // How a finding can be resolved:
7
+ // autofix - deterministically fixable on-device, $0, no model
8
+ // needs-llm - requires a model; escalated with a tight context packet
9
+ // ignore - informational, not worth a fix
10
+ export type Fixability = "autofix" | "needs-llm" | "ignore";
11
+
12
+ // Difficulty class -> drives model routing in Treat.
13
+ export type DifficultyClass = "mechanical" | "semantic" | "architectural";
14
+
15
+ export interface Finding {
16
+ id: string; // stable hash of (source, rule, file, line) so it dedupes across runs
17
+ source: string; // "tsc" | "eslint" | "ast-grep:<rule>" | ...
18
+ rule: string; // e.g. "TS2322"
19
+ severity: Severity;
20
+ message: string;
21
+ file: string; // relative to scan root
22
+ line: number;
23
+ col: number;
24
+ fixability: Fixability;
25
+ difficulty?: DifficultyClass;
26
+ context?: ContextPacket; // assembled only when escalated
27
+ resolution?: Resolution; // filled by Treat
28
+ }
29
+
30
+ // The tight, pre-assembled packet handed to the model instead of the whole repo.
31
+ export interface ContextPacket {
32
+ snippet: string;
33
+ startLine: number;
34
+ tokensEstimate: number;
35
+ }
36
+
37
+ export interface Resolution {
38
+ model: string; // "local" | model id
39
+ tokensIn: number;
40
+ tokensOut: number;
41
+ cost: number; // USD
42
+ patched: boolean;
43
+ verified: boolean; // a fix is not "done" until its source check passes again
44
+ patch?: string; // the corrected snippet that replaces the finding's context lines
45
+ }
46
+
47
+ // --- Audit (Approach A: the retroactive audit over existing call logs) ---
48
+
49
+ // Two-bucket economics (Premise 5):
50
+ // eliminable - reducible to a deterministic rule; could have cost $0 on-device
51
+ // routable - real work, but resolvable on a cheaper model (not eliminable)
52
+ // essential - genuine reasoning; left untouched
53
+ export type Bucket = "eliminable" | "routable" | "essential";
54
+
55
+ // One past LLM call, read from a team's logs / agent traces. `category` is the
56
+ // instrumented/concierge signal — when present it's authoritative; otherwise the
57
+ // call is bucketed heuristically from `task` text (and the result flagged estimated).
58
+ export interface CallRecord {
59
+ model: string;
60
+ inputTokens: number;
61
+ outputTokens: number;
62
+ task?: string;
63
+ category?: string;
64
+ }
65
+
66
+ export interface AuditResult {
67
+ calls: number;
68
+ spend: number; // real USD, from tokens × price
69
+ byBucket: Record<Bucket, { count: number; spend: number }>;
70
+ eliminableFraction: number; // share of spend in the eliminable bucket — the bet
71
+ projectedSpend: number; // eliminable→$0, routable→re-priced to cheapest tier, essential→unchanged
72
+ projectedSaved: number;
73
+ estimated: boolean; // true if any call was bucketed heuristically (no category)
74
+ unpriced: number; // calls whose model had no known price (excluded from cost)
75
+ }
76
+
77
+ // --- Amortization (v2): synthesize a deterministic check from a recurring class ---
78
+
79
+ // A generated check, stored as DATA not code: an ast-grep rule object plus the
80
+ // fixtures that gate its promotion. Lives in .tokenclinic/rules/<id>.json once it
81
+ // passes validation, or .tokenclinic/quarantine/ if it doesn't.
82
+ export interface GeneratedRule {
83
+ id: string; // kebab-case; also the filename
84
+ language: string; // an ast-grep Lang key, e.g. "TypeScript"
85
+ message: string;
86
+ severity: Severity;
87
+ rule: Record<string, unknown>; // the ast-grep rule object, e.g. { pattern: "..." }
88
+ fix?: string; // optional ast-grep fix template (informational in v2)
89
+ origin?: string; // the analyzer rule this was amortized from, e.g. "TS2304"
90
+ fixtures: {
91
+ positive: string[]; // code the rule MUST flag
92
+ negative: string[]; // similar code the rule must NOT flag
93
+ };
94
+ }
95
+
96
+ // A group of recurring needs-llm findings of the same shape — the trigger for synthesis.
97
+ export interface Cluster {
98
+ rule: string; // the source rule code shared by the group
99
+ message: string;
100
+ findings: Finding[];
101
+ }
102
+
103
+ // Explanation Of Benefits — the screenshot-able receipt.
104
+ export interface EOB {
105
+ total: number;
106
+ fixedLocally: number;
107
+ escalated: number;
108
+ ignored: number;
109
+ spend: number; // USD actually (or, in v1, would-be) spent by the clinic loop
110
+ naiveCost: number; // USD estimate for the naive "dump the file at a top model" approach
111
+ saved: number; // naiveCost - spend
112
+ byModel: Record<string, { count: number; cost: number }>;
113
+ estimated: boolean; // true while the LLM step is simulated (no key wired)
114
+ }
package/src/util.ts ADDED
Binary file