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.
- package/README.md +347 -0
- package/dist/adapters/claude-code.d.ts +4 -0
- package/dist/adapters/claude-code.js +191 -0
- package/dist/adapters/codex.d.ts +8 -0
- package/dist/adapters/codex.js +257 -0
- package/dist/adapters/cursor-agent.d.ts +4 -0
- package/dist/adapters/cursor-agent.js +62 -0
- package/dist/adapters/fake.d.ts +45 -0
- package/dist/adapters/fake.js +115 -0
- package/dist/adapters/grok.d.ts +4 -0
- package/dist/adapters/grok.js +122 -0
- package/dist/adapters/model-lints.d.ts +6 -0
- package/dist/adapters/model-lints.js +130 -0
- package/dist/adapters/opencode.d.ts +3 -0
- package/dist/adapters/opencode.js +108 -0
- package/dist/adapters/pi.d.ts +3 -0
- package/dist/adapters/pi.js +191 -0
- package/dist/adapters/prompt.d.ts +6 -0
- package/dist/adapters/prompt.js +84 -0
- package/dist/adapters/registry.d.ts +31 -0
- package/dist/adapters/registry.js +165 -0
- package/dist/adapters/types.d.ts +98 -0
- package/dist/adapters/types.js +56 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +72 -0
- package/dist/cli/commands/compile.d.ts +1 -0
- package/dist/cli/commands/compile.js +23 -0
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +103 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +32 -0
- package/dist/cli/commands/plan.d.ts +2 -0
- package/dist/cli/commands/plan.js +101 -0
- package/dist/cli/commands/profile.d.ts +1 -0
- package/dist/cli/commands/profile.js +56 -0
- package/dist/cli/commands/report.d.ts +4 -0
- package/dist/cli/commands/report.js +306 -0
- package/dist/cli/commands/resume.d.ts +1 -0
- package/dist/cli/commands/resume.js +11 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/scope.d.ts +3 -0
- package/dist/cli/commands/scope.js +22 -0
- package/dist/cli/commands/status.d.ts +9 -0
- package/dist/cli/commands/status.js +162 -0
- package/dist/cli/commands/unlock.d.ts +1 -0
- package/dist/cli/commands/unlock.js +11 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +65 -0
- package/dist/compile/collateral.d.ts +6 -0
- package/dist/compile/collateral.js +128 -0
- package/dist/compile/common.d.ts +12 -0
- package/dist/compile/common.js +56 -0
- package/dist/compile/gsd.d.ts +3 -0
- package/dist/compile/gsd.js +222 -0
- package/dist/compile/index.d.ts +4 -0
- package/dist/compile/index.js +33 -0
- package/dist/compile/native.d.ts +4 -0
- package/dist/compile/native.js +196 -0
- package/dist/compile/prd.d.ts +2 -0
- package/dist/compile/prd.js +64 -0
- package/dist/compile/speckit.d.ts +2 -0
- package/dist/compile/speckit.js +66 -0
- package/dist/config/config.d.ts +184 -0
- package/dist/config/config.js +329 -0
- package/dist/drivers/herdr.d.ts +44 -0
- package/dist/drivers/herdr.js +394 -0
- package/dist/drivers/index.d.ts +3 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/subprocess.d.ts +25 -0
- package/dist/drivers/subprocess.js +106 -0
- package/dist/drivers/types.d.ts +61 -0
- package/dist/drivers/types.js +77 -0
- package/dist/gates/acceptance.d.ts +19 -0
- package/dist/gates/acceptance.js +92 -0
- package/dist/gates/baseline.d.ts +12 -0
- package/dist/gates/baseline.js +97 -0
- package/dist/gates/evidence.d.ts +4 -0
- package/dist/gates/evidence.js +12 -0
- package/dist/gates/llm.d.ts +25 -0
- package/dist/gates/llm.js +101 -0
- package/dist/gates/review.d.ts +12 -0
- package/dist/gates/review.js +80 -0
- package/dist/gates/run-gates.d.ts +34 -0
- package/dist/gates/run-gates.js +105 -0
- package/dist/gates/scope.d.ts +8 -0
- package/dist/gates/scope.js +32 -0
- package/dist/gates/types.d.ts +6 -0
- package/dist/gates/types.js +1 -0
- package/dist/graph/graph.d.ts +20 -0
- package/dist/graph/graph.js +148 -0
- package/dist/graph/schema.d.ts +185 -0
- package/dist/graph/schema.js +141 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plan/prompt.d.ts +4 -0
- package/dist/plan/prompt.js +31 -0
- package/dist/plan/scope.d.ts +17 -0
- package/dist/plan/scope.js +141 -0
- package/dist/report/cost.d.ts +24 -0
- package/dist/report/cost.js +89 -0
- package/dist/route/preference.d.ts +27 -0
- package/dist/route/preference.js +49 -0
- package/dist/route/profile.d.ts +65 -0
- package/dist/route/profile.js +197 -0
- package/dist/route/router.d.ts +26 -0
- package/dist/route/router.js +200 -0
- package/dist/run/consult.d.ts +23 -0
- package/dist/run/consult.js +80 -0
- package/dist/run/daemon.d.ts +23 -0
- package/dist/run/daemon.js +728 -0
- package/dist/run/git.d.ts +12 -0
- package/dist/run/git.js +56 -0
- package/dist/run/journal.d.ts +91 -0
- package/dist/run/journal.js +287 -0
- package/dist/run/lock.d.ts +29 -0
- package/dist/run/lock.js +182 -0
- package/dist/run/merge.d.ts +12 -0
- package/dist/run/merge.js +44 -0
- package/dist/run/reconcile.d.ts +4 -0
- package/dist/run/reconcile.js +95 -0
- package/package.json +39 -0
- package/schema/rungraph.schema.json +306 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Assignment, type BillingChannel } from "../adapters/types.js";
|
|
2
|
+
import { type DrovrConfig } from "../config/config.js";
|
|
3
|
+
import type { Task } from "../graph/schema.js";
|
|
4
|
+
import { type RoutingProfile } from "./profile.js";
|
|
5
|
+
export type LadderStep = "retry" | "escalate" | "consult" | "human";
|
|
6
|
+
export interface RouteDeviation {
|
|
7
|
+
static: string;
|
|
8
|
+
chosen: string;
|
|
9
|
+
score: number;
|
|
10
|
+
staticScore: number;
|
|
11
|
+
n: number;
|
|
12
|
+
explore?: true;
|
|
13
|
+
}
|
|
14
|
+
export interface Route {
|
|
15
|
+
assignment: Assignment;
|
|
16
|
+
ladder: LadderStep[];
|
|
17
|
+
lints: string[];
|
|
18
|
+
provenance: string;
|
|
19
|
+
deviation?: RouteDeviation;
|
|
20
|
+
}
|
|
21
|
+
export declare class RoutingError extends Error {
|
|
22
|
+
constructor(msg: string);
|
|
23
|
+
}
|
|
24
|
+
export declare function marginalCostRank(c: BillingChannel): number;
|
|
25
|
+
export declare function route(task: Task, cfg: DrovrConfig, channels: BillingChannel[], profile?: RoutingProfile): Route;
|
|
26
|
+
export declare function nextChannel(current: Assignment, task: Task, cfg: DrovrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile): Assignment | null;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { channelKey, channelsFromConfig } from "../adapters/types.js";
|
|
2
|
+
import { TIER_RANK } from "../config/config.js";
|
|
3
|
+
import { disallowedBy } from "./preference.js";
|
|
4
|
+
import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore } from "./profile.js";
|
|
5
|
+
export class RoutingError extends Error {
|
|
6
|
+
constructor(msg) {
|
|
7
|
+
super(msg);
|
|
8
|
+
this.name = "RoutingError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function marginalCostRank(c) {
|
|
12
|
+
if (c.channel === "sub")
|
|
13
|
+
return 0; // flat-rate: ~zero marginal cost until quota
|
|
14
|
+
return TIER_RANK[c.tier] + 1; // metered API: cheap 1, mid 2, frontier 3
|
|
15
|
+
}
|
|
16
|
+
const toAssignment = (c) => ({
|
|
17
|
+
adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier,
|
|
18
|
+
});
|
|
19
|
+
function resolvePin(pin, channels) {
|
|
20
|
+
const c = channels.find((c) => c.adapter === pin.via && c.model === pin.model);
|
|
21
|
+
if (!c) {
|
|
22
|
+
throw new RoutingError(`pinned ${pin.via}:${pin.model} not available — doctor found: ${channels.map(channelKey).join(", ") || "(nothing)"}`);
|
|
23
|
+
}
|
|
24
|
+
return c;
|
|
25
|
+
}
|
|
26
|
+
function preferIndex(c, prefer = []) {
|
|
27
|
+
const i = prefer.findIndex((p) => p === c.adapter || p === channelKey(c));
|
|
28
|
+
return i === -1 ? prefer.length : i;
|
|
29
|
+
}
|
|
30
|
+
function ladderFor(task, entry) {
|
|
31
|
+
const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
|
|
32
|
+
return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
|
|
33
|
+
}
|
|
34
|
+
export function route(task, cfg, channels, profile) {
|
|
35
|
+
const lints = [];
|
|
36
|
+
const floor = cfg.routing.floors[task.shape];
|
|
37
|
+
const entry = cfg.routing.map[task.shape];
|
|
38
|
+
const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
|
|
39
|
+
const disallowedPin = (via, model, kind) => {
|
|
40
|
+
const d = disallowedBy({ adapter: via, model }, cfg.routing);
|
|
41
|
+
if (d) {
|
|
42
|
+
throw new RoutingError(`${task.id}: ${kind} ${via}:${model} is disallowed by routing.${d.by} (${d.entry}) — remove the ${d.by} entry or re-pin to an allowed channel`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const preflightPrefer = (p) => {
|
|
46
|
+
if (p.includes(":")) {
|
|
47
|
+
const i = p.indexOf(":");
|
|
48
|
+
disallowedPin(p.slice(0, i), p.slice(i + 1), `prefer entry ${p}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (cfg.tiers[p]) {
|
|
52
|
+
const expanded = channelsFromConfig(p, cfg);
|
|
53
|
+
if (expanded.length && expanded.every((c) => disallowedBy(c, cfg.routing) !== null)) {
|
|
54
|
+
const d = disallowedBy(expanded[0], cfg.routing);
|
|
55
|
+
throw new RoutingError(`${task.id}: prefer entry ${p} is disallowed by routing.${d.by} (${d.entry}) — remove the ${d.by} entry or re-pin to an allowed channel`);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const d = disallowedBy({ adapter: "", model: p }, cfg.routing);
|
|
60
|
+
if (d) {
|
|
61
|
+
throw new RoutingError(`${task.id}: prefer entry ${p} is disallowed by routing.${d.by} (${d.entry}) — remove the ${d.by} entry or re-pin to an allowed channel`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const lintFloor = (tier, what) => {
|
|
65
|
+
if (floor && TIER_RANK[tier] < TIER_RANK[floor]) {
|
|
66
|
+
lints.push(`${task.id} (${task.shape}): ${what} routes ${tier}, below advisory floor ${floor}`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const taskFloor = task.routingHints?.floor;
|
|
70
|
+
const source = task.routingHints?.source;
|
|
71
|
+
const src = source ? `, ${source}` : ""; // never interpolate a possibly-undefined source
|
|
72
|
+
// task pin: planner-authored, try-first — degrades on miss or below-floor (D-05, research A3), never throws
|
|
73
|
+
let degraded = "";
|
|
74
|
+
const taskPin = task.routingHints?.pin;
|
|
75
|
+
if (taskPin) {
|
|
76
|
+
disallowedPin(taskPin.via, taskPin.model, "task pin");
|
|
77
|
+
const c = channels.find((c) => c.adapter === taskPin.via && c.model === taskPin.model);
|
|
78
|
+
if (c && (!taskFloor || TIER_RANK[c.tier] >= TIER_RANK[taskFloor])) {
|
|
79
|
+
lintFloor(c.tier, "task pin");
|
|
80
|
+
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `pin ${taskPin.via}:${taskPin.model} (task hint${src})` };
|
|
81
|
+
}
|
|
82
|
+
const why = c ? `below task floor ${taskFloor}` : "unavailable";
|
|
83
|
+
lints.push(`${task.id}: pinned ${taskPin.via}:${taskPin.model} ${why} — degrading to floor/auto (task hint${src})`);
|
|
84
|
+
degraded = `pin ${taskPin.via}:${taskPin.model} ${why} → `;
|
|
85
|
+
}
|
|
86
|
+
// map pin: operator-authored config — a miss stays fail-loud (D-05)
|
|
87
|
+
if (entry?.pin) {
|
|
88
|
+
disallowedPin(entry.pin.via, entry.pin.model, "map pin (config routing.map)");
|
|
89
|
+
const c = resolvePin(entry.pin, channels);
|
|
90
|
+
lintFloor(c.tier, "map pin");
|
|
91
|
+
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
|
|
92
|
+
}
|
|
93
|
+
const baseTier = entry?.tier ?? floor ?? "cheap";
|
|
94
|
+
const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
|
|
95
|
+
if (entry?.tier)
|
|
96
|
+
lintFloor(entry.tier, "map tier");
|
|
97
|
+
if (prefActive)
|
|
98
|
+
for (const p of entry?.prefer ?? [])
|
|
99
|
+
preflightPrefer(p);
|
|
100
|
+
// key order is a contract: prefer > marginal cost > tier (cheapest sufficient) > learned score (v1.6 ROUTE-06)
|
|
101
|
+
// > discovery order (same-tier fairness, D2). The learned score is the LAST key — it decides only the
|
|
102
|
+
// discovery-order tail, never a pin/floor/prefer/cost/tier boundary (they all sort above it, ROUTE-08).
|
|
103
|
+
const staticCmp = (a, b) => preferIndex(a, entry?.prefer) - preferIndex(b, entry?.prefer) || marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier];
|
|
104
|
+
const eligibleRaw = channels.filter((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier]);
|
|
105
|
+
if (!eligibleRaw.length) {
|
|
106
|
+
let msg = `${task.id}: no channel at tier>=${minTier}; available: ${channels.map(channelKey).join(", ") || "(none)"}`;
|
|
107
|
+
if (prefActive) {
|
|
108
|
+
const excluded = Object.keys(cfg.tiers).flatMap((id) => channelsFromConfig(id, cfg))
|
|
109
|
+
.filter((c) => disallowedBy(c, cfg.routing) !== null && TIER_RANK[c.tier] >= TIER_RANK[minTier])
|
|
110
|
+
.map(channelKey);
|
|
111
|
+
if (excluded.length)
|
|
112
|
+
msg += `; ${excluded.length} channel(s) excluded by routing.allow/deny: ${excluded.join(", ")}`;
|
|
113
|
+
}
|
|
114
|
+
throw new RoutingError(msg);
|
|
115
|
+
}
|
|
116
|
+
let eligible;
|
|
117
|
+
let deviation;
|
|
118
|
+
let learnedChosen = "";
|
|
119
|
+
if (profile) {
|
|
120
|
+
// scores precomputed ONCE over the eligible set — never inside the comparator (Pitfall 1).
|
|
121
|
+
// ponytail: no epsilon — two finite scores subtract to a finite number (Phase 12 totality); an
|
|
122
|
+
// epsilon would be intransitive. Upgrade path if warm cells ever tie near-exactly: quantize the score.
|
|
123
|
+
// PROF-04: cells are split by channel class, so pass c.channel explicitly (tsc-required). This map
|
|
124
|
+
// is keyed by channelKey alone, which would collide only if a fleet exposed the same adapter:model on
|
|
125
|
+
// both classes at once — impossible under today's scalar TierEntrySchema.channel.
|
|
126
|
+
// ROUTE-15: availWeight threads from config as a pure param; undefined ⇒ module default (byte-identical).
|
|
127
|
+
const scores = new Map(eligibleRaw.map((c) => [channelKey(c), learnedScore(profile, task.shape, channelKey(c), c.channel, { availWeight: cfg.routing.learnedTuning?.availWeight })]));
|
|
128
|
+
const scoreOf = (c) => scores.get(channelKey(c));
|
|
129
|
+
// v1.6 Phase 14: exploration bonus precomputed ONCE (Pitfall 5), a new key ABOVE the score. EXP-01 needs
|
|
130
|
+
// it above the score (a warm-good incumbent can't permanently outrank an under-cap rival in a static tie);
|
|
131
|
+
// magnitude-free as its own lexicographic key. EXP-02 holds structurally: pins returned at :67/:78 and the
|
|
132
|
+
// floor filtered eligibleRaw at :89, all upstream of this block, so the bonus decides only within a static tie.
|
|
133
|
+
const bonuses = new Map(eligibleRaw.map((c) => [channelKey(c), explorationBonus(cellOf(profile, task.shape, channelKey(c), c.channel))]));
|
|
134
|
+
const bonusOf = (c) => bonuses.get(channelKey(c));
|
|
135
|
+
const staticWinner = [...eligibleRaw].sort(staticCmp)[0];
|
|
136
|
+
// Phase 34 ROUTE-17: prefer becomes a BAND + group-rep keys so exploration fires ACROSS prefer
|
|
137
|
+
// entries, while intra-entry order (cost > tier > bonus > score) and every cold path stay byte-identical.
|
|
138
|
+
// Cold reduces to preferIndex || cost || tier ≡ staticCmp (proof P1); rep keys use the group HEAD so
|
|
139
|
+
// the probed group's budget self-extinguishes at EXPLORE_CAP (P4); flat lexicographic K is total (P5).
|
|
140
|
+
const prefer = entry?.prefer ?? [];
|
|
141
|
+
const groupCmp = (a, b) => marginalCostRank(a) - marginalCostRank(b) || TIER_RANK[a.tier] - TIER_RANK[b.tier] ||
|
|
142
|
+
bonusOf(b) - bonusOf(a) || scoreOf(b) - scoreOf(a);
|
|
143
|
+
const heads = new Map();
|
|
144
|
+
for (const c of [...eligibleRaw].sort(groupCmp)) {
|
|
145
|
+
const g = preferIndex(c, prefer);
|
|
146
|
+
if (!heads.has(g))
|
|
147
|
+
heads.set(g, c);
|
|
148
|
+
}
|
|
149
|
+
const keyOf = new Map(eligibleRaw.map((c) => {
|
|
150
|
+
const g = preferIndex(c, prefer);
|
|
151
|
+
const rep = heads.get(g);
|
|
152
|
+
return [channelKey(c), [g < prefer.length ? 0 : 1, -bonusOf(rep), -scoreOf(rep), g,
|
|
153
|
+
marginalCostRank(c), TIER_RANK[c.tier], -bonusOf(c), -scoreOf(c)]];
|
|
154
|
+
}));
|
|
155
|
+
const kOf = (c) => keyOf.get(channelKey(c));
|
|
156
|
+
const firstDiff = (a, b) => kOf(a).findIndex((v, i) => v !== kOf(b)[i]);
|
|
157
|
+
eligible = [...eligibleRaw].sort((a, b) => { const i = firstDiff(a, b); return i === -1 ? 0 : kOf(a)[i] - kOf(b)[i]; });
|
|
158
|
+
const w = eligible[0];
|
|
159
|
+
const ru = channelKey(staticWinner) !== channelKey(w) ? staticWinner : eligible[1];
|
|
160
|
+
// Phase 34 ROUTE-17: first-differing-key markers — probe when a rep or intra bonus key decided.
|
|
161
|
+
const diffKey = ru ? firstDiff(w, ru) : -1;
|
|
162
|
+
const probe = diffKey === 1 || diffKey === 6;
|
|
163
|
+
if (probe) {
|
|
164
|
+
learnedChosen = `via exploration probe (dispatches=${cellOf(profile, task.shape, channelKey(w), w.channel)?.dispatches ?? 0} < ${EXPLORE_CAP})`;
|
|
165
|
+
}
|
|
166
|
+
else if (diffKey === 2 || diffKey === 7) {
|
|
167
|
+
const n = cellOf(profile, task.shape, channelKey(w), w.channel)?.n ?? 0;
|
|
168
|
+
learnedChosen = `via learned score ${scoreOf(w).toFixed(3)} (n=${n}) over ${channelKey(ru)} ${scoreOf(ru).toFixed(3)}`;
|
|
169
|
+
}
|
|
170
|
+
if (channelKey(staticWinner) !== channelKey(w)) {
|
|
171
|
+
deviation = { static: channelKey(staticWinner), chosen: channelKey(w), score: scoreOf(w), staticScore: scoreOf(staticWinner), n: cellOf(profile, task.shape, channelKey(w), w.channel)?.n ?? 0, ...(probe ? { explore: true } : {}) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
eligible = eligibleRaw.sort(staticCmp); // ROUTE-07/09: literally the v1.5 code path — dead code cannot deviate
|
|
176
|
+
}
|
|
177
|
+
const bound = taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
|
|
178
|
+
entry?.tier ? `tier ${entry.tier} (config routing.map)` :
|
|
179
|
+
floor ? `floor ${floor} (config floors)` :
|
|
180
|
+
"tier cheap (default)";
|
|
181
|
+
// name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
|
|
182
|
+
// winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
|
|
183
|
+
const chosenBy = learnedChosen || (entry?.prefer && preferIndex(eligible[0], entry.prefer) < entry.prefer.length
|
|
184
|
+
? "via prefer" : "cheapest sufficient tier");
|
|
185
|
+
return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
|
|
186
|
+
}
|
|
187
|
+
export function nextChannel(current, task, cfg, channels, tried, profile) {
|
|
188
|
+
// already cheapest-sufficient: TIER_RANK asc is the PRIMARY key so escalation climbs one band at a time.
|
|
189
|
+
// Do NOT "unify" this onto route()'s key order (marginal-cost first) — that reverses climb-one-band on mixed fleets (ROUTE-02, D2).
|
|
190
|
+
// ROUTE-13: learnedScore is the STRICTLY-LAST key — within-band tiebreak only. Precomputed
|
|
191
|
+
// outside the comparator (Pitfall 1), never arithmetic-combined with the band keys, no
|
|
192
|
+
// profile-dependent filter. NO exploration bonus here (route():110 has one; a probe on the
|
|
193
|
+
// failure path would spend a real retry). Absent profile ⇒ every score is 0 ⇒ third key
|
|
194
|
+
// all-ties ⇒ the stable sort preserves the exact v1.7 candidate ORDER.
|
|
195
|
+
const pool = channels.filter((c) => !tried.includes(channelKey(c)) && TIER_RANK[c.tier] >= TIER_RANK[current.tier]);
|
|
196
|
+
const scores = new Map(pool.map((c) => [channelKey(c), profile ? learnedScore(profile, task.shape, channelKey(c), c.channel, { availWeight: cfg.routing.learnedTuning?.availWeight }) : 0]));
|
|
197
|
+
const scoreOf = (c) => scores.get(channelKey(c));
|
|
198
|
+
const candidates = pool.sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier] || marginalCostRank(a) - marginalCostRank(b) || scoreOf(b) - scoreOf(a));
|
|
199
|
+
return candidates.length ? toAssignment(candidates[0]) : null;
|
|
200
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import type { DrovrConfig } from "../config/config.js";
|
|
3
|
+
import type { ExecutorDriver, Slot } from "../drivers/types.js";
|
|
4
|
+
import type { GateResult } from "../gates/types.js";
|
|
5
|
+
export interface ConsultVerdict {
|
|
6
|
+
action: "retry" | "reroute" | "decompose" | "human";
|
|
7
|
+
notes: string;
|
|
8
|
+
excludeAdapter?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface Dossier {
|
|
11
|
+
taskId: string;
|
|
12
|
+
trigger: string;
|
|
13
|
+
journalTail: string;
|
|
14
|
+
transcript: string;
|
|
15
|
+
diff: string;
|
|
16
|
+
gates: GateResult[];
|
|
17
|
+
}
|
|
18
|
+
export declare function buildDossierPrompt(d: Dossier): string;
|
|
19
|
+
export declare function consult(d: Dossier, cfg: DrovrConfig, adapters: WorkerAdapter[], driver: ExecutorDriver, cwd: string, runDir: string, opts?: {
|
|
20
|
+
keep?: boolean;
|
|
21
|
+
onSlot?: (slot: Slot) => void;
|
|
22
|
+
runId?: string;
|
|
23
|
+
}): Promise<ConsultVerdict>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAdapter } from "../adapters/registry.js";
|
|
4
|
+
import { extractJson, gatePaneName } from "../gates/llm.js";
|
|
5
|
+
import { sh } from "./git.js";
|
|
6
|
+
const ACTIONS = ["retry", "reroute", "decompose", "human"];
|
|
7
|
+
export function buildDossierPrompt(d) {
|
|
8
|
+
return `DROVR-CONSULT
|
|
9
|
+
You are a senior engineering consult for the drovr orchestrator. A worker task hit trouble.
|
|
10
|
+
Read the dossier and return a verdict. Be decisive; cost is the lowest priority, quality the highest.
|
|
11
|
+
|
|
12
|
+
## Task: ${d.taskId} — trigger: ${d.trigger}
|
|
13
|
+
|
|
14
|
+
## Gate results
|
|
15
|
+
${d.gates.map((g) => `- [${g.pass ? "pass" : "FAIL"}] ${g.gate}: ${g.details}`).join("\n") || "(none)"}
|
|
16
|
+
|
|
17
|
+
## Failure context / diff
|
|
18
|
+
${d.diff || "(none)"}
|
|
19
|
+
|
|
20
|
+
## Worker transcript (tail)
|
|
21
|
+
${d.transcript || "(none)"}
|
|
22
|
+
|
|
23
|
+
## Journal (recent events)
|
|
24
|
+
${d.journalTail}
|
|
25
|
+
|
|
26
|
+
Verdict meanings: retry = same assignment with your notes as feedback; reroute = different CLI/model;
|
|
27
|
+
decompose = task too big, needs human re-planning; human = a person must look at this.
|
|
28
|
+
|
|
29
|
+
On reroute only, optional excludeAdapter is an adapter id (e.g. "cursor-agent") that bans EVERY
|
|
30
|
+
channel of that adapter for this task. Use it for environmental failures ("the CLI is blocked",
|
|
31
|
+
trust dialog, broken install) — not when a single model produced bad code. Omit for model-level
|
|
32
|
+
reroutes so other models of the same adapter remain eligible.
|
|
33
|
+
|
|
34
|
+
Respond with ONLY this JSON:
|
|
35
|
+
{"action": "retry" | "reroute" | "decompose" | "human", "notes": "one short paragraph of guidance", "excludeAdapter"?: "<adapter-id>"}
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
let consultSeq = 0;
|
|
39
|
+
export async function consult(d, cfg, adapters, driver, cwd, runDir,
|
|
40
|
+
// T2 ownership contract: runId names the consult pane canonically (drovr:consult:<task>:0:<runId>)
|
|
41
|
+
// via SlotOpts.owned; without it the legacy gatePaneName shape survives (non-daemon callers/tests).
|
|
42
|
+
opts = {}) {
|
|
43
|
+
const n = ++consultSeq;
|
|
44
|
+
const dir = join(runDir, "consults");
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
const promptFile = join(dir, `${d.taskId}-${n}.md`);
|
|
47
|
+
writeFileSync(promptFile, buildDossierPrompt(d));
|
|
48
|
+
const adapter = getAdapter(cfg.consult.adapter, adapters);
|
|
49
|
+
let out;
|
|
50
|
+
if (cfg.visibility.llm === "headless") {
|
|
51
|
+
const r = await sh(adapter.headlessCommand(promptFile, cfg.consult.model), cwd, cfg.consult.stallMinutes * 60_000);
|
|
52
|
+
out = r.stdout + r.stderr;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// T8: role-first pane name for fleet visibility (consult · T2); consultSeq stays on the dossier artifact only
|
|
56
|
+
const slot = await driver.slot(cwd, gatePaneName("consult", d.taskId), {
|
|
57
|
+
label: `CONSULT ${d.taskId}`,
|
|
58
|
+
...(opts.runId ? { owned: { role: "consult", taskId: d.taskId, attempt: 0, runId: opts.runId } } : {}),
|
|
59
|
+
});
|
|
60
|
+
opts.onSlot?.(slot);
|
|
61
|
+
await driver.run(slot, `${adapter.headlessCommand(promptFile, cfg.consult.model)}; printf '\\nDROVR_''EXIT:%s\\n' $?`);
|
|
62
|
+
// digit-suffixed marker only: a dossier quoting drovr's own "DROVR_EXIT:" literal must not
|
|
63
|
+
// false-complete the consult pane before its verdict (same guard as daemon.ts:193 / llm.ts).
|
|
64
|
+
await driver.waitOutput(slot, "DROVR_EXIT:\\d", cfg.consult.stallMinutes * 60_000, { regex: true });
|
|
65
|
+
out = await driver.read(slot, 300);
|
|
66
|
+
if (!opts.keep)
|
|
67
|
+
await driver.close(slot);
|
|
68
|
+
}
|
|
69
|
+
const v = extractJson(out);
|
|
70
|
+
if (!v || !ACTIONS.includes(v.action)) {
|
|
71
|
+
return { action: "human", notes: "consult verdict unparseable — failing safe to human" };
|
|
72
|
+
}
|
|
73
|
+
// fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
|
|
74
|
+
// empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
|
|
75
|
+
// and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
|
|
76
|
+
// zero-match expansion as channel-level reroute.
|
|
77
|
+
const raw = v.excludeAdapter;
|
|
78
|
+
const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
|
79
|
+
return { action: v.action, notes: String(v.notes ?? ""), ...(excludeAdapter ? { excludeAdapter } : {}) };
|
|
80
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import { type ExecutorDriver } from "../drivers/types.js";
|
|
3
|
+
import { type JournalEvent } from "./journal.js";
|
|
4
|
+
export interface RunOptions {
|
|
5
|
+
runId?: string;
|
|
6
|
+
resume?: boolean;
|
|
7
|
+
concurrency?: number;
|
|
8
|
+
driver?: ExecutorDriver;
|
|
9
|
+
adapters?: WorkerAdapter[];
|
|
10
|
+
globalDir?: string;
|
|
11
|
+
narrate?: (event: JournalEvent) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface RunSummary {
|
|
14
|
+
runId: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
done: string[];
|
|
17
|
+
failed: string[];
|
|
18
|
+
human: string[];
|
|
19
|
+
pending: string[];
|
|
20
|
+
blocked: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function formatSummary(s: RunSummary): string;
|
|
23
|
+
export declare function runDaemon(repoRoot: string, opts?: RunOptions): Promise<RunSummary>;
|