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 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
- `tickmarkr doctor` is a pure sensor; `tickmarkr fleet` is the actuator. It walks six steps —
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 fleet # interactive editor (requires a TTY)
162
- tickmarkr fleet --print # effective fleet state (repo > global > defaults), non-interactive good for CI drift checks
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
- Routing obeys a strict precedence **pin > prefer > floors > marginal-cost auto**:
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
- - **pin** a shape to an exact channel: `map: { plan: { via: claude-code, model: fable } }`
168
- - **prefer**-rank adapters per shape: `map: { implement: { prefer: [cursor-agent, codex] } }`
169
- - **floors** set the minimum capability band per shape (`migration: frontier`, `tests: cheap`);
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
- More levers, all optional and absent-safe (absent config keeps default behavior):
167
+ ## Steering
175
168
 
176
- - `routing.explore` fence the exploration budget: `mode: off`, `excludeShapes`,
177
- `excludeComplexityAtOrAbove`, and a per-channel `cap`; `run --no-explore` disables
178
- exploration probes for a single run
179
- - `tiers.<adapter>.windows`declare context-window sizes per model; doctor grows a window
180
- column and `plan` warns (advisory, never blocking) when a task's payload estimate exceeds
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
- const initial = fleetEditableFromConfig(cfg);
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
- return { out: "fleet: tier reassignment on classified models is not supported in v1 edit config directly", code: 1 };
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
- editable.tiers[a.id][rows[mCursor].model] = { tier, provenance: note };
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
- const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
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 { QUALITY_ENV, route, RoutingError } from "../../route/router.js";
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).
@@ -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, QUALITY_ENV } from "../../route/router.js";
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: the flag never sets ExploreContext.quality or the
29
- // TICKMARKR_QUALITY env, so no downstream code raises a floor on its behalf (proven in mode-sources).
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
- const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => {
58
- const chip = `${GATE_KEYS[gate]}${gateBox(states[i], unicode)}`;
59
- return unicode ? GATE_STATE_TOKEN[states[i]](chip) : chip;
60
- }).join(" ");
61
- // plain (uncolored) chip width for column math — ANSI codes have zero display width
62
- const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + GATE_KEYS[gate].length + (unicode ? 1 : 3) + 1, -1);
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.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(" · ")}`);
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
- " ".repeat(stW - (String(st) + label).length);
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");
@@ -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>>): string;
265
- export declare function serializeFleetOverlay(overlay: Record<string, unknown>, provenance?: Record<string, Record<string, 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 {};
@@ -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
- tiers[adapter][model] = { tier };
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
- if (deny.adapters !== undefined)
746
- lines.push(...block({ adapters: deny.adapters }, " "));
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
- 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
- }
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 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]}`);
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
  }
@@ -1,5 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../adapters/types.js";
2
- import { type TickmarkrConfig, type Tier } from "../config/config.js";
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
  }
@@ -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 (qualityOn(exploreCtx) || exploreCtx?.noExplore || process.env[NO_EXPLORE_ENV] === "1")
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 taskFloorRaw = task.routingHints?.floor;
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
- let minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
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 = qualityBound(quality, advisoryFloor, taskFloorRaw) ??
305
- (taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
306
- floor ? `floor ${floor} (config floors)` :
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)
@@ -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, QUALITY_ENV, route } from "../route/router.js";
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 now; an exported
94
- // TICKMARKR_QUALITY must not resurrect the old one-band floor raise in daemon dispatches.
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",