tickmarkr 1.59.0 → 1.61.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 +26 -9
- package/dist/compile/native.d.ts +1 -0
- package/dist/compile/native.js +32 -0
- package/dist/config/config.d.ts +3 -10
- package/dist/config/config.js +10 -187
- package/dist/config/fleet-overlay.d.ts +27 -0
- package/dist/config/fleet-overlay.js +249 -0
- 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/floor/prefer precedence, review and consult steering syntax,
|
|
164
|
+
provenance rules, and `--quality` / `--mode` flags are documented in
|
|
165
|
+
**[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/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](https://github.com/alzahrani-khalid/tickmarkr/blob/main/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](https://github.com/alzahrani-khalid/tickmarkr/blob/main/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,24 @@ 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
|
+
};
|
|
70
|
+
// a designed human gate parks the task BEFORE any gate result exists (daemon.ts execTask), so the
|
|
71
|
+
// awaited approval is named from task state alone — never from gate results. Failed gates win the
|
|
72
|
+
// cell when present: a post-approval park is not awaiting the designed gate. Plain text for column
|
|
73
|
+
// math; rendered with a dim dot + warn words. TTY-only — the non-TTY surface stays byte-pinned.
|
|
74
|
+
const humanGateSuffix = (t, st, states) => st === "human" && t.humanGate && failedGates(states).length === 0 ? " · awaiting approval" : "";
|
|
63
75
|
const shortGoal = (goal, max) => {
|
|
64
76
|
const clause = goal.split(/[,;.?!]/, 1)[0].trim();
|
|
65
77
|
if (clause.length <= max)
|
|
@@ -193,19 +205,24 @@ const renderFrame = (cwd) => {
|
|
|
193
205
|
: `no runs yet${dot}`) +
|
|
194
206
|
`${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
|
|
195
207
|
const hr = rule(Math.min(width, 100));
|
|
196
|
-
const gatesLegend = legend(` gates: ${GATE_NAMES.
|
|
208
|
+
const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
|
|
197
209
|
const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
|
|
198
210
|
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));
|
|
211
|
+
const stW = Math.max(...cells.map((c) => (String(c.st) + c.label + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states)).length));
|
|
200
212
|
const chainW = gateChainWidth(true);
|
|
201
213
|
const assignW = Math.max(...cells.map((c) => c.assignCol.length));
|
|
202
214
|
const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
|
|
203
215
|
const rows = cells.map(({ t, st, label, assignCol, states }) => {
|
|
204
216
|
const goal = shortGoal(t.goal, goalW).padEnd(goalW);
|
|
205
217
|
const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
|
|
218
|
+
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
219
|
+
const f = failedGates(states);
|
|
220
|
+
const human = humanGateSuffix(t, st, states);
|
|
206
221
|
const statusCell = stWord +
|
|
207
222
|
(label ? (label === " starved" ? fail(label) : dim(label)) : "") +
|
|
208
|
-
" "
|
|
223
|
+
(f.length ? dim(" · ") + fail(f.join(", ")) : "") +
|
|
224
|
+
(human ? dim(" · ") + warn("awaiting approval") : "") +
|
|
225
|
+
" ".repeat(stW - (String(st) + label + failedSuffix(states) + human).length);
|
|
209
226
|
return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
|
|
210
227
|
});
|
|
211
228
|
return [header, hr, gatesLegend, ...rows].join("\n");
|
package/dist/compile/native.d.ts
CHANGED
package/dist/compile/native.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import picomatch from "picomatch";
|
|
2
3
|
import { GATE_NAMES, GRAPH_ROUTING_MODES, ORACLES, SHAPES, TIERS, validateGraph } from "../graph/schema.js";
|
|
3
4
|
import { CompileError, inferShape, sha256 } from "./common.js";
|
|
5
|
+
// OBS-97: mirror of the vitest.config.ts suite include — the only path class vitest collects.
|
|
6
|
+
export const COLLECTABLE_TESTS = "tests/**/*.test.ts";
|
|
4
7
|
export const LEGACY_PREFIX = ["dro", "vr"].join("");
|
|
5
8
|
export const TICKMARKR_NATIVE_MARKER = /^<!--\s*tickmarkr:spec(?:\s+v1)?\s*-->\s*$/m;
|
|
6
9
|
export const NATIVE_MARKER = new RegExp(`^<!--\\s*(?:tickmarkr|${LEGACY_PREFIX}):spec(?:\\s+v1)?\\s*-->\\s*$`, "m");
|
|
@@ -116,6 +119,35 @@ export function compileNative(file) {
|
|
|
116
119
|
`Add to each section in ${file}:\n- acceptance:\n - <observable outcome>`);
|
|
117
120
|
}
|
|
118
121
|
const csv = (value) => (value && value.toLowerCase() !== "none" ? value.split(",").map((item) => item.trim()).filter(Boolean) : []);
|
|
122
|
+
// OBS-97: a typed test: oracle needs a collectable home. vitest only collects COLLECTABLE_TESTS
|
|
123
|
+
// paths, so a task whose non-empty files[] cannot host one makes scope-green and acceptance-green
|
|
124
|
+
// mutually exclusive by construction — run-20260719-210434 burned two dispatch attempts before a
|
|
125
|
+
// consult diagnosed exactly this. Empty files[] stays exempt: no file scope means unrestricted
|
|
126
|
+
// (src/gates/scope.ts). An entry hosts a collectable path iff its glob can produce one — probed by
|
|
127
|
+
// substituting each wildcard run with a test-shaped segment (also covers literal test-file paths).
|
|
128
|
+
const collectable = picomatch(COLLECTABLE_TESTS, { dot: true });
|
|
129
|
+
// Single-token substitution is not a true glob-overlap test (tests/**/*.ts needs "probe.test",
|
|
130
|
+
// a bare ** needs the whole collectable path) — probe with several test-shaped tokens and accept
|
|
131
|
+
// if ANY candidate satisfies both globs. Scopes that truly cannot host one still fail every probe.
|
|
132
|
+
const PROBE_TOKENS = ["probe.test.ts", "probe.test", "tests/probe.test.ts"];
|
|
133
|
+
const canHostTest = (entry) => {
|
|
134
|
+
const self = picomatch(entry, { dot: true });
|
|
135
|
+
return PROBE_TOKENS.some((token) => {
|
|
136
|
+
const probe = entry.replace(/\*+/g, token);
|
|
137
|
+
return self(probe) && collectable(probe);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const homeless = drafts
|
|
141
|
+
.filter((draft) => {
|
|
142
|
+
const files = csv(draft.fields.files);
|
|
143
|
+
return files.length > 0 && !files.some(canHostTest)
|
|
144
|
+
&& draft.acceptance.some((item) => typeof item !== "string" && item.oracle === "test");
|
|
145
|
+
})
|
|
146
|
+
.map((draft) => draft.id);
|
|
147
|
+
if (homeless.length) {
|
|
148
|
+
throw new CompileError(`OBS-97: task${homeless.length === 1 ? "" : "s"} ${homeless.join(", ")} carr${homeless.length === 1 ? "ies" : "y"} a test: acceptance oracle but files[] cannot host a vitest-collectable test path (${COLLECTABLE_TESTS}).\n` +
|
|
149
|
+
`Add a ${COLLECTABLE_TESTS} entry to files[] in ${file}, or replace the test: oracle with command:/judge:.`);
|
|
150
|
+
}
|
|
119
151
|
const tasks = drafts.map((draft) => {
|
|
120
152
|
const { fields } = draft;
|
|
121
153
|
const shape = fields.shape ?? inferShape(draft.title);
|
package/dist/config/config.d.ts
CHANGED
|
@@ -235,8 +235,8 @@ export type InitConfigOverlay = {
|
|
|
235
235
|
};
|
|
236
236
|
};
|
|
237
237
|
export declare function configTemplate(overlay?: InitConfigOverlay): string;
|
|
238
|
-
|
|
239
|
-
export
|
|
238
|
+
export { FLEET_OVERLAY_KEYS, fleetEditableEquals, fleetRepoOverlayFromDelta, harvestFleetProvenance, repoOverlayYaml, serializeFleetOverlay, unifiedYamlDiff, } from "./fleet-overlay.js";
|
|
239
|
+
export type { FleetDenyNotes, HarvestedProvenance } from "./fleet-overlay.js";
|
|
240
240
|
export type FleetTierAssignment = {
|
|
241
241
|
tier: Tier;
|
|
242
242
|
provenance?: string;
|
|
@@ -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,3 @@ 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
|
-
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
263
|
-
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>>): string;
|
|
265
|
-
export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>): string;
|
|
266
|
-
export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
|
|
267
|
-
export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
|
|
268
|
-
export {};
|
package/dist/config/config.js
CHANGED
|
@@ -533,8 +533,9 @@ export function configTemplate(overlay) {
|
|
|
533
533
|
const nl = base.indexOf("\n");
|
|
534
534
|
return `${base.slice(0, nl + 1)}${lines.join("\n")}\n${base.slice(nl + 1)}`;
|
|
535
535
|
}
|
|
536
|
-
|
|
537
|
-
|
|
536
|
+
// v1.61 seed 8: the fleet-overlay provenance cluster (harvesting, serialization, diff rendering)
|
|
537
|
+
// moved to fleet-overlay.ts as a pure move — re-exported here so prior import paths keep working.
|
|
538
|
+
export { FLEET_OVERLAY_KEYS, fleetEditableEquals, fleetRepoOverlayFromDelta, harvestFleetProvenance, repoOverlayYaml, serializeFleetOverlay, unifiedYamlDiff, } from "./fleet-overlay.js";
|
|
538
539
|
export function repoOverlayPath(repoRoot) {
|
|
539
540
|
return join(repoRoot, stateDirName(repoRoot), "config.yaml");
|
|
540
541
|
}
|
|
@@ -546,12 +547,16 @@ export function readOverlayFile(path) {
|
|
|
546
547
|
throw new ConfigError(`invalid overlay at ${path}`);
|
|
547
548
|
return raw;
|
|
548
549
|
}
|
|
549
|
-
export function fleetEditableFromConfig(cfg) {
|
|
550
|
+
export function fleetEditableFromConfig(cfg, provenance = {}) {
|
|
550
551
|
const tiers = {};
|
|
551
552
|
for (const [adapter, entry] of Object.entries(cfg.tiers)) {
|
|
552
553
|
tiers[adapter] = {};
|
|
553
|
-
for (const [model, tier] of Object.entries(entry.models))
|
|
554
|
-
|
|
554
|
+
for (const [model, tier] of Object.entries(entry.models)) {
|
|
555
|
+
// OBS-88: notes harvested from the overlay ride the session state from load, so a later
|
|
556
|
+
// write knows about every existing note — not only the ones this session typed itself
|
|
557
|
+
const note = provenance[adapter]?.[model];
|
|
558
|
+
tiers[adapter][model] = note ? { tier, provenance: note } : { tier };
|
|
559
|
+
}
|
|
555
560
|
}
|
|
556
561
|
return {
|
|
557
562
|
denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
|
|
@@ -561,14 +566,6 @@ export function fleetEditableFromConfig(cfg) {
|
|
|
561
566
|
floors: { ...cfg.routing.floors },
|
|
562
567
|
};
|
|
563
568
|
}
|
|
564
|
-
function fleetSubset(obj) {
|
|
565
|
-
const out = {};
|
|
566
|
-
for (const k of FLEET_OVERLAY_KEYS) {
|
|
567
|
-
if (obj[k] !== undefined)
|
|
568
|
-
out[k] = obj[k];
|
|
569
|
-
}
|
|
570
|
-
return out;
|
|
571
|
-
}
|
|
572
569
|
/** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
|
|
573
570
|
export function fleetKeyLayer(repoRoot, dotted, opts = {}) {
|
|
574
571
|
const gdir = opts.globalDir ?? globalConfigDir();
|
|
@@ -646,177 +643,3 @@ export function formatFleetPrint(repoRoot, opts = {}) {
|
|
|
646
643
|
}
|
|
647
644
|
return `${lines.join("\n")}\n`;
|
|
648
645
|
}
|
|
649
|
-
function sortedUnique(xs) {
|
|
650
|
-
return [...new Set(xs)].sort();
|
|
651
|
-
}
|
|
652
|
-
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
653
|
-
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
654
|
-
if (fleetEditableEquals(initial, edited))
|
|
655
|
-
return {};
|
|
656
|
-
const out = structuredClone(existingRepo);
|
|
657
|
-
const routing = { ...out.routing };
|
|
658
|
-
let routingTouched = false;
|
|
659
|
-
const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
|
|
660
|
-
|| sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
|
|
661
|
-
if (denyChanged) {
|
|
662
|
-
routing.deny = {
|
|
663
|
-
adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
|
|
664
|
-
models: edited.denyModels.length ? edited.denyModels : null,
|
|
665
|
-
};
|
|
666
|
-
routingTouched = true;
|
|
667
|
-
}
|
|
668
|
-
const mapDelta = {};
|
|
669
|
-
for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
|
|
670
|
-
if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape]))
|
|
671
|
-
mapDelta[shape] = edited.map[shape];
|
|
672
|
-
}
|
|
673
|
-
if (Object.keys(mapDelta).length) {
|
|
674
|
-
routing.map = { ...routing.map, ...mapDelta };
|
|
675
|
-
routingTouched = true;
|
|
676
|
-
}
|
|
677
|
-
const floorDelta = {};
|
|
678
|
-
for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
|
|
679
|
-
if (initial.floors[shape] !== edited.floors[shape])
|
|
680
|
-
floorDelta[shape] = edited.floors[shape];
|
|
681
|
-
}
|
|
682
|
-
if (Object.keys(floorDelta).length) {
|
|
683
|
-
routing.floors = { ...routing.floors, ...floorDelta };
|
|
684
|
-
routingTouched = true;
|
|
685
|
-
}
|
|
686
|
-
if (routingTouched)
|
|
687
|
-
out.routing = routing;
|
|
688
|
-
const tiersOut = {
|
|
689
|
-
...out.tiers,
|
|
690
|
-
};
|
|
691
|
-
let tiersTouched = false;
|
|
692
|
-
const adapters = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
|
|
693
|
-
for (const adapter of adapters) {
|
|
694
|
-
const models = new Set([
|
|
695
|
-
...Object.keys(initial.tiers[adapter] ?? {}),
|
|
696
|
-
...Object.keys(edited.tiers[adapter] ?? {}),
|
|
697
|
-
]);
|
|
698
|
-
const modelDelta = {};
|
|
699
|
-
for (const model of models) {
|
|
700
|
-
const a = initial.tiers[adapter]?.[model];
|
|
701
|
-
const b = edited.tiers[adapter]?.[model];
|
|
702
|
-
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
|
703
|
-
modelDelta[model] = b === null || b === undefined ? null : b.tier;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
if (Object.keys(modelDelta).length) {
|
|
707
|
-
// spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
|
|
708
|
-
// makes the overlay unloadable for any adapter without a default seed (reload-guard class)
|
|
709
|
-
tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
|
|
710
|
-
tiersTouched = true;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
if (tiersTouched)
|
|
714
|
-
out.tiers = tiersOut;
|
|
715
|
-
return out;
|
|
716
|
-
}
|
|
717
|
-
export function repoOverlayYaml(overlay, provenance = {}) {
|
|
718
|
-
if (!Object.keys(overlay).length)
|
|
719
|
-
return "";
|
|
720
|
-
const fleet = fleetSubset(overlay);
|
|
721
|
-
const fleetBody = serializeFleetOverlay(fleet, provenance);
|
|
722
|
-
const rest = { ...overlay };
|
|
723
|
-
for (const k of FLEET_OVERLAY_KEYS)
|
|
724
|
-
delete rest[k];
|
|
725
|
-
if (!Object.keys(rest).length)
|
|
726
|
-
return fleetBody;
|
|
727
|
-
const head = stringify(rest).trimEnd();
|
|
728
|
-
return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
|
|
729
|
-
}
|
|
730
|
-
export function serializeFleetOverlay(overlay, provenance = {}) {
|
|
731
|
-
if (!Object.keys(overlay).length)
|
|
732
|
-
return "";
|
|
733
|
-
const lines = [];
|
|
734
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
735
|
-
// OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
|
|
736
|
-
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
737
|
-
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
738
|
-
const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
|
|
739
|
-
const routing = overlay.routing;
|
|
740
|
-
if (routing) {
|
|
741
|
-
lines.push("routing:");
|
|
742
|
-
const deny = routing.deny;
|
|
743
|
-
if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
|
|
744
|
-
lines.push(" deny:");
|
|
745
|
-
if (deny.adapters !== undefined)
|
|
746
|
-
lines.push(...block({ adapters: deny.adapters }, " "));
|
|
747
|
-
if (deny.models !== undefined)
|
|
748
|
-
lines.push(...block({ models: deny.models }, " "));
|
|
749
|
-
}
|
|
750
|
-
if (routing.map)
|
|
751
|
-
lines.push(...block({ map: routing.map }, " "));
|
|
752
|
-
if (routing.floors)
|
|
753
|
-
lines.push(...block({ floors: routing.floors }, " "));
|
|
754
|
-
}
|
|
755
|
-
const tiers = overlay.tiers;
|
|
756
|
-
if (tiers && Object.keys(tiers).length) {
|
|
757
|
-
lines.push("tiers:");
|
|
758
|
-
for (const [adapter, entry] of Object.entries(tiers)) {
|
|
759
|
-
const body = [];
|
|
760
|
-
if (entry.vendor)
|
|
761
|
-
body.push(` vendor: ${entry.vendor}`);
|
|
762
|
-
if (entry.channel)
|
|
763
|
-
body.push(` channel: ${entry.channel}`);
|
|
764
|
-
if (entry.windows)
|
|
765
|
-
body.push(...block({ windows: entry.windows }, " "));
|
|
766
|
-
const models = Object.entries(entry.models ?? {});
|
|
767
|
-
if (models.length) {
|
|
768
|
-
body.push(" models:");
|
|
769
|
-
for (const [model, tier] of models) {
|
|
770
|
-
if (tier === null)
|
|
771
|
-
body.push(` ${model}: null`);
|
|
772
|
-
else {
|
|
773
|
-
const note = provenance[adapter]?.[model];
|
|
774
|
-
const suffix = note ? ` # ${note} — fleet ${today}` : "";
|
|
775
|
-
body.push(` ${model}: ${tier}${suffix}`);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
else if (entry.models) {
|
|
780
|
-
body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
|
|
781
|
-
}
|
|
782
|
-
// a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
|
|
783
|
-
if (body.length)
|
|
784
|
-
lines.push(` ${adapter}:`, ...body);
|
|
785
|
-
else
|
|
786
|
-
lines.push(` ${adapter}: {}`);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
return `${lines.join("\n")}\n`;
|
|
790
|
-
}
|
|
791
|
-
export function unifiedYamlDiff(before, after, label = "config overlay") {
|
|
792
|
-
const a = before.split("\n");
|
|
793
|
-
const b = after.split("\n");
|
|
794
|
-
if (before === after)
|
|
795
|
-
return "";
|
|
796
|
-
const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
|
|
797
|
-
const hunks = [];
|
|
798
|
-
let i = 0;
|
|
799
|
-
let j = 0;
|
|
800
|
-
while (i < a.length || j < b.length) {
|
|
801
|
-
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
802
|
-
i++;
|
|
803
|
-
j++;
|
|
804
|
-
continue;
|
|
805
|
-
}
|
|
806
|
-
const startI = i;
|
|
807
|
-
const startJ = j;
|
|
808
|
-
while (i < a.length && (j >= b.length || a[i] !== b[j]))
|
|
809
|
-
i++;
|
|
810
|
-
while (j < b.length && (i >= a.length || a[i] !== b[j]))
|
|
811
|
-
j++;
|
|
812
|
-
hunks.push("@@");
|
|
813
|
-
for (let k = startI; k < i; k++)
|
|
814
|
-
hunks.push(`-${a[k]}`);
|
|
815
|
-
for (let k = startJ; k < j; k++)
|
|
816
|
-
hunks.push(`+${b[k]}`);
|
|
817
|
-
}
|
|
818
|
-
return `${header.join("\n")}\n${hunks.join("\n")}\n`;
|
|
819
|
-
}
|
|
820
|
-
export function fleetEditableEquals(a, b) {
|
|
821
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
822
|
-
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FleetEditable } from "./config.js";
|
|
2
|
+
/** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
|
|
3
|
+
export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
|
|
4
|
+
/** Trailing `# note` comments on per-entry lines an operator may have hand-written or a prior
|
|
5
|
+
* fleet write stamped: model tier lines (including null tombstones) and deny list items. */
|
|
6
|
+
export type HarvestedProvenance = {
|
|
7
|
+
tiers: Record<string, Record<string, string>>;
|
|
8
|
+
denyAdapters: Record<string, string>;
|
|
9
|
+
denyModels: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
/** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
|
|
12
|
+
* load. yaml.parse discards comments, so before this every fleet write re-serialized the file
|
|
13
|
+
* knowing only the current session's own notes and silently stripped all prior ones — a typed
|
|
14
|
+
* benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
|
|
15
|
+
* overlay is the loader's problem to reject, never the harvester's. */
|
|
16
|
+
export declare function harvestFleetProvenance(overlayText: string): HarvestedProvenance;
|
|
17
|
+
/** Harvested deny-entry notes keyed by the exact entry string, re-attached at serialize time. */
|
|
18
|
+
export type FleetDenyNotes = {
|
|
19
|
+
adapters?: Record<string, string>;
|
|
20
|
+
models?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
23
|
+
export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>): Record<string, unknown>;
|
|
24
|
+
export declare function repoOverlayYaml(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
|
|
25
|
+
export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, string>>, denyNotes?: FleetDenyNotes): string;
|
|
26
|
+
export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
|
|
27
|
+
export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Fleet-overlay provenance cluster (v1.61 seed 8): harvesting, serialization, and diff rendering
|
|
2
|
+
// for the `tickmarkr fleet` write path. Pure move out of config.ts — prior import paths preserved
|
|
3
|
+
// via re-exports there.
|
|
4
|
+
import { isMap, isScalar, isSeq, parseDocument, stringify } from "yaml";
|
|
5
|
+
/** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
|
|
6
|
+
export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
|
|
7
|
+
function fleetSubset(obj) {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const k of FLEET_OVERLAY_KEYS) {
|
|
10
|
+
if (obj[k] !== undefined)
|
|
11
|
+
out[k] = obj[k];
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
function sortedUnique(xs) {
|
|
16
|
+
return [...new Set(xs)].sort();
|
|
17
|
+
}
|
|
18
|
+
/** OBS-88: harvest existing provenance comments from raw repo-overlay bytes at fleet-session
|
|
19
|
+
* load. yaml.parse discards comments, so before this every fleet write re-serialized the file
|
|
20
|
+
* knowing only the current session's own notes and silently stripped all prior ones — a typed
|
|
21
|
+
* benchmark-provenance note survived exactly one write. Fail-open to empty: an unreadable
|
|
22
|
+
* overlay is the loader's problem to reject, never the harvester's. */
|
|
23
|
+
export function harvestFleetProvenance(overlayText) {
|
|
24
|
+
const out = { tiers: {}, denyAdapters: {}, denyModels: {} };
|
|
25
|
+
if (!overlayText.trim())
|
|
26
|
+
return out;
|
|
27
|
+
const doc = parseDocument(overlayText);
|
|
28
|
+
const note = (n) => {
|
|
29
|
+
const c = isScalar(n) ? n.comment : undefined;
|
|
30
|
+
return typeof c === "string" && c.trim() ? c.trim() : undefined;
|
|
31
|
+
};
|
|
32
|
+
const tiers = doc.getIn(["tiers"]);
|
|
33
|
+
if (isMap(tiers)) {
|
|
34
|
+
for (const ap of tiers.items) {
|
|
35
|
+
if (!isScalar(ap.key))
|
|
36
|
+
continue;
|
|
37
|
+
const models = isMap(ap.value) ? ap.value.get("models") : undefined;
|
|
38
|
+
if (!isMap(models))
|
|
39
|
+
continue;
|
|
40
|
+
for (const mp of models.items) {
|
|
41
|
+
const n = note(mp.value);
|
|
42
|
+
if (isScalar(mp.key) && n)
|
|
43
|
+
(out.tiers[String(ap.key.value)] ??= {})[String(mp.key.value)] = n;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const [key, dest] of [["adapters", out.denyAdapters], ["models", out.denyModels]]) {
|
|
48
|
+
const seq = doc.getIn(["routing", "deny", key]);
|
|
49
|
+
if (!isSeq(seq))
|
|
50
|
+
continue;
|
|
51
|
+
for (const item of seq.items) {
|
|
52
|
+
const n = note(item);
|
|
53
|
+
if (isScalar(item) && n)
|
|
54
|
+
dest[String(item.value)] = n;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Build the repo overlay fragment fleet would write for edits since session start. */
|
|
60
|
+
export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
|
|
61
|
+
if (fleetEditableEquals(initial, edited))
|
|
62
|
+
return {};
|
|
63
|
+
const out = structuredClone(existingRepo);
|
|
64
|
+
const routing = { ...out.routing };
|
|
65
|
+
let routingTouched = false;
|
|
66
|
+
const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
|
|
67
|
+
|| sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
|
|
68
|
+
if (denyChanged) {
|
|
69
|
+
routing.deny = {
|
|
70
|
+
adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
|
|
71
|
+
models: edited.denyModels.length ? edited.denyModels : null,
|
|
72
|
+
};
|
|
73
|
+
routingTouched = true;
|
|
74
|
+
}
|
|
75
|
+
const mapDelta = {};
|
|
76
|
+
for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
|
|
77
|
+
if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape]))
|
|
78
|
+
mapDelta[shape] = edited.map[shape];
|
|
79
|
+
}
|
|
80
|
+
if (Object.keys(mapDelta).length) {
|
|
81
|
+
routing.map = { ...routing.map, ...mapDelta };
|
|
82
|
+
routingTouched = true;
|
|
83
|
+
}
|
|
84
|
+
const floorDelta = {};
|
|
85
|
+
for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
|
|
86
|
+
if (initial.floors[shape] !== edited.floors[shape])
|
|
87
|
+
floorDelta[shape] = edited.floors[shape];
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(floorDelta).length) {
|
|
90
|
+
routing.floors = { ...routing.floors, ...floorDelta };
|
|
91
|
+
routingTouched = true;
|
|
92
|
+
}
|
|
93
|
+
if (routingTouched)
|
|
94
|
+
out.routing = routing;
|
|
95
|
+
const tiersOut = {
|
|
96
|
+
...out.tiers,
|
|
97
|
+
};
|
|
98
|
+
let tiersTouched = false;
|
|
99
|
+
const adapters = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
|
|
100
|
+
for (const adapter of adapters) {
|
|
101
|
+
const models = new Set([
|
|
102
|
+
...Object.keys(initial.tiers[adapter] ?? {}),
|
|
103
|
+
...Object.keys(edited.tiers[adapter] ?? {}),
|
|
104
|
+
]);
|
|
105
|
+
const modelDelta = {};
|
|
106
|
+
for (const model of models) {
|
|
107
|
+
const a = initial.tiers[adapter]?.[model];
|
|
108
|
+
const b = edited.tiers[adapter]?.[model];
|
|
109
|
+
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
|
110
|
+
modelDelta[model] = b === null || b === undefined ? null : b.tier;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (Object.keys(modelDelta).length) {
|
|
114
|
+
// spread the existing entry so vendor/channel/windows survive the rewrite — dropping them
|
|
115
|
+
// makes the overlay unloadable for any adapter without a default seed (reload-guard class)
|
|
116
|
+
tiersOut[adapter] = { ...tiersOut[adapter], models: { ...tiersOut[adapter]?.models, ...modelDelta } };
|
|
117
|
+
tiersTouched = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (tiersTouched)
|
|
121
|
+
out.tiers = tiersOut;
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
export function repoOverlayYaml(overlay, provenance = {}, denyNotes = {}) {
|
|
125
|
+
if (!Object.keys(overlay).length)
|
|
126
|
+
return "";
|
|
127
|
+
const fleet = fleetSubset(overlay);
|
|
128
|
+
const fleetBody = serializeFleetOverlay(fleet, provenance, denyNotes);
|
|
129
|
+
const rest = { ...overlay };
|
|
130
|
+
for (const k of FLEET_OVERLAY_KEYS)
|
|
131
|
+
delete rest[k];
|
|
132
|
+
if (!Object.keys(rest).length)
|
|
133
|
+
return fleetBody;
|
|
134
|
+
const head = stringify(rest).trimEnd();
|
|
135
|
+
return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
|
|
136
|
+
}
|
|
137
|
+
export function serializeFleetOverlay(overlay, provenance = {}, denyNotes = {}) {
|
|
138
|
+
if (!Object.keys(overlay).length)
|
|
139
|
+
return "";
|
|
140
|
+
const lines = [];
|
|
141
|
+
// OBS-75: never glue stringify() output onto a key line — wrap the key into the object and
|
|
142
|
+
// re-indent the whole emitted block, so sequences/nested maps nest correctly and null
|
|
143
|
+
// tombstones/empty collections survive the serialize→parse round-trip.
|
|
144
|
+
const block = (obj, pad) => stringify(obj).trimEnd().split("\n").map((l) => `${pad}${l}`);
|
|
145
|
+
// deny lists emit item-by-item through the same stringify quoting rules as block(), so a
|
|
146
|
+
// harvested `# reason` can re-attach to its exact entry (multi-line emissions never take one)
|
|
147
|
+
const denySeq = (key, v) => {
|
|
148
|
+
if (v === undefined)
|
|
149
|
+
return [];
|
|
150
|
+
if (v === null || !v.length)
|
|
151
|
+
return block({ [key]: v }, " ");
|
|
152
|
+
const out = [` ${key}:`];
|
|
153
|
+
for (const item of v) {
|
|
154
|
+
const emitted = stringify([item]).trimEnd().split("\n");
|
|
155
|
+
const n = denyNotes[key]?.[item];
|
|
156
|
+
if (n && emitted.length === 1)
|
|
157
|
+
emitted[0] += ` # ${n}`;
|
|
158
|
+
out.push(...emitted.map((l) => ` ${l}`));
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
};
|
|
162
|
+
const routing = overlay.routing;
|
|
163
|
+
if (routing) {
|
|
164
|
+
lines.push("routing:");
|
|
165
|
+
const deny = routing.deny;
|
|
166
|
+
if (deny && (deny.adapters !== undefined || deny.models !== undefined)) {
|
|
167
|
+
lines.push(" deny:");
|
|
168
|
+
lines.push(...denySeq("adapters", deny.adapters));
|
|
169
|
+
lines.push(...denySeq("models", deny.models));
|
|
170
|
+
}
|
|
171
|
+
if (routing.map)
|
|
172
|
+
lines.push(...block({ map: routing.map }, " "));
|
|
173
|
+
if (routing.floors)
|
|
174
|
+
lines.push(...block({ floors: routing.floors }, " "));
|
|
175
|
+
}
|
|
176
|
+
const tiers = overlay.tiers;
|
|
177
|
+
if (tiers && Object.keys(tiers).length) {
|
|
178
|
+
lines.push("tiers:");
|
|
179
|
+
for (const [adapter, entry] of Object.entries(tiers)) {
|
|
180
|
+
const body = [];
|
|
181
|
+
if (entry.vendor)
|
|
182
|
+
body.push(` vendor: ${entry.vendor}`);
|
|
183
|
+
if (entry.channel)
|
|
184
|
+
body.push(` channel: ${entry.channel}`);
|
|
185
|
+
if (entry.windows)
|
|
186
|
+
body.push(...block({ windows: entry.windows }, " "));
|
|
187
|
+
const models = Object.entries(entry.models ?? {});
|
|
188
|
+
if (models.length) {
|
|
189
|
+
body.push(" models:");
|
|
190
|
+
for (const [model, tier] of models) {
|
|
191
|
+
// OBS-88: notes serialize verbatim (fresh session notes arrive pre-stamped with their
|
|
192
|
+
// "— fleet <date>" suffix), so a harvested note round-trips byte-for-byte every write
|
|
193
|
+
const note = provenance[adapter]?.[model];
|
|
194
|
+
const suffix = note ? ` # ${note}` : "";
|
|
195
|
+
body.push(` ${model}: ${tier === null ? "null" : tier}${suffix}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else if (entry.models) {
|
|
199
|
+
body.push(" models: {}"); // present-but-empty: explicit {} — a childless header parses as null and the loader rejects it
|
|
200
|
+
}
|
|
201
|
+
// a bare `adapter:` line parses as a null tombstone and would DELETE the adapter's default seeds on merge
|
|
202
|
+
if (body.length)
|
|
203
|
+
lines.push(` ${adapter}:`, ...body);
|
|
204
|
+
else
|
|
205
|
+
lines.push(` ${adapter}: {}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return `${lines.join("\n")}\n`;
|
|
209
|
+
}
|
|
210
|
+
export function unifiedYamlDiff(before, after, label = "config overlay") {
|
|
211
|
+
if (before === after)
|
|
212
|
+
return "";
|
|
213
|
+
const a = before.split("\n");
|
|
214
|
+
const b = after.split("\n");
|
|
215
|
+
// v1.60 T3: shortest-edit (LCS) matching. The old scan resynced greedily on the first mismatched
|
|
216
|
+
// line, so one inserted line could cascade into a whole-file remove/re-add hunk on the one
|
|
217
|
+
// confirmation surface an operator reviews before a write.
|
|
218
|
+
// ponytail: O(n·m) table — overlays are tens of lines; Myers O(nd) if files ever grow.
|
|
219
|
+
const lcs = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
|
|
220
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
221
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
222
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
|
|
226
|
+
const hunks = [];
|
|
227
|
+
let i = 0;
|
|
228
|
+
let j = 0;
|
|
229
|
+
while (i < a.length || j < b.length) {
|
|
230
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
231
|
+
i++;
|
|
232
|
+
j++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const del = [];
|
|
236
|
+
const add = [];
|
|
237
|
+
while ((i < a.length || j < b.length) && !(i < a.length && j < b.length && a[i] === b[j])) {
|
|
238
|
+
if (j >= b.length || (i < a.length && lcs[i + 1][j] >= lcs[i][j + 1]))
|
|
239
|
+
del.push(`-${a[i++]}`);
|
|
240
|
+
else
|
|
241
|
+
add.push(`+${b[j++]}`);
|
|
242
|
+
}
|
|
243
|
+
hunks.push("@@", ...del, ...add);
|
|
244
|
+
}
|
|
245
|
+
return `${header.join("\n")}\n${hunks.join("\n")}\n`;
|
|
246
|
+
}
|
|
247
|
+
export function fleetEditableEquals(a, b) {
|
|
248
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
249
|
+
}
|
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();
|