tickmarkr 1.59.0 → 1.60.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 +22 -81
- package/dist/cli/commands/fleet.js +40 -13
- package/dist/cli/commands/plan.js +1 -2
- package/dist/cli/commands/run.js +4 -4
- package/dist/cli/commands/status.js +19 -9
- package/dist/config/config.d.ts +21 -3
- package/dist/config/config.js +97 -32
- package/dist/route/router.d.ts +1 -3
- package/dist/route/router.js +11 -25
- package/dist/run/daemon.js +3 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,94 +143,34 @@ away from a failing adapter will never retry it in subsequent `tickmarkr resume`
|
|
|
143
143
|
|
|
144
144
|
## Choosing your fleet: `tickmarkr fleet`
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
probe data, agent CLIs, model tiers, routing mode, shape routing with a candidate picker, and
|
|
148
|
-
steering preferences — and ends in a unified diff of your config overlay that is written **only
|
|
149
|
-
on explicit confirm**. Pressing Enter through every step writes nothing. Assigning a tier to a
|
|
150
|
-
model that has no classification yet requires a typed benchmark-provenance note, stored as a
|
|
151
|
-
config comment beside the assignment.
|
|
152
|
-
|
|
153
|
-
The shape routing step (step 5/6) features an **arrow-driven candidate picker** that ranks
|
|
154
|
-
available channels by the production router's own logic, showing economics (tier + channel
|
|
155
|
-
cost signals for API and sub channels) and the router's rationale for each rank. This replaces
|
|
156
|
-
typed pin entry with verifiable suggestions. Step 4/6 shows an estimated spend context line
|
|
157
|
-
across all shapes under each routing mode's resolved floors, also derived from the same preview
|
|
158
|
-
routing machinery.
|
|
146
|
+
Configure which agent CLIs and models tickmarkr may route before your first run:
|
|
159
147
|
|
|
160
148
|
```bash
|
|
161
|
-
tickmarkr
|
|
162
|
-
tickmarkr fleet
|
|
149
|
+
tickmarkr doctor # probe auth + capabilities (run after install or credential changes)
|
|
150
|
+
tickmarkr fleet # interactive editor (requires a TTY) — six steps, confirm to write
|
|
151
|
+
tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive
|
|
152
|
+
tickmarkr plan # lint the resolved routing table against your spec
|
|
153
|
+
tickmarkr run # dispatch with the fleet you confirmed
|
|
163
154
|
```
|
|
164
155
|
|
|
165
|
-
|
|
156
|
+
`tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. The editor walks **six
|
|
157
|
+
steps** — probe data, agent CLIs, model tiers, routing mode, shape routing with a **candidate
|
|
158
|
+
picker**, and steering preferences — and ends in a unified diff of your repo config overlay.
|
|
159
|
+
Nothing is written until you confirm; pressing Enter through every step leaves config unchanged.
|
|
160
|
+
Step 5/6 uses an arrow-driven candidate picker ranked by the production router; step 3 may ask
|
|
161
|
+
for a benchmark-provenance note when you classify a new model.
|
|
166
162
|
|
|
167
|
-
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
auto-routing then picks the cheapest authed channel at or above the floor
|
|
171
|
-
- **deny/allow** bench models or whole adapters without touching tiers (see `deny.models` below)
|
|
172
|
-
- **tiers** classify models into bands — only classified, doctor-authed models ever route
|
|
163
|
+
Routing-mode semantics, pin/prefer/floor precedence, review and consult steering syntax,
|
|
164
|
+
provenance rules, and `--quality` / `--mode` flags are documented in
|
|
165
|
+
**[FLEET.md](FLEET.md)** (advanced reference).
|
|
173
166
|
|
|
174
|
-
|
|
167
|
+
## Steering
|
|
175
168
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
the routed model's window
|
|
182
|
-
- `routing.sla` — per-shape latency expectations, surfaced as advisory plan lints against the
|
|
183
|
-
learned performance profile
|
|
184
|
-
- `run --quality` — compatibility alias for `run --mode partner-led` for that run; it disables
|
|
185
|
-
exploration and records the selected mode in routing provenance
|
|
186
|
-
|
|
187
|
-
## Steering: routing modes, reviewer preferences, and reruns
|
|
188
|
-
|
|
189
|
-
### Routing modes
|
|
190
|
-
|
|
191
|
-
`routing.mode` is a preset that compiles into floor assignments at config load time. The router never sees the mode itself; it receives resolved floors only.
|
|
192
|
-
|
|
193
|
-
Three routing modes are available:
|
|
194
|
-
|
|
195
|
-
- **`risk-based`** (default): byte-identical to pre-v1.51 routing. Absent `mode` key resolves as risk-based.
|
|
196
|
-
- **`partner-led`**: resolves every non-overridden shape to a `frontier` floor and disables exploration — use when quality is paramount and cost is secondary.
|
|
197
|
-
- **`staff-led`**: lowers each mode default by one tier (e.g., `implement` and `refactor` become `cheap` instead of `mid`) while keeping the preset floor for the integrity set (`plan`, `spec`, `migration`, `ui`) at `frontier`.
|
|
198
|
-
|
|
199
|
-
Explicit `routing.floors` entries beat mode-preset deltas and are linted during plan if they shadow the mode's delta; an explicit integrity floor below `frontier` is also linted. The mode is compiled once at config load and never consulted during routing.
|
|
200
|
-
|
|
201
|
-
### Review preferences
|
|
202
|
-
|
|
203
|
-
`review.prefer` is an ordered list of reviewer seats for the cross-vendor code-review gate. Entries are matched by diversity (never the same vendor or model as the original worker), and routing reorders the available channels only — it does not admit unauthed or denied channels.
|
|
204
|
-
|
|
205
|
-
```yaml
|
|
206
|
-
review:
|
|
207
|
-
prefer: [codex, kimi] # bare adapter: inherits model from the routed channel
|
|
208
|
-
prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model explicit
|
|
209
|
-
prefer: [codex, kimi:kimi-code/k3] # mixed: bare and explicit
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
**Grammar**: review prefer entries may name a bare adapter (inheriting the model from the current channel) or an explicit `adapter:model` pair. Bare adapters rank every diversity-eligible channel for that adapter; explicit pairs rank one diversity-eligible channel.
|
|
213
|
-
|
|
214
|
-
### Consult preferences
|
|
215
|
-
|
|
216
|
-
`consult.prefer` is a ranked failover list of seats for escalations on deadlock or gate stalls. Unlike review, a consult seat has no channel to inherit a model from, so entries **must be explicit `adapter:model` pairs**.
|
|
217
|
-
|
|
218
|
-
```yaml
|
|
219
|
-
consult:
|
|
220
|
-
adapter: claude-code
|
|
221
|
-
model: fable
|
|
222
|
-
prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model ONLY
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
The daemon walks the preference list to the first live adapter, then the pinned `consult.adapter:model` pair as the final fallback. Failed or unparseable verdicts fall to the next entry.
|
|
226
|
-
|
|
227
|
-
**Grammar**: consult prefer entries require `adapter:model` form — a bare adapter name is invalid and fails config load. Every entry must declare both the adapter and the model because a consult seat runs independently with no channel context.
|
|
228
|
-
|
|
229
|
-
### Rerun control: --supersedes
|
|
230
|
-
|
|
231
|
-
`tickmarkr run --supersedes <prior-runId>` marks the current run as a rerun of a prior engagement. The current task graph is used for the rerun; compile it fresh first if the spec changed. The prior runId is recorded in the new journal, and the prior journal records the successor, for audit trails and change attribution.
|
|
232
|
-
|
|
233
|
-
Use this when you modify the spec or worker logic and want to mark an intentional rerun while preserving the relationship in both run journals.
|
|
169
|
+
Fleet step 6/6 sets `review.prefer` and `consult.prefer`; routing modes (`risk-based`,
|
|
170
|
+
`partner-led`, `staff-led`) are chosen in step 4/6. Full grammar — including when review
|
|
171
|
+
prefer may name a **bare adapter** versus `adapter:model`, and why consult prefer entries
|
|
172
|
+
require **`adapter:model`** form — plus `tickmarkr run --supersedes` rerun control, is in
|
|
173
|
+
**[FLEET.md](FLEET.md)**.
|
|
234
174
|
|
|
235
175
|
## Model scoping and auth detection
|
|
236
176
|
|
|
@@ -426,6 +366,7 @@ accepted contributions are credited via `Co-authored-by:` on the release commit.
|
|
|
426
366
|
|
|
427
367
|
## Documentation
|
|
428
368
|
|
|
369
|
+
- **[FLEET.md](FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
|
|
429
370
|
- **[LICENSE](LICENSE)** — MIT license
|
|
430
371
|
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
|
|
431
372
|
- **[SECURITY.md](SECURITY.md)** — security policy and private vulnerability reporting
|
|
@@ -6,7 +6,7 @@ import { parseArgs } from "node:util";
|
|
|
6
6
|
import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, readAutoPrefer } from "../../adapters/registry.js";
|
|
7
7
|
import { fleetUnclassifiedModels } from "../../adapters/model-lints.js";
|
|
8
8
|
import { GLYPHS, bold, legend as legendText, toggleActive, toggleInactive } from "../../brand.js";
|
|
9
|
-
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
|
|
9
|
+
import { fleetEditableFromConfig, fleetEditableEquals, fleetRepoOverlayFromDelta, formatFleetPrint, globalConfigDir, harvestFleetProvenance, overlayBytesLoadError, overlayPreferShapes, readOverlayFile, repoOverlayPath, repoOverlayYaml, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
|
|
10
10
|
import { SHAPES, TIERS } from "../../graph/schema.js";
|
|
11
11
|
import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
|
|
12
12
|
import { route } from "../../route/router.js";
|
|
@@ -16,6 +16,10 @@ const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY
|
|
|
16
16
|
const QUIT = "fleet: quit without writing";
|
|
17
17
|
const isDeniedAdapter = (id, editable) => editable.denyAdapters.includes(id);
|
|
18
18
|
const isDeniedModel = (adapter, model, editable) => editable.denyModels.includes(`${adapter}:${model}`);
|
|
19
|
+
// v1.60 T3: every preview surface ranks with the SAME exploration setting as the candidate picker
|
|
20
|
+
// (rankCandidates routes noExplore so repeated calls agree) — a due probe must never make a
|
|
21
|
+
// step-4/5 row disagree with the picker's rank-1 for the same shape and channel set.
|
|
22
|
+
const PREVIEW_EXPLORE = { noExplore: true };
|
|
19
23
|
function previewTask(shape) {
|
|
20
24
|
return {
|
|
21
25
|
id: "fleet-preview",
|
|
@@ -190,7 +194,10 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
190
194
|
}
|
|
191
195
|
const rm = resolveRunMode(cwd, { globalDir });
|
|
192
196
|
const cfg = rm.cfg;
|
|
193
|
-
|
|
197
|
+
// OBS-88: harvest existing `# note` comments from the overlay bytes at session load — the
|
|
198
|
+
// session must know about every prior note, not only its own edits, or the next write strips them
|
|
199
|
+
const harvested = harvestFleetProvenance(currentRepoOverlayText(cwd));
|
|
200
|
+
const initial = fleetEditableFromConfig(cfg, harvested.tiers);
|
|
194
201
|
const editable = structuredClone(initial);
|
|
195
202
|
const health = cached;
|
|
196
203
|
const term = openTerm(input, output);
|
|
@@ -290,16 +297,29 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
290
297
|
}
|
|
291
298
|
else if (k.name === "t" && rows[mCursor]) {
|
|
292
299
|
if (rows[mCursor].kind === "classified") {
|
|
293
|
-
|
|
300
|
+
// v1.60 T2: t on a classified row is a step-3 input mistake like any other — the
|
|
301
|
+
// notice renders inline and the session (with every in-session edit) stays alive
|
|
302
|
+
term.frame([
|
|
303
|
+
...listFrame(title, modelsLegend, renderRows(rows), mCursor),
|
|
304
|
+
"fleet: tier reassignment on classified models is not supported in v1 — edit config directly",
|
|
305
|
+
]);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// v1.60 T2: an input mistake re-prompts the same field — a typo must never unwind
|
|
309
|
+
// the whole editor and discard every in-session edit before the review screen
|
|
310
|
+
let tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
311
|
+
while (!TIERS.includes(tier)) {
|
|
312
|
+
tier = (await term.askTyped(`fleet: invalid tier ${tier || "(empty)"} — tier (${TIERS.join("|")})> `));
|
|
313
|
+
}
|
|
314
|
+
let note = await term.askTyped("benchmark provenance note (required): ");
|
|
315
|
+
while (!note) {
|
|
316
|
+
note = await term.askTyped("fleet: a typed benchmark-provenance note is required — benchmark provenance note (required): ");
|
|
294
317
|
}
|
|
295
|
-
const tier = (await term.askTyped(`tier (${TIERS.join("|")})> `));
|
|
296
|
-
if (!TIERS.includes(tier))
|
|
297
|
-
return { out: `fleet: invalid tier ${tier}`, code: 1 };
|
|
298
|
-
const note = await term.askTyped("benchmark provenance note (required): ");
|
|
299
|
-
if (!note)
|
|
300
|
-
return { out: "fleet: assigning a tier to an unclassified model requires a typed benchmark-provenance note", code: 1 };
|
|
301
318
|
editable.tiers[a.id] ??= {};
|
|
302
|
-
|
|
319
|
+
// stamp at typing time — the serializer writes provenance verbatim, so harvested notes
|
|
320
|
+
// round-trip byte-for-byte instead of accreting a fresh date suffix every write (OBS-88)
|
|
321
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
322
|
+
editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: `${note} — fleet ${today}` };
|
|
303
323
|
changed = true;
|
|
304
324
|
}
|
|
305
325
|
if (changed)
|
|
@@ -325,7 +345,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
325
345
|
let apiUsd = 0;
|
|
326
346
|
for (const shape of SHAPES) {
|
|
327
347
|
try {
|
|
328
|
-
const a = route(previewTask(shape), previewCfg(cand), channels, profile).assignment;
|
|
348
|
+
const a = route(previewTask(shape), previewCfg(cand), channels, profile, undefined, undefined, PREVIEW_EXPLORE).assignment;
|
|
329
349
|
tierCount[a.tier] = (tierCount[a.tier] ?? 0) + 1;
|
|
330
350
|
if (a.channel === "sub")
|
|
331
351
|
subs += 1;
|
|
@@ -393,7 +413,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
393
413
|
const shapeRows = () => SHAPES.map((shape) => {
|
|
394
414
|
let now;
|
|
395
415
|
try {
|
|
396
|
-
const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile);
|
|
416
|
+
const r = route(previewTask(shape), previewCfg(selectedMode), channels, profile, undefined, undefined, PREVIEW_EXPLORE);
|
|
397
417
|
now = `${r.assignment.adapter}:${r.assignment.model} (${r.assignment.channel}, ${r.assignment.tier}) ${costSignal(r.assignment, cfg.pricing)}`;
|
|
398
418
|
}
|
|
399
419
|
catch (e) {
|
|
@@ -527,7 +547,14 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
|
|
|
527
547
|
else
|
|
528
548
|
delete merged[key];
|
|
529
549
|
}
|
|
530
|
-
|
|
550
|
+
// OBS-88: session notes (which include the harvested ones attached at load) win per model;
|
|
551
|
+
// harvested notes alone cover entries with no editable seat — e.g. a null tombstone's comment
|
|
552
|
+
const tierNotes = structuredClone(harvested.tiers);
|
|
553
|
+
for (const [ad, ms] of Object.entries(provenanceMap(editable))) {
|
|
554
|
+
for (const [m, n] of Object.entries(ms))
|
|
555
|
+
(tierNotes[ad] ??= {})[m] = n;
|
|
556
|
+
}
|
|
557
|
+
const after = withModeLine(repoOverlayYaml(merged, tierNotes, { adapters: harvested.denyAdapters, models: harvested.denyModels }), writeMode);
|
|
531
558
|
if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, editable)) || before === after) {
|
|
532
559
|
return "fleet: no overlay changes (empty diff)";
|
|
533
560
|
}
|
|
@@ -8,7 +8,7 @@ import { loadGraph } from "../../graph/graph.js";
|
|
|
8
8
|
import { resolveRunMode } from "../../run/daemon.js";
|
|
9
9
|
import { excludedChannels, exclusionLine } from "../../route/preference.js";
|
|
10
10
|
import { staffLedEvidence } from "../../route/profile.js";
|
|
11
|
-
import {
|
|
11
|
+
import { route, RoutingError } from "../../route/router.js";
|
|
12
12
|
import { modelId } from "../../gates/review.js";
|
|
13
13
|
import { loadRoutingProfile } from "../../run/journal.js";
|
|
14
14
|
// T4 (v1.50): TTY-only brand pass — the title helper frames the routing table, lint/unroutable
|
|
@@ -44,7 +44,6 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
|
|
|
44
44
|
if (values.mode !== undefined && !ROUTING_MODES.includes(values.mode)) {
|
|
45
45
|
throw new Error(`--mode must be one of ${ROUTING_MODES.join(" | ")} (got ${values.mode})`);
|
|
46
46
|
}
|
|
47
|
-
delete process.env[QUALITY_ENV];
|
|
48
47
|
const g = loadGraph(cwd);
|
|
49
48
|
const { cfg, mode, source } = resolveRunMode(cwd, { flag: values.mode, spec: g.mode });
|
|
50
49
|
// readDoctor cache path: staleness line only fires here (probeAll fallback is fresh by construction).
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -4,7 +4,7 @@ import { ROUTING_MODES } from "../../config/config.js";
|
|
|
4
4
|
import { pickDriver } from "../../drivers/index.js";
|
|
5
5
|
import { loadGraph } from "../../graph/graph.js";
|
|
6
6
|
import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
|
|
7
|
-
import { route, NO_EXPLORE_ENV
|
|
7
|
+
import { route, NO_EXPLORE_ENV } from "../../route/router.js";
|
|
8
8
|
import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
|
|
9
9
|
import { ttyVisual } from "../../adapters/model-lints.js";
|
|
10
10
|
import { statusRow } from "../../brand.js";
|
|
@@ -25,8 +25,9 @@ export const narrationLine = (event) => {
|
|
|
25
25
|
const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
|
|
26
26
|
&& s.tipVerify !== "failed";
|
|
27
27
|
// v1.51 T2: --quality is a pure compatibility alias for `--mode partner-led` (this run only). It
|
|
28
|
-
// carries no one-band floor raise of its own
|
|
29
|
-
// TICKMARKR_QUALITY env, so no downstream code
|
|
28
|
+
// carries no one-band floor raise of its own — and since the OBS-89 rip (v1.60) route() no longer
|
|
29
|
+
// reads the retired TICKMARKR_QUALITY env at all, so no downstream code can raise a floor on its
|
|
30
|
+
// behalf (proven in mode-sources).
|
|
30
31
|
const QUALITY_ALIAS_NOTICE = "tickmarkr: --quality is a compatibility alias for --mode partner-led (this run only) — "
|
|
31
32
|
+ "the v1.47 one-band floor raise is retired (deprecated); use --mode partner-led";
|
|
32
33
|
export async function run(argv, cwd = process.cwd()) {
|
|
@@ -56,7 +57,6 @@ export async function run(argv, cwd = process.cwd()) {
|
|
|
56
57
|
if (values.quality)
|
|
57
58
|
console.warn(QUALITY_ALIAS_NOTICE);
|
|
58
59
|
const flagMode = values.mode ?? (values.quality ? "partner-led" : undefined);
|
|
59
|
-
delete process.env[QUALITY_ENV];
|
|
60
60
|
const graph = loadGraph(cwd);
|
|
61
61
|
const { cfg, conflict } = resolveRunMode(cwd, { flag: flagMode, spec: graph.mode });
|
|
62
62
|
if (conflict) {
|
|
@@ -54,12 +54,19 @@ const gateStates = (task, events) => {
|
|
|
54
54
|
};
|
|
55
55
|
// verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
|
|
56
56
|
const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
// TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
|
|
58
|
+
// legend, and in words on a failing row; non-TTY keeps the letter+box chips (byte-pinned surface)
|
|
59
|
+
const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
|
|
60
|
+
? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
|
|
61
|
+
: `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
|
|
62
|
+
// plain (uncolored) cell width for column math — ANSI codes have zero display width
|
|
63
|
+
const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + (unicode ? 1 : GATE_KEYS[gate].length + 3) + 1, -1);
|
|
64
|
+
const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
|
|
65
|
+
// plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
|
|
66
|
+
const failedSuffix = (states) => {
|
|
67
|
+
const f = failedGates(states);
|
|
68
|
+
return f.length ? ` · ${f.join(", ")}` : "";
|
|
69
|
+
};
|
|
63
70
|
const shortGoal = (goal, max) => {
|
|
64
71
|
const clause = goal.split(/[,;.?!]/, 1)[0].trim();
|
|
65
72
|
if (clause.length <= max)
|
|
@@ -193,19 +200,22 @@ const renderFrame = (cwd) => {
|
|
|
193
200
|
: `no runs yet${dot}`) +
|
|
194
201
|
`${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
|
|
195
202
|
const hr = rule(Math.min(width, 100));
|
|
196
|
-
const gatesLegend = legend(` gates: ${GATE_NAMES.
|
|
203
|
+
const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
|
|
197
204
|
const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
|
|
198
205
|
const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
|
|
199
|
-
const stW = Math.max(...cells.map((c) => (String(c.st) + c.label).length));
|
|
206
|
+
const stW = Math.max(...cells.map((c) => (String(c.st) + c.label + failedSuffix(c.states)).length));
|
|
200
207
|
const chainW = gateChainWidth(true);
|
|
201
208
|
const assignW = Math.max(...cells.map((c) => c.assignCol.length));
|
|
202
209
|
const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
|
|
203
210
|
const rows = cells.map(({ t, st, label, assignCol, states }) => {
|
|
204
211
|
const goal = shortGoal(t.goal, goalW).padEnd(goalW);
|
|
205
212
|
const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
|
|
213
|
+
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
214
|
+
const f = failedGates(states);
|
|
206
215
|
const statusCell = stWord +
|
|
207
216
|
(label ? (label === " starved" ? fail(label) : dim(label)) : "") +
|
|
208
|
-
" "
|
|
217
|
+
(f.length ? dim(" · ") + fail(f.join(", ")) : "") +
|
|
218
|
+
" ".repeat(stW - (String(st) + label + failedSuffix(states)).length);
|
|
209
219
|
return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
|
|
210
220
|
});
|
|
211
221
|
return [header, hr, gatesLegend, ...rows].join("\n");
|
package/dist/config/config.d.ts
CHANGED
|
@@ -250,7 +250,7 @@ export type FleetEditable = {
|
|
|
250
250
|
};
|
|
251
251
|
export declare function repoOverlayPath(repoRoot: string): string;
|
|
252
252
|
export declare function readOverlayFile(path: string): Record<string, unknown>;
|
|
253
|
-
export declare function fleetEditableFromConfig(cfg: TickmarkrConfig): FleetEditable;
|
|
253
|
+
export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, provenance?: Record<string, Record<string, string>>): FleetEditable;
|
|
254
254
|
/** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
|
|
255
255
|
export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
|
|
256
256
|
globalDir?: string;
|
|
@@ -259,10 +259,28 @@ export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
|
|
|
259
259
|
export declare function formatFleetPrint(repoRoot: string, opts?: {
|
|
260
260
|
globalDir?: string;
|
|
261
261
|
}): string;
|
|
262
|
+
/** Trailing `# note` comments on per-entry lines an operator may have hand-written or a prior
|
|
263
|
+
* fleet write stamped: model tier lines (including null tombstones) and deny list items. */
|
|
264
|
+
export type HarvestedProvenance = {
|
|
265
|
+
tiers: Record<string, Record<string, string>>;
|
|
266
|
+
denyAdapters: Record<string, string>;
|
|
267
|
+
denyModels: Record<string, string>;
|
|
268
|
+
};
|
|
269
|
+
/** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
|
|
270
|
+
* load. yaml.parse discards comments, so before this every fleet write re-serialized the file
|
|
271
|
+
* knowing only the current session's own notes and silently stripped all prior ones — a typed
|
|
272
|
+
* benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
|
|
273
|
+
* overlay is the loader's problem to reject, never the harvester's. */
|
|
274
|
+
export declare function harvestFleetProvenance(overlayText: string): HarvestedProvenance;
|
|
275
|
+
/** Harvested deny-entry notes keyed by the exact entry string, re-attached at serialize time. */
|
|
276
|
+
export type FleetDenyNotes = {
|
|
277
|
+
adapters?: Record<string, string>;
|
|
278
|
+
models?: Record<string, string>;
|
|
279
|
+
};
|
|
262
280
|
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
263
281
|
export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>): Record<string, unknown>;
|
|
264
|
-
export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string
|
|
265
|
-
export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string
|
|
282
|
+
export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
|
|
283
|
+
export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
|
|
266
284
|
export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
|
|
267
285
|
export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
|
|
268
286
|
export {};
|
package/dist/config/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { parse, stringify } from "yaml";
|
|
4
|
+
import { isMap, isScalar, isSeq, parse, parseDocument, stringify } from "yaml";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { stateDirName } from "../graph/graph.js";
|
|
7
7
|
import { SHAPES, TIERS } from "../graph/schema.js";
|
|
@@ -546,12 +546,16 @@ export function readOverlayFile(path) {
|
|
|
546
546
|
throw new ConfigError(`invalid overlay at ${path}`);
|
|
547
547
|
return raw;
|
|
548
548
|
}
|
|
549
|
-
export function fleetEditableFromConfig(cfg) {
|
|
549
|
+
export function fleetEditableFromConfig(cfg, provenance = {}) {
|
|
550
550
|
const tiers = {};
|
|
551
551
|
for (const [adapter, entry] of Object.entries(cfg.tiers)) {
|
|
552
552
|
tiers[adapter] = {};
|
|
553
|
-
for (const [model, tier] of Object.entries(entry.models))
|
|
554
|
-
|
|
553
|
+
for (const [model, tier] of Object.entries(entry.models)) {
|
|
554
|
+
// OBS-88: notes harvested from the overlay ride the session state from load, so a later
|
|
555
|
+
// write knows about every existing note — not only the ones this session typed itself
|
|
556
|
+
const note = provenance[adapter]?.[model];
|
|
557
|
+
tiers[adapter][model] = note ? { tier, provenance: note } : { tier };
|
|
558
|
+
}
|
|
555
559
|
}
|
|
556
560
|
return {
|
|
557
561
|
denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
|
|
@@ -649,6 +653,47 @@ export function formatFleetPrint(repoRoot, opts = {}) {
|
|
|
649
653
|
function sortedUnique(xs) {
|
|
650
654
|
return [...new Set(xs)].sort();
|
|
651
655
|
}
|
|
656
|
+
/** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
|
|
657
|
+
* load. yaml.parse discards comments, so before this every fleet write re-serialized the file
|
|
658
|
+
* knowing only the current session's own notes and silently stripped all prior ones — a typed
|
|
659
|
+
* benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
|
|
660
|
+
* overlay is the loader's problem to reject, never the harvester's. */
|
|
661
|
+
export function harvestFleetProvenance(overlayText) {
|
|
662
|
+
const out = { tiers: {}, denyAdapters: {}, denyModels: {} };
|
|
663
|
+
if (!overlayText.trim())
|
|
664
|
+
return out;
|
|
665
|
+
const doc = parseDocument(overlayText);
|
|
666
|
+
const note = (n) => {
|
|
667
|
+
const c = isScalar(n) ? n.comment : undefined;
|
|
668
|
+
return typeof c === "string" && c.trim() ? c.trim() : undefined;
|
|
669
|
+
};
|
|
670
|
+
const tiers = doc.getIn(["tiers"]);
|
|
671
|
+
if (isMap(tiers)) {
|
|
672
|
+
for (const ap of tiers.items) {
|
|
673
|
+
if (!isScalar(ap.key))
|
|
674
|
+
continue;
|
|
675
|
+
const models = isMap(ap.value) ? ap.value.get("models") : undefined;
|
|
676
|
+
if (!isMap(models))
|
|
677
|
+
continue;
|
|
678
|
+
for (const mp of models.items) {
|
|
679
|
+
const n = note(mp.value);
|
|
680
|
+
if (isScalar(mp.key) && n)
|
|
681
|
+
(out.tiers[String(ap.key.value)] ??= {})[String(mp.key.value)] = n;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
for (const [key, dest] of [["adapters", out.denyAdapters], ["models", out.denyModels]]) {
|
|
686
|
+
const seq = doc.getIn(["routing", "deny", key]);
|
|
687
|
+
if (!isSeq(seq))
|
|
688
|
+
continue;
|
|
689
|
+
for (const item of seq.items) {
|
|
690
|
+
const n = note(item);
|
|
691
|
+
if (isScalar(item) && n)
|
|
692
|
+
dest[String(item.value)] = n;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
652
697
|
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
653
698
|
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
654
699
|
if (fleetEditableEquals(initial, edited))
|
|
@@ -714,11 +759,11 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
|
714
759
|
out.tiers = tiersOut;
|
|
715
760
|
return out;
|
|
716
761
|
}
|
|
717
|
-
export function repoOverlayYaml(overlay, provenance = {}) {
|
|
762
|
+
export function repoOverlayYaml(overlay, provenance = {}, denyNotes = {}) {
|
|
718
763
|
if (!Object.keys(overlay).length)
|
|
719
764
|
return "";
|
|
720
765
|
const fleet = fleetSubset(overlay);
|
|
721
|
-
const fleetBody = serializeFleetOverlay(fleet, provenance);
|
|
766
|
+
const fleetBody = serializeFleetOverlay(fleet, provenance, denyNotes);
|
|
722
767
|
const rest = { ...overlay };
|
|
723
768
|
for (const k of FLEET_OVERLAY_KEYS)
|
|
724
769
|
delete rest[k];
|
|
@@ -727,25 +772,39 @@ export function repoOverlayYaml(overlay, provenance = {}) {
|
|
|
727
772
|
const head = stringify(rest).trimEnd();
|
|
728
773
|
return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
|
|
729
774
|
}
|
|
730
|
-
export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
775
|
+
export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {}) {
|
|
731
776
|
if (!Object.keys(overlay).length)
|
|
732
777
|
return "";
|
|
733
778
|
const lines = [];
|
|
734
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
735
779
|
// OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
|
|
736
780
|
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
737
781
|
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
738
782
|
const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
|
|
783
|
+
// deny lists emit item-by-item through the same stringify quoting rules as block(), so a
|
|
784
|
+
// harvested `# reason` can re-attach to its exact entry (multi-line emissions never take one)
|
|
785
|
+
const denySeq = (key, v) => {
|
|
786
|
+
if (v === undefined)
|
|
787
|
+
return [];
|
|
788
|
+
if (v === null || !v.length)
|
|
789
|
+
return block({ [key]: v }, " ");
|
|
790
|
+
const out = [` ${key}:`];
|
|
791
|
+
for (const item of v) {
|
|
792
|
+
const emitted = stringify([item]).trimEnd().split("\n");
|
|
793
|
+
const n = denyNotes[key]?.[item];
|
|
794
|
+
if (n && emitted.length === 1)
|
|
795
|
+
emitted[0] += ` # ${n}`;
|
|
796
|
+
out.push(...emitted.map((l) => ` ${l}`));
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
};
|
|
739
800
|
const routing = overlay.routing;
|
|
740
801
|
if (routing) {
|
|
741
802
|
lines.push("routing:");
|
|
742
803
|
const deny = routing.deny;
|
|
743
804
|
if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
|
|
744
805
|
lines.push(" deny:");
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (deny.models !== undefined)
|
|
748
|
-
lines.push(...block({ models: deny.models }, " "));
|
|
806
|
+
lines.push(...denySeq("adapters", deny.adapters));
|
|
807
|
+
lines.push(...denySeq("models", deny.models));
|
|
749
808
|
}
|
|
750
809
|
if (routing.map)
|
|
751
810
|
lines.push(...block({ map: routing.map }, " "));
|
|
@@ -767,13 +826,11 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
|
767
826
|
if (models.length) {
|
|
768
827
|
body.push(" models:");
|
|
769
828
|
for (const [model, tier] of models) {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
body.push(` ${model}: ${tier}${suffix}`);
|
|
776
|
-
}
|
|
829
|
+
// OBS-88: notes serialize verbatim (fresh session notes arrive pre-stamped with their
|
|
830
|
+
// "— fleet <date>" suffix), so a harvested note round-trips byte-for-byte every write
|
|
831
|
+
const note = provenance[adapter]?.[model];
|
|
832
|
+
const suffix = note ? ` # ${note}` : "";
|
|
833
|
+
body.push(` ${model}: ${tier === null ? "null" : tier}${suffix}`);
|
|
777
834
|
}
|
|
778
835
|
}
|
|
779
836
|
else if (entry.models) {
|
|
@@ -789,10 +846,20 @@ export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
|
789
846
|
return `${lines.join("\n")}\n`;
|
|
790
847
|
}
|
|
791
848
|
export function unifiedYamlDiff(before, after, label = "config overlay") {
|
|
792
|
-
const a = before.split("\n");
|
|
793
|
-
const b = after.split("\n");
|
|
794
849
|
if (before === after)
|
|
795
850
|
return "";
|
|
851
|
+
const a = before.split("\n");
|
|
852
|
+
const b = after.split("\n");
|
|
853
|
+
// v1.60 T3: shortest-edit (LCS) matching. The old scan resynced greedily on the first mismatched
|
|
854
|
+
// line, so one inserted line could cascade into a whole-file remove/re-add hunk on the one
|
|
855
|
+
// confirmation surface an operator reviews before a write.
|
|
856
|
+
// ponytail: O(n·m) table — overlays are tens of lines; Myers O(nd) if files ever grow.
|
|
857
|
+
const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
|
|
858
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
859
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
860
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
796
863
|
const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
|
|
797
864
|
const hunks = [];
|
|
798
865
|
let i = 0;
|
|
@@ -803,17 +870,15 @@ export function unifiedYamlDiff(before, after, label = "config overlay") {
|
|
|
803
870
|
j++;
|
|
804
871
|
continue;
|
|
805
872
|
}
|
|
806
|
-
const
|
|
807
|
-
const
|
|
808
|
-
while (i < a.length && (j
|
|
809
|
-
i
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
for (let k = startJ; k < j; k++)
|
|
816
|
-
hunks.push(`+${b[k]}`);
|
|
873
|
+
const del = [];
|
|
874
|
+
const add = [];
|
|
875
|
+
while ((i < a.length || j < b.length) && !(i < a.length && j < b.length && a[i] === b[j])) {
|
|
876
|
+
if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1]))
|
|
877
|
+
del.push(`-${a[i++]}`);
|
|
878
|
+
else
|
|
879
|
+
add.push(`+${b[j++]}`);
|
|
880
|
+
}
|
|
881
|
+
hunks.push("@@", ...del, ...add);
|
|
817
882
|
}
|
|
818
883
|
return `${header.join("\n")}\n${hunks.join("\n")}\n`;
|
|
819
884
|
}
|
package/dist/route/router.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Assignment, type BillingChannel } from "../adapters/types.js";
|
|
2
|
-
import { type TickmarkrConfig
|
|
2
|
+
import { type TickmarkrConfig } from "../config/config.js";
|
|
3
3
|
import type { Task } from "../graph/schema.js";
|
|
4
4
|
import { type RoutingProfile } from "./profile.js";
|
|
5
5
|
export type LadderStep = "retry" | "escalate" | "consult" | "human";
|
|
@@ -28,12 +28,10 @@ export interface RoutingPreferContext {
|
|
|
28
28
|
}
|
|
29
29
|
export interface ExploreContext {
|
|
30
30
|
noExplore?: boolean;
|
|
31
|
-
quality?: boolean;
|
|
32
31
|
}
|
|
33
32
|
export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
34
33
|
export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
35
34
|
export declare const ROUTING_ENV_SEAMS: readonly ["TICKMARKR_QUALITY", "TICKMARKR_NO_EXPLORE"];
|
|
36
|
-
export declare const raiseTier: (tier: Tier) => Tier;
|
|
37
35
|
export declare class RoutingError extends Error {
|
|
38
36
|
constructor(msg: string);
|
|
39
37
|
}
|
package/dist/route/router.js
CHANGED
|
@@ -3,15 +3,18 @@ import { TIER_RANK } from "../config/config.js";
|
|
|
3
3
|
import { disallowedBy } from "./preference.js";
|
|
4
4
|
import { cellOf, EXPLORE_CAP, explorationBonus, learnedScore, MIN_SAMPLES } from "./profile.js";
|
|
5
5
|
export const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
|
|
6
|
+
// OBS-89 (v1.60): the TICKMARKR_QUALITY variable is RETIRED — nothing in src reads it anymore and
|
|
7
|
+
// route() ignores it entirely (the v1.47 one-band floor raise, its exploration suppression, and the
|
|
8
|
+
// env fallback were deleted, not moved). The name survives solely as a scrub target: the spawn seam
|
|
9
|
+
// keeps erasing a stale operator shell's legacy export from child environments, and the retired-seam
|
|
10
|
+
// tests (tests/run/git.test.ts, tests/setup.ts, tests/gates/baseline.test.ts) pin that contract.
|
|
6
11
|
export const QUALITY_ENV = "TICKMARKR_QUALITY";
|
|
7
12
|
// OBS-74: every routing env seam, in one list — the spawn seam (src/run/git.ts) scrubs exactly
|
|
8
13
|
// these from child env so gate/baseline/tip-verify children are hermetic by construction.
|
|
9
14
|
export const ROUTING_ENV_SEAMS = [QUALITY_ENV, NO_EXPLORE_ENV];
|
|
10
15
|
const exploreCap = (cfg) => cfg.routing.explore?.cap ?? EXPLORE_CAP;
|
|
11
|
-
const qualityOn = (exploreCtx) => !!exploreCtx?.quality || process.env[QUALITY_ENV] === "1";
|
|
12
|
-
export const raiseTier = (tier) => tier === "cheap" ? "mid" : tier === "mid" ? "frontier" : "frontier";
|
|
13
16
|
const exploreOff = (task, cfg, exploreCtx) => {
|
|
14
|
-
if (
|
|
17
|
+
if (exploreCtx?.noExplore || process.env[NO_EXPLORE_ENV] === "1")
|
|
15
18
|
return true;
|
|
16
19
|
const e = cfg.routing.explore;
|
|
17
20
|
if (!e)
|
|
@@ -110,15 +113,6 @@ function withoutExcluded(channels, exclude) {
|
|
|
110
113
|
// OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
|
|
111
114
|
return channels.filter((c) => !exclude.has(channelKey(c)));
|
|
112
115
|
}
|
|
113
|
-
const qualityBound = (quality, configFloor, taskFloor) => {
|
|
114
|
-
if (!quality)
|
|
115
|
-
return undefined;
|
|
116
|
-
if (configFloor)
|
|
117
|
-
return `floor ${configFloor}→${raiseTier(configFloor)} (--quality)`;
|
|
118
|
-
if (taskFloor)
|
|
119
|
-
return `floor ${taskFloor}→${raiseTier(taskFloor)} (--quality)`;
|
|
120
|
-
return undefined;
|
|
121
|
-
};
|
|
122
116
|
const maybeSlaLint = (lints, task, profile, slaMinutes, c) => {
|
|
123
117
|
if (slaMinutes === undefined || !profile)
|
|
124
118
|
return;
|
|
@@ -135,7 +129,6 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
135
129
|
channels = withoutExcluded(channels, exclude);
|
|
136
130
|
const lints = [];
|
|
137
131
|
const advisoryFloor = cfg.routing.floors[task.shape];
|
|
138
|
-
const quality = qualityOn(exploreCtx);
|
|
139
132
|
const floor = advisoryFloor;
|
|
140
133
|
const slaMinutes = cfg.routing.sla?.[task.shape];
|
|
141
134
|
// ponytail: sla is plan-time advisory only — never thread into learnedScore (would reroute warm rivals).
|
|
@@ -173,8 +166,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
173
166
|
lints.push(`${task.id} (${task.shape}): ${what} routes ${tier}, below advisory floor ${advisoryFloor}`);
|
|
174
167
|
}
|
|
175
168
|
};
|
|
176
|
-
const
|
|
177
|
-
const taskFloor = taskFloorRaw && quality ? raiseTier(taskFloorRaw) : taskFloorRaw;
|
|
169
|
+
const taskFloor = task.routingHints?.floor;
|
|
178
170
|
const source = task.routingHints?.source;
|
|
179
171
|
const src = source ? `, ${source}` : ""; // never interpolate a possibly-undefined source
|
|
180
172
|
// task pin: planner-authored, try-first — degrades on miss or below-floor (D-05, research A3), never throws
|
|
@@ -201,12 +193,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
201
193
|
return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
|
|
202
194
|
}
|
|
203
195
|
const baseTier = floor ?? "cheap";
|
|
204
|
-
|
|
205
|
-
if (quality && advisoryFloor) {
|
|
206
|
-
const raised = raiseTier(advisoryFloor);
|
|
207
|
-
if (TIER_RANK[raised] > TIER_RANK[minTier])
|
|
208
|
-
minTier = raised;
|
|
209
|
-
}
|
|
196
|
+
const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
|
|
210
197
|
if (prefActive)
|
|
211
198
|
for (const p of prefer ?? [])
|
|
212
199
|
preflightPrefer(p);
|
|
@@ -301,10 +288,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
|
|
|
301
288
|
eligible = spreadStatic(sortedStatic);
|
|
302
289
|
spreadDecided = channelKey(eligible[0]) !== channelKey(sortedStatic[0]);
|
|
303
290
|
}
|
|
304
|
-
const bound =
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
"tier cheap (default)");
|
|
291
|
+
const bound = taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
|
|
292
|
+
floor ? `floor ${floor} (config floors)` :
|
|
293
|
+
"tier cheap (default)";
|
|
308
294
|
// name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
|
|
309
295
|
// winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
|
|
310
296
|
const preferVia = preferFromAuto(task.shape, preferCtx)
|
package/dist/run/daemon.js
CHANGED
|
@@ -19,7 +19,7 @@ import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYO
|
|
|
19
19
|
import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
|
|
20
20
|
import { acquireRunLock, releaseRunLock } from "./lock.js";
|
|
21
21
|
import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
|
|
22
|
-
import { nextChannel,
|
|
22
|
+
import { nextChannel, route } from "../route/router.js";
|
|
23
23
|
import { desiredPanes } from "./reconcile.js";
|
|
24
24
|
import { normalizeStallSnapshot } from "./stall.js";
|
|
25
25
|
const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
|
|
@@ -90,9 +90,8 @@ async function cherryPickCommits(wt, commits) {
|
|
|
90
90
|
return carried;
|
|
91
91
|
}
|
|
92
92
|
export async function runDaemon(repoRoot, opts = {}) {
|
|
93
|
-
// v1.51 T2: retired --quality env seam. Mode resolution owns premium routing
|
|
94
|
-
//
|
|
95
|
-
delete process.env[QUALITY_ENV];
|
|
93
|
+
// v1.51 T2 / OBS-89 (v1.60): retired --quality env seam. Mode resolution owns premium routing;
|
|
94
|
+
// route() no longer reads the retired env at all, so the old entrypoint scrub is gone with it.
|
|
96
95
|
const adapters = opts.adapters ?? allAdapters();
|
|
97
96
|
const health = readDoctor(repoRoot) ?? (await probeAll(adapters));
|
|
98
97
|
const driver = opts.driver ?? new SubprocessDriver();
|