tickmarkr 1.30.0

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 (123) hide show
  1. package/README.md +347 -0
  2. package/dist/adapters/claude-code.d.ts +4 -0
  3. package/dist/adapters/claude-code.js +191 -0
  4. package/dist/adapters/codex.d.ts +8 -0
  5. package/dist/adapters/codex.js +257 -0
  6. package/dist/adapters/cursor-agent.d.ts +4 -0
  7. package/dist/adapters/cursor-agent.js +62 -0
  8. package/dist/adapters/fake.d.ts +45 -0
  9. package/dist/adapters/fake.js +115 -0
  10. package/dist/adapters/grok.d.ts +4 -0
  11. package/dist/adapters/grok.js +122 -0
  12. package/dist/adapters/model-lints.d.ts +6 -0
  13. package/dist/adapters/model-lints.js +130 -0
  14. package/dist/adapters/opencode.d.ts +3 -0
  15. package/dist/adapters/opencode.js +108 -0
  16. package/dist/adapters/pi.d.ts +3 -0
  17. package/dist/adapters/pi.js +191 -0
  18. package/dist/adapters/prompt.d.ts +6 -0
  19. package/dist/adapters/prompt.js +84 -0
  20. package/dist/adapters/registry.d.ts +31 -0
  21. package/dist/adapters/registry.js +165 -0
  22. package/dist/adapters/types.d.ts +98 -0
  23. package/dist/adapters/types.js +56 -0
  24. package/dist/cli/commands/approve.d.ts +1 -0
  25. package/dist/cli/commands/approve.js +72 -0
  26. package/dist/cli/commands/compile.d.ts +1 -0
  27. package/dist/cli/commands/compile.js +23 -0
  28. package/dist/cli/commands/doctor.d.ts +2 -0
  29. package/dist/cli/commands/doctor.js +103 -0
  30. package/dist/cli/commands/init.d.ts +1 -0
  31. package/dist/cli/commands/init.js +32 -0
  32. package/dist/cli/commands/plan.d.ts +2 -0
  33. package/dist/cli/commands/plan.js +101 -0
  34. package/dist/cli/commands/profile.d.ts +1 -0
  35. package/dist/cli/commands/profile.js +56 -0
  36. package/dist/cli/commands/report.d.ts +4 -0
  37. package/dist/cli/commands/report.js +306 -0
  38. package/dist/cli/commands/resume.d.ts +1 -0
  39. package/dist/cli/commands/resume.js +11 -0
  40. package/dist/cli/commands/run.d.ts +1 -0
  41. package/dist/cli/commands/run.js +35 -0
  42. package/dist/cli/commands/scope.d.ts +3 -0
  43. package/dist/cli/commands/scope.js +22 -0
  44. package/dist/cli/commands/status.d.ts +9 -0
  45. package/dist/cli/commands/status.js +162 -0
  46. package/dist/cli/commands/unlock.d.ts +1 -0
  47. package/dist/cli/commands/unlock.js +11 -0
  48. package/dist/cli/index.d.ts +8 -0
  49. package/dist/cli/index.js +65 -0
  50. package/dist/compile/collateral.d.ts +6 -0
  51. package/dist/compile/collateral.js +128 -0
  52. package/dist/compile/common.d.ts +12 -0
  53. package/dist/compile/common.js +56 -0
  54. package/dist/compile/gsd.d.ts +3 -0
  55. package/dist/compile/gsd.js +222 -0
  56. package/dist/compile/index.d.ts +4 -0
  57. package/dist/compile/index.js +33 -0
  58. package/dist/compile/native.d.ts +4 -0
  59. package/dist/compile/native.js +196 -0
  60. package/dist/compile/prd.d.ts +2 -0
  61. package/dist/compile/prd.js +64 -0
  62. package/dist/compile/speckit.d.ts +2 -0
  63. package/dist/compile/speckit.js +66 -0
  64. package/dist/config/config.d.ts +184 -0
  65. package/dist/config/config.js +329 -0
  66. package/dist/drivers/herdr.d.ts +44 -0
  67. package/dist/drivers/herdr.js +394 -0
  68. package/dist/drivers/index.d.ts +3 -0
  69. package/dist/drivers/index.js +13 -0
  70. package/dist/drivers/subprocess.d.ts +25 -0
  71. package/dist/drivers/subprocess.js +106 -0
  72. package/dist/drivers/types.d.ts +61 -0
  73. package/dist/drivers/types.js +77 -0
  74. package/dist/gates/acceptance.d.ts +19 -0
  75. package/dist/gates/acceptance.js +92 -0
  76. package/dist/gates/baseline.d.ts +12 -0
  77. package/dist/gates/baseline.js +97 -0
  78. package/dist/gates/evidence.d.ts +4 -0
  79. package/dist/gates/evidence.js +12 -0
  80. package/dist/gates/llm.d.ts +25 -0
  81. package/dist/gates/llm.js +101 -0
  82. package/dist/gates/review.d.ts +12 -0
  83. package/dist/gates/review.js +80 -0
  84. package/dist/gates/run-gates.d.ts +34 -0
  85. package/dist/gates/run-gates.js +105 -0
  86. package/dist/gates/scope.d.ts +8 -0
  87. package/dist/gates/scope.js +32 -0
  88. package/dist/gates/types.d.ts +6 -0
  89. package/dist/gates/types.js +1 -0
  90. package/dist/graph/graph.d.ts +20 -0
  91. package/dist/graph/graph.js +148 -0
  92. package/dist/graph/schema.d.ts +185 -0
  93. package/dist/graph/schema.js +141 -0
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +1 -0
  96. package/dist/plan/prompt.d.ts +4 -0
  97. package/dist/plan/prompt.js +31 -0
  98. package/dist/plan/scope.d.ts +17 -0
  99. package/dist/plan/scope.js +141 -0
  100. package/dist/report/cost.d.ts +24 -0
  101. package/dist/report/cost.js +89 -0
  102. package/dist/route/preference.d.ts +27 -0
  103. package/dist/route/preference.js +49 -0
  104. package/dist/route/profile.d.ts +65 -0
  105. package/dist/route/profile.js +197 -0
  106. package/dist/route/router.d.ts +26 -0
  107. package/dist/route/router.js +200 -0
  108. package/dist/run/consult.d.ts +23 -0
  109. package/dist/run/consult.js +80 -0
  110. package/dist/run/daemon.d.ts +23 -0
  111. package/dist/run/daemon.js +728 -0
  112. package/dist/run/git.d.ts +12 -0
  113. package/dist/run/git.js +56 -0
  114. package/dist/run/journal.d.ts +91 -0
  115. package/dist/run/journal.js +287 -0
  116. package/dist/run/lock.d.ts +29 -0
  117. package/dist/run/lock.js +182 -0
  118. package/dist/run/merge.d.ts +12 -0
  119. package/dist/run/merge.js +44 -0
  120. package/dist/run/reconcile.d.ts +4 -0
  121. package/dist/run/reconcile.js +95 -0
  122. package/package.json +39 -0
  123. package/schema/rungraph.schema.json +306 -0
@@ -0,0 +1,31 @@
1
+ export function scopePrompt(intent, repair) {
2
+ return `DROVR-JUDGE
3
+ You are drafting a drovr native spec from an answered intent. Return only the Markdown spec, with no commentary or code fence.
4
+
5
+ The draft must:
6
+ - start with <!-- drovr:spec -->
7
+ - include a Requirements section whose ids are sequential REQ-01, REQ-02, ...
8
+ - include an Assumptions section that makes every residual uncertainty explicit
9
+ - include a Traceability section mapping every REQ-nn to at least one Tn task
10
+ - use native "## Tn: Title" tasks and only command:, test:, or judge: acceptance entries
11
+ - keep scope guesses advisory in prose; runtime files scope remains authoritative
12
+
13
+ Intent:
14
+ <intent>
15
+ ${intent.trim()}
16
+ </intent>
17
+ ${repair ? `
18
+ The previous draft failed validation. Repair it without dropping valid detail.
19
+
20
+ Validation errors:
21
+ <errors>
22
+ ${repair.error}
23
+ </errors>
24
+
25
+ Previous draft:
26
+ <draft>
27
+ ${repair.draft}
28
+ </draft>
29
+ ` : ""}
30
+ `;
31
+ }
@@ -0,0 +1,17 @@
1
+ import type { WorkerAdapter } from "../adapters/types.js";
2
+ import type { DrovrConfig } from "../config/config.js";
3
+ import type { ExecutorDriver } from "../drivers/types.js";
4
+ export declare function clarificationGate(intent: string): string[];
5
+ export interface ScopeOptions {
6
+ cfg: DrovrConfig;
7
+ adapters: WorkerAdapter[];
8
+ driver?: ExecutorDriver;
9
+ force?: boolean;
10
+ }
11
+ export interface ScopeResult {
12
+ specFile: string;
13
+ tasks: number;
14
+ attempts: number;
15
+ }
16
+ export declare function specPathForIntent(intentFile: string): string;
17
+ export declare function scopeIntent(intentFile: string, repoRoot: string, options: ScopeOptions): Promise<ScopeResult>;
@@ -0,0 +1,141 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, extname, join } from "node:path";
4
+ import { discoverChannels, getAdapter, probeAll } from "../adapters/registry.js";
5
+ import { compileNative } from "../compile/native.js";
6
+ import { pickDriver } from "../drivers/index.js";
7
+ import { extractJson, runLlm } from "../gates/llm.js";
8
+ import { TaskSchema } from "../graph/schema.js";
9
+ import { route } from "../route/router.js";
10
+ import { scopePrompt } from "./prompt.js";
11
+ const HEADING_RE = /^#{1,6}\s+(.+?)\s*$/;
12
+ const ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)(?:\[[ xX]\]\s*)?(?:[QA]\d*:\s*)?(.+?)\s*$/;
13
+ function sectionItems(source, heading) {
14
+ const items = [];
15
+ let active = false;
16
+ for (const line of source.split("\n")) {
17
+ const h = line.match(HEADING_RE);
18
+ if (h) {
19
+ active = heading.test(h[1]);
20
+ continue;
21
+ }
22
+ if (!active)
23
+ continue;
24
+ const item = line.match(ITEM_RE)?.[1].trim();
25
+ if (item)
26
+ items.push(item);
27
+ }
28
+ return items;
29
+ }
30
+ export function clarificationGate(intent) {
31
+ const questions = sectionItems(intent, /^blocking questions?$/i);
32
+ if (questions.length > 3)
33
+ throw new Error(`intent has ${questions.length} blocking questions; at most 3 blocking questions are allowed`);
34
+ const answers = sectionItems(intent, /^(?:blocking )?answers?$/i);
35
+ return questions.filter((_, i) => !answers[i] || /^(?:tbd|todo|unanswered|\?)$/i.test(answers[i]));
36
+ }
37
+ export function specPathForIntent(intentFile) {
38
+ const ext = extname(intentFile);
39
+ const stem = basename(intentFile, ext).replace(/\.intent$/i, "");
40
+ return join(dirname(intentFile), `${stem}.spec.md`);
41
+ }
42
+ function section(source, name) {
43
+ const lines = source.split("\n");
44
+ const start = lines.findIndex((line) => new RegExp(`^#{1,6}\\s+${name}\\s*$`, "i").test(line));
45
+ if (start === -1)
46
+ return "";
47
+ const end = lines.findIndex((line, i) => i > start && HEADING_RE.test(line));
48
+ return lines.slice(start + 1, end === -1 ? undefined : end).join("\n");
49
+ }
50
+ function extractDraft(raw) {
51
+ const clean = raw.replace(/\nDROVR_EXIT:\d[\s\S]*$/, "").trim();
52
+ try {
53
+ const value = JSON.parse(clean);
54
+ if (typeof value === "string")
55
+ return value;
56
+ }
57
+ catch {
58
+ // normal adapters return Markdown, not JSON strings
59
+ }
60
+ const json = extractJson(clean);
61
+ const value = json?.spec ?? json?.draft;
62
+ if (typeof value === "string")
63
+ return value;
64
+ const fenced = [...clean.matchAll(/```(?:markdown|md)?\s*\n([\s\S]*?)```/gi)].at(-1)?.[1];
65
+ if (fenced)
66
+ return fenced;
67
+ const marker = clean.indexOf("<!-- drovr:spec");
68
+ return (marker === -1 ? clean : clean.slice(marker)).trimEnd() + "\n";
69
+ }
70
+ function validateDraft(draft) {
71
+ if (!/^<!--\s*drovr:spec(?:\s+v1)?\s*-->/m.test(draft))
72
+ throw new Error("draft is missing the drovr native marker");
73
+ if (!section(draft, "Assumptions").trim())
74
+ throw new Error("draft is missing explicit assumptions");
75
+ const requirements = [...new Set(section(draft, "Requirements").match(/\bREQ-\d{2}\b/g) ?? [])];
76
+ if (!requirements.length)
77
+ throw new Error("draft has no REQ-nn requirements");
78
+ const dir = mkdtempSync(join(tmpdir(), "drovr-scope-compile-"));
79
+ const file = join(dir, "draft.spec.md");
80
+ try {
81
+ writeFileSync(file, draft);
82
+ const graph = compileNative(file);
83
+ const trace = section(draft, "Traceability");
84
+ for (const req of requirements) {
85
+ const mapped = trace.split("\n").some((line) => line.includes(req) && graph.tasks.some((task) => new RegExp(`\\b${task.id}\\b`).test(line)));
86
+ if (!mapped)
87
+ throw new Error(`${req} is not mapped to a task in Traceability`);
88
+ }
89
+ const plain = graph.tasks.flatMap((task) => task.acceptance).filter((item) => typeof item === "string");
90
+ if (plain.length)
91
+ throw new Error(`draft has ${plain.length} untyped acceptance item${plain.length === 1 ? "" : "s"}`);
92
+ return graph.tasks.length;
93
+ }
94
+ finally {
95
+ rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ }
98
+ export async function scopeIntent(intentFile, repoRoot, options) {
99
+ if (!existsSync(intentFile))
100
+ throw new Error(`no such intent file: ${intentFile}`);
101
+ const intent = readFileSync(intentFile, "utf8");
102
+ const unanswered = clarificationGate(intent);
103
+ if (unanswered.length) {
104
+ throw new Error(`unanswered blocking questions (${unanswered.length}):\n${unanswered.map((q, i) => `${i + 1}. ${q}`).join("\n")}`);
105
+ }
106
+ const specFile = specPathForIntent(intentFile);
107
+ if (existsSync(specFile) && !options.force)
108
+ throw new Error(`${specFile} already exists; pass --force to overwrite it`);
109
+ const health = await probeAll(options.adapters);
110
+ const channels = discoverChannels(options.cfg, options.adapters, health);
111
+ const planningTask = TaskSchema.parse({
112
+ id: "SCOPE", title: "Draft native spec", goal: "Draft a compiled native spec", shape: "spec", complexity: 7,
113
+ acceptance: [{ oracle: "judge", text: "Every requirement maps to a task with typed acceptance oracles" }],
114
+ });
115
+ const assignment = route(planningTask, options.cfg, channels).assignment;
116
+ const adapter = getAdapter(assignment.adapter, options.adapters);
117
+ const driver = options.cfg.visibility.llm === "pane" ? options.driver ?? pickDriver(options.cfg) : undefined;
118
+ const name = basename(specFile, ".spec.md");
119
+ let prompt = scopePrompt(intent);
120
+ for (let attempts = 1; attempts <= 3; attempts++) {
121
+ const via = driver ? {
122
+ driver, name: `scope-${name}-${attempts}-${adapter.id}`, label: "SCOPE",
123
+ keep: options.cfg.visibility.keepPanes === "forever",
124
+ } : undefined;
125
+ const draft = extractDraft(await runLlm(adapter, assignment.model, prompt, repoRoot, via));
126
+ let tasks;
127
+ try {
128
+ tasks = validateDraft(draft);
129
+ }
130
+ catch (error) {
131
+ const message = error.message;
132
+ if (attempts === 3)
133
+ throw new Error(`scope draft failed after 2 repair retries:\n${message}`);
134
+ prompt = scopePrompt(intent, { draft, error: message });
135
+ continue;
136
+ }
137
+ writeFileSync(specFile, draft, options.force ? undefined : { flag: "wx" });
138
+ return { specFile, tasks, attempts };
139
+ }
140
+ throw new Error("unreachable");
141
+ }
@@ -0,0 +1,24 @@
1
+ import { type TokenUsage } from "../adapters/types.js";
2
+ import type { ModelPricing, SubPricing } from "../config/config.js";
3
+ import type { TelemetryRow } from "../run/journal.js";
4
+ export interface CostConfig {
5
+ models?: Record<string, ModelPricing>;
6
+ subs?: Record<string, SubPricing>;
7
+ }
8
+ export interface ChannelCost {
9
+ adapter: string;
10
+ model: string;
11
+ channel: string;
12
+ attempts: number;
13
+ tasks: number;
14
+ tokens?: TokenUsage;
15
+ partialMetering: boolean;
16
+ apiUsd?: number;
17
+ amortizedUsd?: [number, number];
18
+ counterfactualUsd?: number;
19
+ rate?: ModelPricing;
20
+ subPlan?: SubPricing;
21
+ measurable: boolean;
22
+ reason?: string;
23
+ }
24
+ export declare function estimateCosts(rows: TelemetryRow[], cost?: CostConfig): ChannelCost[];
@@ -0,0 +1,89 @@
1
+ // v1.20 REC-02: pure cost estimation. Turns telemetry usage rows + an operator-maintained price table
2
+ // into per-channel cost estimates with full basis (tokens counted, rate used, rate date) — justification
3
+ // is data, not prose. ABSENT pricing degrades to "not measurable", never a crash or a fake $0. No network
4
+ // anywhere in the path; the price table is operator config copied from LiteLLM's price JSON (spec cost model).
5
+ import { addUsage } from "../adapters/types.js";
6
+ const round6 = (n) => Math.round(n * 1e6) / 1e6; // kill float dust without faking cents precision
7
+ // tokens × per-Mtok rate. Only dimensions with a configured rate are priced; cacheWrite/reasoning carry no
8
+ // rate field (REC-02) so they stay counted-in-basis but unpriced — honest, not silently inflated.
9
+ function apiCost(t, r) {
10
+ const perMtok = (tokens, rate) => (tokens / 1_000_000) * rate;
11
+ let usd = perMtok(t.input, r.inPerMtok) + perMtok(t.output, r.outPerMtok);
12
+ if (t.cacheRead !== undefined && r.cacheReadPerMtok !== undefined)
13
+ usd += perMtok(t.cacheRead, r.cacheReadPerMtok);
14
+ return round6(usd);
15
+ }
16
+ function estimateGroup(gr, cost) {
17
+ const { adapter, model, channel } = gr[0];
18
+ const attempts = gr.reduce((s, r) => s + (r.attempts ?? 0), 0);
19
+ // fold only rows that metered tokens; absent stays absent (the one sanctioned addUsage coalesce)
20
+ let tokens;
21
+ let metered = 0;
22
+ for (const r of gr)
23
+ if (r.tokens) {
24
+ metered++;
25
+ tokens = addUsage(tokens, r.tokens);
26
+ }
27
+ const partialMetering = metered > 0 && metered < gr.length;
28
+ const rate = cost.models?.[model];
29
+ const subPlan = cost.subs?.[adapter];
30
+ const c = {
31
+ adapter,
32
+ model,
33
+ channel,
34
+ attempts,
35
+ tasks: gr.length,
36
+ partialMetering,
37
+ measurable: false,
38
+ ...(tokens ? { tokens } : {}),
39
+ };
40
+ if (channel === "api") {
41
+ if (tokens && rate) {
42
+ c.apiUsd = apiCost(tokens, rate);
43
+ c.rate = rate;
44
+ }
45
+ else {
46
+ // fail closed: known rate but no tokens ⇒ unmetered; tokens but no rate ⇒ price unknown
47
+ c.reason = !tokens ? "unmetered — no token usage recorded" : `no API rate configured for model "${model}"`;
48
+ }
49
+ }
50
+ else {
51
+ // sub channel: amortized range from the plan (a RANGE — time-varying quotas); counterfactual from
52
+ // metered tokens × the model's API rate. Each is independent; either may be absent.
53
+ if (subPlan) {
54
+ // more windows ⇒ cheaper per window ⇒ low uses High and high uses Low
55
+ const perWindowLow = subPlan.planMonthly / subPlan.windowsPerMonthHigh;
56
+ const perWindowHigh = subPlan.planMonthly / subPlan.windowsPerMonthLow;
57
+ c.amortizedUsd = [round6(attempts * perWindowLow), round6(attempts * perWindowHigh)];
58
+ c.subPlan = subPlan;
59
+ }
60
+ if (tokens && rate) {
61
+ c.counterfactualUsd = apiCost(tokens, rate);
62
+ c.rate = rate;
63
+ }
64
+ if (c.amortizedUsd === undefined && c.counterfactualUsd === undefined) {
65
+ // here subPlan is necessarily undefined (else amortized would be set); counterfactual needs tokens+rate
66
+ c.reason = tokens
67
+ ? `no sub plan for adapter "${adapter}" and no API rate for model "${model}"`
68
+ : `no sub plan for adapter "${adapter}" and unmetered`;
69
+ }
70
+ }
71
+ c.measurable = c.apiUsd !== undefined || c.amortizedUsd !== undefined || c.counterfactualUsd !== undefined;
72
+ return c;
73
+ }
74
+ // Pure: (telemetry rows, price table) → per-channel estimates. Groups by adapter:model (matches the
75
+ // report's existing grouping). Deterministic order: sorted by adapter:model. No I/O, no network.
76
+ export function estimateCosts(rows, cost = {}) {
77
+ const groups = new Map();
78
+ for (const r of rows) {
79
+ const key = `${r.adapter}:${r.model}`;
80
+ const arr = groups.get(key);
81
+ if (arr)
82
+ arr.push(r);
83
+ else
84
+ groups.set(key, [r]);
85
+ }
86
+ return [...groups.entries()]
87
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
88
+ .map(([, gr]) => estimateGroup(gr, cost));
89
+ }
@@ -0,0 +1,27 @@
1
+ import { type AuthHealth } from "../adapters/types.js";
2
+ import type { DrovrConfig } from "../config/config.js";
3
+ export interface Disallowed {
4
+ by: "deny" | "allow";
5
+ entry: string;
6
+ }
7
+ export declare function excludedChannels(cfg: DrovrConfig, adapters: {
8
+ id: string;
9
+ }[] | string[], health: Record<string, AuthHealth>): {
10
+ key: string;
11
+ d: Disallowed;
12
+ }[];
13
+ export declare function exclusionLine(excluded: {
14
+ key: string;
15
+ d: Disallowed;
16
+ }[]): string;
17
+ export declare function preferRanks(c: {
18
+ adapter: string;
19
+ model: string;
20
+ }, cfg: DrovrConfig): {
21
+ shape: string;
22
+ rank: number;
23
+ }[];
24
+ export declare function disallowedBy(c: {
25
+ adapter: string;
26
+ model: string;
27
+ }, routing: DrovrConfig["routing"]): Disallowed | null;
@@ -0,0 +1,49 @@
1
+ import { channelKey, channelsFromConfig } from "../adapters/types.js";
2
+ const adapterIds = (adapters) => typeof adapters[0] === "string" ? adapters : adapters.map((a) => a.id);
3
+ export function excludedChannels(cfg, adapters, health) {
4
+ const { allow, deny } = cfg.routing;
5
+ if (!allow && !deny)
6
+ return [];
7
+ const out = [];
8
+ for (const id of adapterIds(adapters)) {
9
+ if (!health[id]?.installed || !health[id]?.authed)
10
+ continue;
11
+ for (const c of channelsFromConfig(id, cfg)) {
12
+ const d = disallowedBy(c, cfg.routing);
13
+ if (d)
14
+ out.push({ key: channelKey(c), d });
15
+ }
16
+ }
17
+ return out;
18
+ }
19
+ export function exclusionLine(excluded) {
20
+ const parts = excluded.map(({ key, d }) => `${key} (${d.by}: ${d.entry})`);
21
+ return `routing preference active: ${excluded.length} channel(s) excluded — ${parts.join(", ")}`;
22
+ }
23
+ // T4: the prefer rank a channel holds across the routing map — every shape whose prefer list ranks
24
+ // it, with its 0-based index in that list. Empty ⇒ the channel isn't a prefer target (routes only via
25
+ // tier/floor/cost). Mirrors preferIndex's match grammar (adapter id | channel key) — the authoritative
26
+ // routing comparator in router.ts — never the looser disallowedBy grammar that also bare-matches models.
27
+ export function preferRanks(c, cfg) {
28
+ const out = [];
29
+ for (const [shape, entry] of Object.entries(cfg.routing.map)) {
30
+ const i = (entry.prefer ?? []).findIndex((p) => p === c.adapter || p === channelKey(c));
31
+ if (i !== -1)
32
+ out.push({ shape, rank: i });
33
+ }
34
+ return out;
35
+ }
36
+ // entries in either list accept adapter id, model id, or adapter:model (grammar A1)
37
+ export function disallowedBy(c, routing) {
38
+ const { allow, deny } = routing;
39
+ const matches = (e) => e === c.adapter || e === c.model || e === channelKey(c);
40
+ const d = [...(deny?.adapters ?? []), ...(deny?.models ?? [])].find(matches);
41
+ if (d)
42
+ return { by: "deny", entry: d };
43
+ if (allow) {
44
+ const entries = [...(allow.adapters ?? []), ...(allow.models ?? [])];
45
+ if (!entries.some(matches))
46
+ return { by: "allow", entry: entries.join(", ") || "(empty allowlist)" };
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,65 @@
1
+ export interface ProfileRow {
2
+ shape: string;
3
+ adapter: string;
4
+ model: string;
5
+ channel: string;
6
+ attempts: number;
7
+ outcome: "done" | "failed" | "human";
8
+ durationMs: number;
9
+ firstAttemptOk?: boolean;
10
+ gateFails?: number;
11
+ consults?: number;
12
+ parkKind?: string;
13
+ runId?: string;
14
+ quotaFailover?: true;
15
+ overrun?: true;
16
+ }
17
+ export interface ProfileCell {
18
+ n: number;
19
+ qSum: number;
20
+ dispatches: number;
21
+ doneCount: number;
22
+ quotaHits: number;
23
+ doneMedianMs?: number;
24
+ nRaw?: number;
25
+ overruns?: number;
26
+ }
27
+ export interface RoutingProfile {
28
+ cells: Map<string, ProfileCell>;
29
+ }
30
+ export declare const NEUTRAL = 0;
31
+ export declare const MIN_SAMPLES = 5;
32
+ export declare const PRIOR_K = 3;
33
+ export declare const PERF_WEIGHT = 0.05;
34
+ export declare const AVAIL_WEIGHT = 0.05;
35
+ export declare const OVERRUN_WEIGHT = 0.05;
36
+ export declare const REF_MS = 600000;
37
+ export declare const EXPLORE_CAP = 5;
38
+ export declare const HALF_LIFE_RUNS = 5;
39
+ export declare const DECAY_CAP = 30;
40
+ export declare function decayWeight(age: number, halfLife?: number): number;
41
+ export declare function explorationBonus(cell: ProfileCell | undefined): number;
42
+ export declare function classify(r: ProfileRow): 1 | 0.5 | 0 | null;
43
+ export declare function buildProfile(rows: ProfileRow[], opts?: {
44
+ halfLifeRuns?: number;
45
+ }): RoutingProfile;
46
+ export declare function cellOf(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string): ProfileCell | undefined;
47
+ export declare function cellsOf(profile: RoutingProfile): Generator<{
48
+ shape: string;
49
+ chKey: string;
50
+ channel: string;
51
+ cell: ProfileCell;
52
+ }>;
53
+ export interface CellSummary {
54
+ nRaw: number;
55
+ nEff: number;
56
+ dispatches: number;
57
+ quality?: number;
58
+ quotaHits: number;
59
+ cold: boolean;
60
+ exploreRemaining: number;
61
+ }
62
+ export declare function cellSummary(cell: ProfileCell): CellSummary;
63
+ export declare function learnedScore(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
64
+ availWeight?: number;
65
+ }): number;
@@ -0,0 +1,197 @@
1
+ // v1.6 self-learning routing — Phase 12: pure profile derivation + total scoring.
2
+ // pure: no I/O, no clock, no RNG (grep-pinned by tests/route/profile.test.ts); structural
3
+ // mirror of TelemetryRow & { runId } — route/ must NOT import run/ (layer DAG: run → route).
4
+ // Phase 13 wires learnedScore in as the 4th (last) router sort key; this file only produces it.
5
+ import { channelKey } from "../adapters/types.js";
6
+ // A1 tuning knobs (module constants; promotion to routing.learned.* config is Phase 13's call).
7
+ // Every value preserves totality and exact-neutrality — each is a one-line edit.
8
+ export const NEUTRAL = 0;
9
+ export const MIN_SAMPLES = 5; // n below this ⇒ score exactly 0 ⇒ static sort keys decide (ROUTE-07)
10
+ export const PRIOR_K = 3; // α=β=3 pseudo-count; denom = n + 2·PRIOR_K ≥ 6 > 0 always
11
+ export const PERF_WEIGHT = 0.05; // perf term ∈ (−0.025, +0.025] — strictly below one quality quantum
12
+ export const AVAIL_WEIGHT = 0.05; // conservative default, NOT data-derived; penalty-only — avail ∈ [−AVAIL_WEIGHT, 0],
13
+ // ≤ one quality quantum; a quotaHits=0 cell contributes exactly 0 (pre-ROUTE-12
14
+ // byte-identity for no-throttle profiles)
15
+ export const OVERRUN_WEIGHT = 0.05; // ROUTE-18 (OBS-04): conservative default, NOT data-derived; penalty-only —
16
+ // overrunPen ∈ [−OVERRUN_WEIGHT, 0], ≤ one quality quantum (twin of AVAIL_WEIGHT);
17
+ // an overruns=0 cell contributes exactly 0 (byte-identity for overrun-free profiles).
18
+ export const REF_MS = 600_000; // 10 min; REF_MS/(REF_MS+m) is a total monotone map into (0,1]
19
+ export const EXPLORE_CAP = MIN_SAMPLES; // Phase 14 probe budget in dispatches per (shape × channel); A1 knob (2*MIN_SAMPLES = one edit)
20
+ // ROUTE-11 evidence decay (v1.8). Quality evidence loses influence by run-recency RANK, so a
21
+ // warm-but-bad channel whose luck changed no longer waits the whole RUNS_WINDOW to roll.
22
+ export const HALF_LIFE_RUNS = 5; // conservative default, NOT data-derived (real journal: 18 pre-v1.6 rows — cannot tune)
23
+ // conservative default, NOT data-derived. TWO-part exactness argument:
24
+ // (granularity) caps weights at ≥ 2^-30 — every weight is an exact dyadic, no underflow;
25
+ // (magnitude) weights are multiples of 2^-31 ⇒ qSum stays bit-exact while row-count ≪ 2^21
26
+ // (= 2^(51-DECAY_CAP)); raising DECAY_CAP past ~40 would need a canonical fold order.
27
+ export const DECAY_CAP = 30;
28
+ // w = 2 ** -min(floor(age/halfLife), DECAY_CAP). age/Infinity = 0 ⇒ weight 1 ⇒ decay-off degenerates
29
+ // to v1.7 exactly. Dyadic power-of-two weights are the load-bearing exactness property: w·q (q dyadic)
30
+ // and every partial sum stay exact in IEEE-754, so the fold is order-insensitive by construction.
31
+ export function decayWeight(age, halfLife = HALF_LIFE_RUNS) {
32
+ return 2 ** -Math.min(Math.floor(age / halfLife), DECAY_CAP);
33
+ }
34
+ // Phase 14 exploration bonus: an under-observed channel keeps gathering evidence so early bad luck can't
35
+ // starve it permanently (EXP-01). Total by construction — dispatches is a finite non-negative integer counter,
36
+ // the denominator is a compile-time positive constant; no ln/sqrt/data-dependent divide, so none of the
37
+ // NaN/Infinity hazards that killed UCB and inverse-dispatch. No cell ⇒ 0: an UNKNOWN channel is not
38
+ // "under-observed", it is UNOBSERVED — bonusing it would be a discovery-order fairness scheduler (D2 forbids
39
+ // it) and would flip learned.test.ts Row 7. Fund from dispatches, not n: a channel that dispatched but
40
+ // yielded no quality obs (quota parks) has spent its budget — stop probing it, don't probe it forever.
41
+ // ponytail: rank-decay ships in v1.8 (buildProfile below); remaining ceiling = doneMedianMs staleness
42
+ // (perf term is undecayed) and per-cell EWMA as the finer upgrade path.
43
+ export function explorationBonus(cell) {
44
+ if (!cell)
45
+ return 0;
46
+ return Math.max(0, 1 - cell.dispatches / EXPLORE_CAP); // ∈ [0,1]; exactly 0 once dispatches ≥ cap
47
+ }
48
+ const QUALITY_FAIL_PARKS = new Set(["ladder-exhausted", "attempt-cap", "gate-fail"]);
49
+ // Quality observation per row: 1 clean, 0.5 degraded, 0 verified failure, null excluded/unobserved.
50
+ // Uses gateFails/consults, NOT firstAttemptOk/attempts: firstAttemptOk is `attempt===0` at the global
51
+ // loop counter (daemon.ts:347), which quota failovers bump — it would blame the final channel for
52
+ // another channel's quota. gateFails/consults are the quota-immune "merged clean" signals. A future
53
+ // session must NOT "fix" this back to firstAttemptOk.
54
+ // v1.5 rows: optional fields undefined = UNOBSERVED, never 0/false (journal.ts:21-22 — the poisoning bug).
55
+ export function classify(r) {
56
+ if (r.outcome === "done") {
57
+ if (r.gateFails === undefined)
58
+ return r.attempts === 1 ? 1 : null; // v1.5: >1 attempt = quota-vs-retry ambiguous
59
+ // consults ?? 0 is the ONE deliberate coalesce — safe ONLY behind gateFails !== undefined, because
60
+ // both fields ship together from the same v1.6 write sites (daemon.ts:120,347). Not the banned pattern.
61
+ return r.gateFails === 0 && (r.consults ?? 0) === 0 ? 1 : 0.5;
62
+ }
63
+ if (r.outcome === "human")
64
+ return r.parkKind !== undefined && QUALITY_FAIL_PARKS.has(r.parkKind) ? 0 : null;
65
+ return null; // "failed" exception rows carry channel "-" — structurally excluded below anyway
66
+ }
67
+ // PROF-04/05a: SYMMETRIC channel-class split — `channel` is a REQUIRED param (no default, no api
68
+ // special-casing) so a forgotten arg is a tsc compile error, not a silent "sub". Ceiling: splitting
69
+ // doubles cells per model reachable on both classes; per-cell EXPLORE_CAP/MIN_SAMPLES then means a
70
+ // warm model's evidence can dilute below MIN_SAMPLES per class once api channels warm and fall back to
71
+ // the exactly-neutral score — that is the correct separated-evidence behavior, not a regression.
72
+ const cellKey = (shape, chKey, channel) => `${shape}|${chKey}|${channel}`;
73
+ // ROUTE-15 PARAM-SHAPE: optional trailing opts (not PROF-05 required-param) — default is PROVABLY the module
74
+ // constant and oracle (a) Object.is byte-identity drill guards a wrong default; zero churn on callers.
75
+ export function buildProfile(rows, opts = {}) {
76
+ const halfLife = opts.halfLifeRuns ?? HALF_LIFE_RUNS;
77
+ const cells = new Map();
78
+ const durs = new Map();
79
+ // ROUTE-11 pre-pass: rank the DISTINCT defined runIds present. String sort of run-YYYYMMDD-HHMMSS
80
+ // IS chronological — runId content is NEVER parsed. age = numDistinct-1-ascIndex (newest = 0);
81
+ // runId undefined ⇒ age = numDistinct (strictly oldest, least influence — ROUTE-11a, fail toward
82
+ // least influence). numDistinct = 0 (no row carries a runId) ⇒ every age = 0 ⇒ every weight = 1 ⇒
83
+ // byte-identical v1.7 for runId-free inputs. Ranks are relative to the rows present at build time;
84
+ // a profile is built once per route call from readAllTelemetry, so ages cannot drift within one
85
+ // in-memory profile object.
86
+ const sorted = [...new Set(rows.map((r) => r.runId).filter((id) => id !== undefined))].sort();
87
+ const numDistinct = sorted.length;
88
+ const ascIndex = new Map(sorted.map((id, i) => [id, i]));
89
+ const ageOf = (runId) => runId === undefined ? numDistinct : numDistinct - 1 - ascIndex.get(runId);
90
+ for (const r of rows) {
91
+ if (r.adapter === "-")
92
+ continue; // exception row (daemon.ts:387): no real channel, skip entirely
93
+ const key = cellKey(r.shape, channelKey(r), r.channel);
94
+ let c = cells.get(key);
95
+ if (!c) {
96
+ c = { n: 0, qSum: 0, dispatches: 0, doneCount: 0, quotaHits: 0, nRaw: 0, overruns: 0 };
97
+ cells.set(key, c);
98
+ durs.set(key, []);
99
+ }
100
+ c.dispatches++; // utilization axis: every real-channel row, regardless of classification (UNDECAYED — EXP-04)
101
+ if (r.quotaFailover === true || r.parkKind === "quota")
102
+ c.quotaHits++; // availability fact, never a quality input (ROUTE-12); || not +: a both-marked row counts once
103
+ if (r.overrun === true)
104
+ c.overruns = (c.overruns ?? 0) + 1; // ROUTE-18 no-trailer fact (OBS-04), never a quality input; === true only — absent = unobserved (48-03's write side feeds this)
105
+ const q = classify(r);
106
+ // w is a power of two and q dyadic ({0, 0.5, 1}) ⇒ every product q·w and every partial sum is EXACT
107
+ // in IEEE-754 ⇒ the fold stays order-insensitive (v1.7's invariant EXTENDED, not abandoned). Magnitude
108
+ // headroom: weights are multiples of 2^-31, so exactness holds while row-count ≪ 2^21. nRaw is the
109
+ // integer undecayed observation count (Phase 28) — never enters the score arithmetic.
110
+ if (q !== null) {
111
+ const w = decayWeight(ageOf(r.runId), halfLife);
112
+ c.n += w;
113
+ c.qSum += q * w;
114
+ c.nRaw = (c.nRaw ?? 0) + 1;
115
+ }
116
+ if (r.outcome === "done" && Number.isFinite(r.durationMs) && r.durationMs >= 0) {
117
+ c.doneCount++;
118
+ durs.get(key).push(r.durationMs);
119
+ }
120
+ }
121
+ for (const [key, list] of durs) {
122
+ if (list.length === 0)
123
+ continue;
124
+ const s = list.slice().sort((a, b) => a - b); // median from a sorted copy ⇒ order-insensitive
125
+ const mid = s.length >> 1;
126
+ cells.get(key).doneMedianMs = s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
127
+ }
128
+ return { cells };
129
+ }
130
+ // The one keyed lookup helper — Phase 13's provenance never hand-builds Map keys.
131
+ export function cellOf(profile, shape, chKey, channel) {
132
+ return profile?.cells.get(cellKey(shape, chKey, channel));
133
+ }
134
+ // Phase 28/VIS-05: the ONLY place besides cellKey that knows the key format. report.ts iterates cells
135
+ // via this and NEVER hand-splits a raw key. PROF-04: chKey is `${adapter}:${model}` (contains ":",
136
+ // never "|"); parse channel from the RIGHT (lastIndexOf "|"), shape from the left.
137
+ export function* cellsOf(profile) {
138
+ for (const key of [...profile.cells.keys()].sort()) {
139
+ const ci = key.lastIndexOf("|");
140
+ const channel = key.slice(ci + 1);
141
+ const rest = key.slice(0, ci);
142
+ const si = rest.indexOf("|");
143
+ yield { shape: rest.slice(0, si), chKey: rest.slice(si + 1), channel, cell: profile.cells.get(key) };
144
+ }
145
+ }
146
+ // Phase 28/VIS-05: the single arithmetic source for the report — report.ts renders, never derives.
147
+ // nRaw ?? 0 coalesces HERE (report.ts bans `?? 0`); cold/exploreRemaining reuse the router thresholds.
148
+ export function cellSummary(cell) {
149
+ return {
150
+ nRaw: cell.nRaw ?? 0,
151
+ nEff: cell.n,
152
+ dispatches: cell.dispatches,
153
+ quality: cell.n > 0 ? cell.qSum / cell.n : undefined,
154
+ quotaHits: cell.quotaHits,
155
+ cold: cell.n < MIN_SAMPLES,
156
+ exploreRemaining: Math.max(0, EXPLORE_CAP - cell.dispatches),
157
+ };
158
+ }
159
+ // Total by construction: every miss returns the explicit NEUTRAL constant; every denominator is a
160
+ // compile-time-positive constant sum; no Record indexing by row strings (the TIER_RANK[typo] scar).
161
+ // Score 0 defers to the static sort keys, which already encode the benchmark-dated tier seeds — a
162
+ // per-channel numeric prior would give differing n=0 scores and break ROUTE-07's byte-identical cold
163
+ // start. Shrinking toward 0 IS shrinking toward the static prior.
164
+ export function learnedScore(profile, shape, chKey, channel, opts = {}) {
165
+ const availWeight = opts.availWeight ?? AVAIL_WEIGHT;
166
+ const cell = cellOf(profile, shape, chKey, channel);
167
+ if (!cell)
168
+ return NEUTRAL; // unknown channel ⇒ exactly neutral (empty-profile / cold-start leg, ROUTE-07)
169
+ // Cold-start proof (ROUTE-07): n ≤ nRaw ≤ dispatches and doneCount ≤ dispatches by construction, so a
170
+ // thin-dispatch cell (dispatches < MIN_SAMPLES) gates ALL THREE terms to literal +0 ⇒ exactly NEUTRAL.
171
+ // Each term is gated INDEPENDENTLY (no early-exit) so a dispatch-warm/quality-cold HIGH-throttle cell can
172
+ // still reach the negative avail term the old `n < MIN ⇒ NEUTRAL` exit suppressed.
173
+ const quality = cell.n < MIN_SAMPLES ? 0 : (cell.qSum + PRIOR_K) / (cell.n + 2 * PRIOR_K) - 0.5; // denom ≥ 2·PRIOR_K > 0
174
+ // REF_MS/(REF_MS+m), NOT range-normalization (x−min)/(max−min): the latter is 0/0 when all durations
175
+ // are identical. median ≥ 0 by the build-time filter ⇒ denom ≥ REF_MS > 0. The leading `cell.n < MIN_SAMPLES ||`
176
+ // is LOAD-BEARING: a null-classified done row bumps doneCount but NOT n (the `n<MIN ⇏ doneCount<MIN` lemma), so
177
+ // without it a {quotaHits=0, n<MIN, doneCount≥MIN} cell would score a nonzero perf where the old early-exit scored
178
+ // 0 — a silent ROUTE-07 break. With it, every quotaHits=0 cell is byte-identical to pre-ROUTE-12.
179
+ const perf = cell.n < MIN_SAMPLES || cell.doneMedianMs === undefined || cell.doneCount < MIN_SAMPLES
180
+ ? 0
181
+ : PERF_WEIGHT * (REF_MS / (REF_MS + cell.doneMedianMs) - 0.5);
182
+ // PENALTY-ONLY availability (ROUTE-12): a throttling channel is less available — deprioritize it without ever
183
+ // treating quota as a quality signal, ejecting it, or predicting quota. quotaHits=0 ⇒ avail 0 (no-throttle
184
+ // byte-identity). Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY
185
+ // of n, so the perf n-gate never suppresses the throttle penalty. NOT the symmetric (0.5 − ratio) form.
186
+ // ROUTE-16: penalty-only CONFIRMED v1.9 (see .overseer/DECISIONS.md 2026-07-11) — Option B (symmetric
187
+ // 0.5−ratio availability) NOT adopted: no evidence of over-penalization in the real dogfood;
188
+ // PREFER-STARVATION is an orthogonal hint/exploration finding, not an availability defect.
189
+ const avail = cell.dispatches < MIN_SAMPLES ? 0 : -availWeight * (cell.quotaHits / cell.dispatches); // ∈ [−availWeight, 0]
190
+ // PENALTY-ONLY no-trailer overrun (ROUTE-18, OBS-04): a channel that repeatedly burns a whole dispatch
191
+ // window without ever emitting a trailer is less reliable — deprioritize it without ever treating the overrun
192
+ // fact as a quality signal, ejecting it, or predicting it. overruns=0 ⇒ overrunPen 0 (overrun-free byte-identity).
193
+ // Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY of n, mirroring
194
+ // the ROUTE-12 avail term exactly. NOT the symmetric (0.5 − ratio) form (the v1.9 ROUTE-16 penalty-only precedent).
195
+ const overrunPen = cell.dispatches < MIN_SAMPLES ? 0 : -OVERRUN_WEIGHT * ((cell.overruns ?? 0) / cell.dispatches); // ∈ [−OVERRUN_WEIGHT, 0]
196
+ return quality + perf + avail + overrunPen; // score ∈ (−0.625, +0.525), finite for every input
197
+ }