tickmarkr 2.6.3 → 2.6.4

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.
@@ -1,11 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions, PREFERENCE_ROLES } from "../../adapters/registry.js";
5
5
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
6
  import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
7
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
8
- import { ConfigError, fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, lowerLayerModelOverrides, overlayBytesLoadError, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
8
+ import { ConfigError, DEFAULT_CONFIG, fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, lowerLayerModelOverrides, overlayBytesLoadError, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, repoShadowedUserKeys, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, universeEntryMatches, } from "../../config/config.js";
9
9
  import { exclusionReason, projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { doctor } from "./doctor.js";
@@ -22,6 +22,16 @@ const QUIT = "fleet: quit without writing";
22
22
  // (rankCandidates routes noExplore so repeated calls agree) — a due probe must never make a
23
23
  // step-4/5 row disagree with the picker's rank-1 for the same shape and channel set.
24
24
  const PREVIEW_EXPLORE = { noExplore: true };
25
+ // B2: a routing.map entry's declaration slot (pin/pool/prefer) — an empty prefer declares nothing
26
+ const mapSlot = (entry) => ({
27
+ ...(entry?.pin ? { pin: entry.pin } : {}),
28
+ ...(entry?.pool ? { pool: entry.pool } : {}),
29
+ ...(entry?.prefer?.length ? { prefer: entry.prefer } : {}),
30
+ });
31
+ const slotLabel = (slot) => slot.pin ? `pin ${slot.pin.via}:${slot.pin.model}`
32
+ : slot.pool ? `pool(${slot.pool.mode}·${slot.pool.channels.length})`
33
+ : slot.prefer ? `prefer [${slot.prefer.join(", ")}]`
34
+ : "cleared slot";
25
35
  function previewTask(shape) {
26
36
  return {
27
37
  id: "fleet-preview",
@@ -208,6 +218,10 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
208
218
  * overlay write. */
209
219
  export async function assembleFleetEditor(cwd, adapters, io, opts) {
210
220
  const globalDir = opts.globalDir;
221
+ // B2: the editor's choices are the operator's MACHINE choices — every save lands in the USER
222
+ // overlay, which every repository on this machine inherits. The repo overlay is read, never written.
223
+ const userPath = join(globalDir, "config.yaml");
224
+ const currentUserText = () => (existsSync(userPath) ? readFileSync(userPath, "utf8") : "");
211
225
  const { reuse, health: cached } = initDoctorReuse(cwd, false);
212
226
  if (!reuse || !cached) {
213
227
  return {
@@ -225,10 +239,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
225
239
  // writer emits the minimal allow form from it. The same pool later ranks every preview.
226
240
  const { deny: _diskDeny, allow: _diskAllow, ...routingScopeBlind } = cfg.routing;
227
241
  const previewChannels = discoverChannels({ ...cfg, routing: routingScopeBlind }, adapters, health);
228
- const universe = adapters
242
+ // B2: the universe any scope-blind discovery serves — the assembly's, or a candidate config's at a
243
+ // membership write (current layers plus staged classifications)
244
+ const universeOf = (discovered) => adapters
229
245
  .filter((adapter) => health[adapter.id]?.installed)
230
246
  .map((adapter) => {
231
- const served = previewChannels.filter((c) => c.adapter === adapter.id);
247
+ const served = discovered.filter((c) => c.adapter === adapter.id);
232
248
  // LEG2-T3 round 2 finding 3: the recorded identity per channel, so the editable membership
233
249
  // matches allow/deny entries with the same authority the collector and the browser use
234
250
  const identities = Object.fromEntries(served.flatMap((c) => (c.identity ? [[c.model, c.identity]] : [])));
@@ -238,6 +254,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
238
254
  ...(Object.keys(identities).length ? { identities } : {}),
239
255
  };
240
256
  });
257
+ const universe = universeOf(previewChannels);
241
258
  const initial = fleetEditableFromConfig(cfg, universe);
242
259
  const editable = structuredClone(initial);
243
260
  // OBS-508: the same catalog evidence doctor's drift overlay prints now rides each unclassified
@@ -346,30 +363,122 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
346
363
  const channels = discoverChannels(cfg, adapters, health);
347
364
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
348
365
  const stagedDenyOf = (editable) => Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), editable[scope.key] ?? []]));
349
- // OBS-1188: previews stage no effort, so both sides drop efforts — the writer then needs no lower layers
350
- const previewInitial = { ...structuredClone(initial), efforts: undefined };
366
+ // B2: the classifications and efforts of the last reviewed state — the preview callbacks carry mode,
367
+ // map and deny only (fleet-app's contract), so every preview after a review ranks the same fully
368
+ // staged candidate that review rendered, never the assembly-time tiers.
369
+ // ponytail: one staged after the last review reaches previews at the next w; thread classifications
370
+ // and efforts through fleet-app's preview callbacks if that lag ever matters.
371
+ let reviewedStage = { tiers: initial.tiers, efforts: initial.efforts };
372
+ // OBS-1182: the layers under the written overlay — B2: the defaults alone under the USER overlay —
373
+ // read raw, so clearing an effort masks an inherited one. OBS-1188: re-read at every review and
374
+ // save, never an assembly-time snapshot; an unreadable read keeps the last good one for the
375
+ // preview only — the save guard below refuses it.
376
+ let lowerOverrides = {};
351
377
  const stagedEditable = (map, deny) => ({
352
- ...structuredClone(previewInitial),
378
+ ...structuredClone(initial),
379
+ ...structuredClone(reviewedStage),
353
380
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, deny[stagedDenyKeyOf(scope)]])),
354
381
  // OBS-1046: the allow complement is staged beside the deny lists, never folded into them
355
382
  allowOut: deny.allowOut ?? initial.allowOut,
356
383
  map,
357
384
  });
385
+ // B2: the writer diffs a CHANGED routing.map slot against what its destination declares there —
386
+ // the user layer (defaults + user, no repo) — never the effective slot a repo pin/pool/prefer
387
+ // shadows, which would leave the user's own hidden declaration beside the new one (a stale pin a
388
+ // second repository still routes to, or pin+pool bytes that refuse a valid edit).
389
+ // B2: a touched membership is the same — the writer rewrites the allow form from the whole staged
390
+ // fleet, so its flat deny lists and allow complement diff against the user layer's own; a user
391
+ // deny the repo's list hides is admitted or kept by the staged fleet, never left to re-exclude
392
+ // it in every other repository.
393
+ const membershipKeys = [...DENY_SCOPES.filter((scope) => scope.path.length === 3).map((scope) => scope.key), "allowOut"];
394
+ const sameSet = (a = [], b = []) => [...new Set(a)].sort().join() === [...new Set(b)].sort().join();
395
+ const destinationInitial = (base, edited, userText, target) => {
396
+ let userCfg;
397
+ try {
398
+ userCfg = loadConfigWithMode(cwd, { globalDir, userOverlayText: userText, repoOverlayText: "" }).cfg;
399
+ }
400
+ catch {
401
+ return base; // ponytail: user bytes that load only beside this repo — the reload guard refuses the write
402
+ }
403
+ const userLayer = userCfg.routing.map;
404
+ const editedMap = edited.map;
405
+ const map = { ...base.map };
406
+ for (const shape of new Set([...Object.keys(base.map), ...Object.keys(editedMap)])) {
407
+ if (JSON.stringify(base.map[shape]) === JSON.stringify(editedMap[shape]))
408
+ continue;
409
+ const { pin, pool, prefer } = userLayer[shape] ?? {};
410
+ // escalate is no slot key: it merges per key, so the effective value stays the baseline
411
+ const escalate = base.map[shape]?.escalate;
412
+ map[shape] = {
413
+ ...(pin ? { pin } : {}), ...(pool ? { pool } : {}), ...(prefer ? { prefer } : {}),
414
+ ...(escalate !== undefined ? { escalate } : {}),
415
+ };
416
+ }
417
+ const membershipTouched = membershipKeys.some((key) => !sameSet(base[key], edited[key]));
418
+ const destination = membershipTouched ? fleetEditableFromConfig(userCfg, target) : undefined;
419
+ return {
420
+ ...base,
421
+ map,
422
+ ...(destination ? Object.fromEntries(membershipKeys.map((key) => [key, destination[key] ?? []])) : {}),
423
+ };
424
+ };
425
+ // B2: a changed slot staged with no declaration (auto, no prefer) where the defaults under the user
426
+ // overlay declare one: deleting the user's pin/pool would restore that default, so the slot is
427
+ // masked with prefer: [] — a declaration that replaces the lower slot atomically and ranks nothing
428
+ const maskClearedSlots = (base, staged) => {
429
+ const map = { ...staged.map };
430
+ for (const shape of new Set([...Object.keys(base.map), ...Object.keys(staged.map)])) {
431
+ const entry = staged.map[shape];
432
+ if (JSON.stringify(base.map[shape]) === JSON.stringify(entry))
433
+ continue;
434
+ if (Object.keys(mapSlot(entry)).length || !Object.keys(mapSlot(DEFAULT_CONFIG.routing.map[shape])).length)
435
+ continue;
436
+ map[shape] = { ...entry, prefer: [] };
437
+ }
438
+ return { ...staged, map };
439
+ };
440
+ // B2: the one user-layer write builder the previews and the review share, so a preview ranks the
441
+ // bytes y would land. A touched membership serializes against the universe the candidate itself
442
+ // serves (current layers plus staged classifications), rendered first with membership unchanged;
443
+ // a channel only that universe adds keeps the verdict the current layers' allow form gives it.
444
+ const userLayerWrite = (staged, userText, rest) => {
445
+ let edited = maskClearedSlots(initial, staged);
446
+ let target = universe;
447
+ if (membershipKeys.some((key) => !sameSet(initial[key], edited[key]))) {
448
+ const unchanged = { ...edited, ...Object.fromEntries(membershipKeys.map((key) => [key, initial[key]])) };
449
+ try {
450
+ const bytes = renderFleetOverlayWrite(userText, {
451
+ ...rest, initial: destinationInitial(initial, unchanged, userText, universe), edited: unchanged, universe,
452
+ });
453
+ const candidate = loadConfigWithMode(cwd, { globalDir, userOverlayText: bytes }).cfg;
454
+ target = universeOf(candidateChannels(candidate));
455
+ const known = (entry) => universe.some((row) => entry === row.adapter || row.models.some((model) => universeEntryMatches(row, model, entry)));
456
+ const grown = (fleetEditableFromConfig(candidate, target).allowOut ?? []).filter((entry) => !known(entry));
457
+ if (grown.length)
458
+ edited = { ...edited, allowOut: [...new Set([...(edited.allowOut ?? []), ...grown])].sort() };
459
+ }
460
+ catch {
461
+ // the full render below (or the reload guard) names the refusal
462
+ }
463
+ }
464
+ return { ...rest, initial: destinationInitial(initial, edited, userText, target), edited, universe: target };
465
+ };
358
466
  const candidateMemo = new Map();
359
467
  const previewCfg = (mode, map, deny) => {
360
- const key = JSON.stringify([mode, map, deny]);
468
+ // B2: the user and repo overlay bytes are part of the key — a layer edited mid-session (a repo
469
+ // deny added before a review) re-renders the preview instead of serving the pre-edit one
470
+ const userText = currentUserText();
471
+ const key = JSON.stringify([mode, map, deny, userText, currentRepoOverlayText(cwd)]);
361
472
  const hit = candidateMemo.get(key);
362
473
  if (hit)
363
474
  return hit;
364
475
  let preview;
365
476
  try {
366
- const bytes = renderFleetOverlayWrite(currentRepoOverlayText(cwd), {
367
- initial: previewInitial,
368
- edited: stagedEditable(map, deny),
369
- universe,
477
+ const bytes = renderFleetOverlayWrite(userText, userLayerWrite(stagedEditable(map, deny), userText, {
478
+ lowerOverrides,
370
479
  ...(mode !== rm.mode.mode ? { mode } : {}),
371
- });
372
- preview = { ok: true, cfg: loadConfigWithMode(cwd, { globalDir, repoOverlayText: bytes }).cfg };
480
+ }));
481
+ preview = { ok: true, ...loadConfigWithMode(cwd, { globalDir, userOverlayText: bytes }) };
373
482
  }
374
483
  catch (error) {
375
484
  preview = { ok: false, error: error.message.replace(/\s+/g, " ").trim() };
@@ -384,9 +493,22 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
384
493
  const preview = previewCfg(rm.mode.mode, editable.map, deny);
385
494
  return preview.ok ? { ok: true, routing: preview.cfg.routing } : preview;
386
495
  };
496
+ // B2: the candidate config's own discovery, blind to both membership scopes — classifications and
497
+ // channel metadata (tier, channel) a reloaded layer changed reach routing, ranking and the ledger,
498
+ // never the assembly-time channels. Keyed on the memoized candidate config object.
499
+ const candidateChannelMemo = new WeakMap();
500
+ const candidateChannels = (cfgPreview) => {
501
+ const hit = candidateChannelMemo.get(cfgPreview);
502
+ if (hit)
503
+ return hit;
504
+ const { deny: _deny, allow: _allow, ...blind } = cfgPreview.routing;
505
+ const discovered = discoverChannels({ ...cfgPreview, routing: blind }, adapters, health);
506
+ candidateChannelMemo.set(cfgPreview, discovered);
507
+ return discovered;
508
+ };
387
509
  // route() never deny-filters its pool — that contract lives in discoverChannels — so every
388
510
  // preview call rebuilds the pool the staged deny would discover
389
- const previewPool = (cfgPreview) => previewChannels.filter((c) => disallowedBy(c, cfgPreview.routing) === null);
511
+ const previewPool = (cfgPreview) => candidateChannels(cfgPreview).filter((c) => disallowedBy(c, cfgPreview.routing) === null);
390
512
  const modeSpend = (mode, map, deny) => {
391
513
  const tierCount = {};
392
514
  let subs = 0;
@@ -405,7 +527,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
405
527
  subs += 1;
406
528
  else {
407
529
  apiN += 1;
408
- apiUsd += cfg.pricing[assignment.tier] ?? 0;
530
+ apiUsd += cfgPreview.pricing[assignment.tier] ?? 0;
409
531
  }
410
532
  }
411
533
  catch {
@@ -423,11 +545,16 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
423
545
  parts.push(`${unroutable} unroutable`);
424
546
  return ` mix: ${mix} — ${parts.join(" · ")}`;
425
547
  };
426
- const floorPreview = (mode) => {
548
+ // B2: the floors the candidate user-layer bytes resolve to — the same config the routed mix ranks
549
+ // against, so a repo-declared mode that shadows the user's pick previews no floor change
550
+ const floorPreview = (mode, map, deny) => {
427
551
  if (mode === rm.mode.mode)
428
552
  return [];
553
+ const preview = previewCfg(mode, map, deny);
554
+ if (!preview.ok)
555
+ return []; // modeSpend names the refusal
429
556
  const current = cfg.routing.floors;
430
- const next = modeCfgs[mode].cfg.routing.floors;
557
+ const next = preview.cfg.routing.floors;
431
558
  const changed = SHAPES.filter((shape) => current[shape] !== next[shape]);
432
559
  return [
433
560
  ` floors vs ${rm.mode.mode}:`,
@@ -436,19 +563,23 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
436
563
  : [" (no floor changes)"]),
437
564
  ];
438
565
  };
439
- const whyDeclaration = (shape, mode, map, mapProducedValue) => {
440
- const mapChanged = JSON.stringify(map[shape] ?? {}) !== JSON.stringify(editable.map[shape] ?? {});
566
+ // B2: `resolution` is the candidate user-layer bytes' own mode resolution — a repo-declared mode that
567
+ // shadows the user's pick keeps its provenance, never an operator pin the loader would not apply
568
+ // B2: `entry` is the slot the candidate's loader applies — a staged slot a repo declaration shadows
569
+ // is no operator pin here; the repo's winning declaration keeps its provenance
570
+ const whyDeclaration = (shape, resolution, entry, mapProducedValue) => {
571
+ const mapChanged = JSON.stringify(mapSlot(entry)) !== JSON.stringify(mapSlot(editable.map[shape]));
441
572
  if (mapProducedValue) {
442
573
  return mapChanged
443
574
  ? { operatorPinned: true }
444
575
  : { declaredAt: `routing.map.${shape}` };
445
576
  }
446
- const floorSource = modeCfgs[mode].mode.provenance[shape];
577
+ const floorSource = resolution.provenance[shape];
447
578
  if (floorSource === undefined)
448
579
  return {};
449
580
  if (floorSource === "config floors")
450
581
  return { declaredAt: `routing.floors.${shape}` };
451
- if (mode !== rm.mode.mode)
582
+ if (resolution.mode !== rm.mode.mode)
452
583
  return { operatorPinned: true };
453
584
  return rm.source === "default"
454
585
  ? { declaredAt: `routing.floors.${shape}` }
@@ -460,35 +591,42 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
460
591
  return projectFleetWhy(SHAPES.map((shape) => ({
461
592
  id: shape,
462
593
  effective: previewUnavailable(preview.error),
463
- ...whyDeclaration(shape, mode, map, false),
594
+ ...whyDeclaration(shape, modeCfgs[mode].mode, map[shape], false),
464
595
  })), { repoRoot: cwd, globalDir });
465
596
  }
466
597
  const cfgPreview = preview.cfg;
467
598
  const pool = previewPool(cfgPreview);
468
599
  const values = SHAPES.map((shape) => {
600
+ // B2: annotations and provenance read the slot the loader applies; a staged slot it does not
601
+ // apply (a repo pin/pool/prefer above the user overlay) is named shadowed, never shown as routing
602
+ const entry = cfgPreview.routing.map[shape];
603
+ const staged = mapSlot(map[shape]);
604
+ const shadowed = JSON.stringify(staged) !== JSON.stringify(mapSlot(editable.map[shape]))
605
+ && JSON.stringify(staged) !== JSON.stringify(mapSlot(entry));
606
+ const shadowNote = shadowed ? ` · staged ${slotLabel(staged)} shadowed — not applied in this repository` : "";
469
607
  try {
470
608
  const routed = route(previewTask(shape), cfgPreview, pool, profile, undefined, undefined, PREVIEW_EXPLORE);
471
609
  const assignment = routed.assignment;
472
610
  // OBS-531: a pooled shape used to render only the routed WINNER — indistinguishable from
473
611
  // a pin. The declaration is the operator's decision; the winner is today's dice.
474
- const declaredPool = map[shape]?.pool;
612
+ const declaredPool = entry?.pool;
475
613
  const poolPrefix = declaredPool ? `pool(${declaredPool.mode}·${declaredPool.channels.length}) → ` : "";
476
- const effective = `${poolPrefix}${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfg.pricing)}`;
477
- const mapProducedValue = map[shape]?.pin !== undefined || map[shape]?.pool !== undefined
614
+ const effective = `${poolPrefix}${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}) ${costSignal(assignment, cfgPreview.pricing)}${shadowNote}`;
615
+ const mapProducedValue = entry?.pin !== undefined || entry?.pool !== undefined
478
616
  || routed.provenance.includes("via prefer");
479
617
  return {
480
618
  id: shape,
481
619
  effective,
482
- ...whyDeclaration(shape, mode, map, mapProducedValue),
620
+ ...whyDeclaration(shape, preview.mode, entry, mapProducedValue),
483
621
  };
484
622
  }
485
623
  catch (error) {
486
- const mapProducedValue = map[shape]?.pin !== undefined || map[shape]?.pool !== undefined
487
- || (map[shape]?.prefer?.length ?? 0) > 0;
624
+ const mapProducedValue = entry?.pin !== undefined || entry?.pool !== undefined
625
+ || (entry?.prefer?.length ?? 0) > 0;
488
626
  return {
489
627
  id: shape,
490
- effective: error.message,
491
- ...whyDeclaration(shape, mode, map, mapProducedValue),
628
+ effective: `${error.message}${shadowNote}`,
629
+ ...whyDeclaration(shape, preview.mode, entry, mapProducedValue),
492
630
  setupCommand: "tickmarkr fleet",
493
631
  };
494
632
  }
@@ -508,7 +646,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
508
646
  const excludedNoteFor = (cfgPreview) => {
509
647
  let stagedOut = 0;
510
648
  let denied = 0;
511
- for (const channel of previewChannels) {
649
+ for (const channel of candidateChannels(cfgPreview)) {
512
650
  if (disallowedBy(channel, cfgPreview.routing) === null)
513
651
  continue;
514
652
  if (disallowedBy(channel, cfg.routing) === null)
@@ -534,9 +672,11 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
534
672
  // silent omission is indistinguishable from a bug (OBS-530's lesson, extended past deny/allow).
535
673
  // allow and the unauthed probe already carry a reason through excludedNoteFor above; the rest
536
674
  // are shape-scoped (a pin/pool/floor/prefer excludes only for THIS shape) and belong here.
537
- const shapeExclusionCaptions = (shape, map, cfgPreview) => {
675
+ // B2: captions read the slot the candidate's loader applies — a staged slot a repo declaration
676
+ // shadows excludes nothing here, so it is never named as the reason
677
+ const shapeExclusionCaptions = (shape, cfgPreview) => {
538
678
  const pool = previewPool(cfgPreview);
539
- const entry = map[shape];
679
+ const entry = cfgPreview.routing.map[shape];
540
680
  const captions = [];
541
681
  // 2. pin or pool — a closed candidate set declared for this shape; a channel outside it is
542
682
  // not manageable from the models view, it is a Shapes-view declaration.
@@ -594,10 +734,11 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
594
734
  // all of them, never a first match), the unauthed probe, pin/pool/floor, the task hint and a
595
735
  // deny∩prefer collision it is the target of — or that leaves the shape with no candidate at all.
596
736
  const channelLedger = (shape, map, cfgPreview, offered) => {
597
- const entry = map[shape];
737
+ // B2: reasons name the applied slot; the carried chain below stays the staged one Space edits
738
+ const entry = cfgPreview.routing.map[shape];
598
739
  const floor = cfgPreview.routing.floors[shape];
599
740
  const collisions = denyPreferCollisions(cfgPreview, [shape], health);
600
- const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
741
+ const discovered = new Set(candidateChannels(cfgPreview).map((c) => `${c.adapter}:${c.model}`));
601
742
  // T2 review (D-225): discovery already dropped a channel whose model probe failed — it is
602
743
  // rebuilt as a structured channel from its adapter's own declaration and walks the SAME
603
744
  // reach + collector path as every other row, the auth reason appended LAST; never a bare string
@@ -606,17 +747,17 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
606
747
  return identity ? { ...declared, identity } : declared;
607
748
  };
608
749
  const failedProbes = unauthedModels.filter(({ key }) => !discovered.has(key)).flatMap(({ key, adapter, reason }) => {
609
- const declared = adapters.find((a) => a.id === adapter)?.channels(cfg).find((c) => `${c.adapter}:${c.model}` === key);
750
+ const declared = adapters.find((a) => a.id === adapter)?.channels(cfgPreview).find((c) => `${c.adapter}:${c.model}` === key);
610
751
  return declared ? [{ ...withIdentity(adapter, declared), authReason: `unauthed (${reason}) — re-probe with tickmarkr doctor` }] : [];
611
752
  });
612
753
  // T2 review round 6: an installed-but-unauthed CLI is dropped by discovery AND by the model
613
754
  // probe list, so its configured channels are rebuilt from the adapter's own declaration too —
614
755
  // each greys with its reach and covering entry, the CLI auth reason last; never one bare adapter line
615
- const unauthedCliChannels = unauthedClis.flatMap((adapter) => (adapters.find((a) => a.id === adapter)?.channels(cfg) ?? [])
756
+ const unauthedCliChannels = unauthedClis.flatMap((adapter) => (adapters.find((a) => a.id === adapter)?.channels(cfgPreview) ?? [])
616
757
  .map((declared) => ({ ...withIdentity(adapter, declared), authReason: `${adapter} CLI unauthed — re-probe with tickmarkr doctor` })));
617
758
  // D-225: the ledger is the EXCLUDED half of a closed partition — a channel the picker offers
618
759
  // never greys beside itself; its pin/pool/floor/prefer captions stay in excludedNote
619
- const ledgerChannels = [...previewChannels.map((c) => ({ ...c, authReason: undefined })), ...failedProbes, ...unauthedCliChannels]
760
+ const ledgerChannels = [...candidateChannels(cfgPreview).map((c) => ({ ...c, authReason: undefined })), ...failedProbes, ...unauthedCliChannels]
620
761
  .filter((c) => !offered.has(`${c.adapter}:${c.model}`));
621
762
  const lines = ledgerChannels.flatMap((c) => {
622
763
  const key = `${c.adapter}:${c.model}`;
@@ -652,7 +793,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
652
793
  // OBS-1145 add.1: every carried pool member the picker does not offer leads the ledger in chain
653
794
  // order — one no reason above names still gets its own row — so the picker renders its ordinal
654
795
  // and Space can drop it; a chain member with no row is hidden AND irremovable
655
- const carried = entry?.pool?.channels.filter((key) => !offered.has(key)) ?? [];
796
+ const carried = map[shape]?.pool?.channels.filter((key) => !offered.has(key)) ?? [];
656
797
  const named = new Set(lines.map((row) => row.key));
657
798
  const unnamed = carried.filter((key) => !named.has(key)).map((key) => ({
658
799
  key,
@@ -668,13 +809,13 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
668
809
  const cfgPreview = preview.cfg;
669
810
  const rows = shapeCandidates(previewTask(shape), cfgPreview, previewPool(cfgPreview), profile).map((candidate) => ({
670
811
  id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
671
- label: candidateRow(candidate, cfg.pricing),
812
+ label: candidateRow(candidate, cfgPreview.pricing),
672
813
  pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
673
814
  // OBS-1065: a picker lift reads eligibility from here, not the ledger strings
674
815
  belowFloor: candidate.belowFloor,
675
816
  }));
676
817
  const offered = new Set(rows.map((row) => row.id));
677
- const notes = [excludedNoteFor(cfgPreview), ...shapeExclusionCaptions(shape, map, cfgPreview)].filter((note) => note !== undefined);
818
+ const notes = [excludedNoteFor(cfgPreview), ...shapeExclusionCaptions(shape, cfgPreview)].filter((note) => note !== undefined);
678
819
  return { rows, excludedNote: notes.length ? notes.join("\n") : undefined, ledger: channelLedger(shape, map, cfgPreview, offered) };
679
820
  };
680
821
  const preferUniverse = [
@@ -702,27 +843,39 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
702
843
  // discovered seats universe plus a keep-default row, and a write is staged only on a real change.
703
844
  const initialJudge = `${cfg.judge.adapter}:${cfg.judge.model}`;
704
845
  let pendingWrite = null;
705
- // OBS-1182: the layers under the repo overlay, read raw, so clearing an effort masks an inherited
706
- // one even when those layers only validate beside repo fields. OBS-1188: re-read at every review
707
- // and save, never an assembly-time snapshot; an unreadable read keeps the last good one for the
708
- // preview only — the save guard below refuses it.
709
- let lowerOverrides = {};
710
- // OBS-1188: the reviewed bytes are the only bytes that may land. Re-rendered from the repo overlay
711
- // and lower layers as they are NOW; any difference is a stale preview. Null = still current.
712
- const stalePreview = (write, reviewed, prior = currentRepoOverlayText(cwd)) => {
713
- const lower = lowerLayerModelOverrides({ globalDir });
846
+ const lowerLayers = () => lowerLayerModelOverrides({ globalDir, below: "user" });
847
+ // OBS-1188: the reviewed bytes are the only bytes that may land. Any byte of the user or repo overlay
848
+ // that moved since the review is a stale preview — a re-render alone would overwrite a same-key edit
849
+ // with the staged value and still match — then the lower layers are re-rendered as they are NOW.
850
+ // Null = still current.
851
+ const stalePreview = (pending, reviewed, prior = currentUserText()) => {
852
+ if (currentRepoOverlayText(cwd) !== pending.repo) {
853
+ return `stale preview — the repository overlay ${repoOverlayPath(cwd)} changed after the review diff was rendered; press w to review what would be written now`;
854
+ }
855
+ if (prior !== pending.user) {
856
+ return `stale preview — the user overlay ${userPath} changed after the review diff was rendered; press w to review what would be written now`;
857
+ }
858
+ const lower = lowerLayers();
714
859
  if (!lower.ok)
715
860
  return lower.error;
716
861
  try {
717
- if (renderFleetOverlayWrite(prior, { ...write, lowerOverrides: lower.overrides }) === reviewed)
862
+ if (renderFleetOverlayWrite(prior, { ...pending.write, lowerOverrides: lower.overrides }) === reviewed)
718
863
  return null;
719
864
  }
720
865
  catch (error) {
721
866
  return error.message;
722
867
  }
723
- return "stale preview — the repo overlay or a lower config layer changed after the review diff was rendered; press w to review what would be written now";
868
+ return `stale preview — the user overlay ${userPath} or a lower config layer changed after the review diff was rendered; press w to review what would be written now`;
724
869
  };
870
+ // B2: a saved user key the repository overlay also decides does not apply in THIS repository —
871
+ // each is named with the value that still applies here, never reported as applied.
872
+ const repoShadowNotes = (before, after) => repoShadowedUserKeys(cwd, before, after, { globalDir }).map(({ path, value }) => {
873
+ const shown = value === undefined ? "absent" : typeof value === "string" ? value : JSON.stringify(value);
874
+ return `repo-shadowed: ${path} stays ${shown} in this repository — ${repoOverlayPath(cwd)} sets it above the user overlay`;
875
+ });
725
876
  const reviewOverlay = (state) => {
877
+ // B2: a review re-reads every layer, so no preview rendered before it is served after it
878
+ candidateMemo.clear();
726
879
  const staged = structuredClone(initial);
727
880
  // OBS-994/FL-1, OBS-1099 add.1: every schema-enumerated deny scope rides the same review/write funnel
728
881
  for (const scope of DENY_SCOPES)
@@ -743,10 +896,13 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
743
896
  provenance: `${classification.note} — fleet ${today}`,
744
897
  };
745
898
  }
899
+ reviewedStage = { tiers: staged.tiers, efforts: staged.efforts };
746
900
  // This callback is the sole candidate-overlay builder. The Ink component renders
747
901
  // the diff and asks for confirmation, but owns neither filesystem access nor a writer.
748
- const before = currentRepoOverlayText(cwd);
749
- const path = repoOverlayPath(cwd);
902
+ // B2: defaults/user/repo are re-read here, at review — the destination is the user overlay.
903
+ const before = currentUserText();
904
+ const repo = currentRepoOverlayText(cwd);
905
+ const path = userPath;
750
906
  const modeChanged = state.selectedMode !== rm.mode.mode;
751
907
  const steeringChanged = ["review", "consult"].some((key) => JSON.stringify(state.steering[key]) !== JSON.stringify(initialSteering[key]));
752
908
  const judgeSeat = state.judgeSeat;
@@ -756,35 +912,32 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
756
912
  pendingWrite = null;
757
913
  return { kind: "empty" };
758
914
  }
759
- const lower = lowerLayerModelOverrides({ globalDir });
915
+ const lower = lowerLayers();
760
916
  if (lower.ok)
761
917
  lowerOverrides = lower.overrides;
762
- const write = {
763
- initial,
764
- edited: staged,
765
- universe,
918
+ const write = userLayerWrite(staged, before, {
766
919
  lowerOverrides,
767
920
  ...(modeChanged ? { mode: state.selectedMode } : {}),
768
921
  ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
769
922
  steering: { initial: initialSteering, edited: state.steering },
770
- };
923
+ });
771
924
  const after = renderFleetOverlayWrite(before, write);
772
925
  if (before === after) {
773
926
  pendingWrite = null;
774
927
  return { kind: "empty" };
775
928
  }
776
- pendingWrite = write;
929
+ pendingWrite = { write, repo, user: before };
777
930
  // OBS-1144: the review names every dead pool entry the written config carries, with its reason and
778
931
  // what still routes — before y. Unloadable bytes carry no notes: the reload guard refuses them anyway.
779
932
  let poolNotes = [];
780
933
  try {
781
- const written = loadConfigWithMode(cwd, { globalDir, repoOverlayText: after }).cfg;
934
+ const written = loadConfigWithMode(cwd, { globalDir, userOverlayText: after }).cfg;
782
935
  poolNotes = deadPoolEntries(written, undefined, health).map(deadPoolEntryLine);
783
936
  }
784
937
  catch {
785
938
  poolNotes = [];
786
939
  }
787
- const notes = [...(lower.ok ? [] : [`${lower.error} — y will be refused`]), ...poolNotes];
940
+ const notes = [...repoShadowNotes(before, after), ...(lower.ok ? [] : [`${lower.error} — y will be refused`]), ...poolNotes];
788
941
  return {
789
942
  kind: "diff",
790
943
  before,
@@ -794,8 +947,17 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
794
947
  ...(notes.length ? { notes } : {}),
795
948
  };
796
949
  };
950
+ // B2: every repository inherits the user overlay, so its bytes must load here AND on their own —
951
+ // a tiers entry whose vendor/channel only this repository declares would break every other one
797
952
  const loaderGuard = io.reloadGuard
798
- ?? ((bytes) => overlayBytesLoadError(cwd, bytes, { globalDir }));
953
+ ?? ((bytes) => {
954
+ const here = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user" });
955
+ if (here !== null)
956
+ return here;
957
+ const alone = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user", repoOverlayText: "" });
958
+ return alone === null ? null
959
+ : `the user overlay ${userPath} loads only beside ${repoOverlayPath(cwd)} — every repository inherits it, and without that overlay it fails: ${alone}`;
960
+ });
799
961
  // OBS-1188: a stale preview or an unreadable lower layer answers on the same error channel as the loader
800
962
  const reloadGuard = (bytes) => (pendingWrite && stalePreview(pendingWrite, bytes)) || loaderGuard(bytes);
801
963
  const props = {
@@ -809,7 +971,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
809
971
  initialMode: rm.mode.mode,
810
972
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
811
973
  initialMap: editable.map,
812
- modePreview: (mode, map, deny) => [modeSpend(mode, map, deny), ...floorPreview(mode)],
974
+ modePreview: (mode, map, deny) => [modeSpend(mode, map, deny), ...floorPreview(mode, map, deny)],
813
975
  shapeRows: routedShapeRows,
814
976
  candidatesForShape,
815
977
  preferOptionsForShape: (_shape, current) => [
@@ -842,15 +1004,19 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
842
1004
  return "fleet: discarded overlay changes";
843
1005
  // The command remains the single config actuator. Every interactive edit reaches this
844
1006
  // one write only after the component-rendered diff confirm and the production reload guard.
845
- const write = pendingWrite;
846
- if (!write)
1007
+ const pending = pendingWrite;
1008
+ if (!pending)
847
1009
  throw new Error("fleet write reached confirmation without a staged overlay mutation");
848
- // OBS-1188: re-checked against the bytes on disk at write time; a refusal throws before the temp file exists
1010
+ const write = pending.write;
1011
+ // OBS-1188: re-checked against the bytes on disk at write time; a refusal throws before the temp file exists.
1012
+ // B2: the one destination is the user overlay, and bytes the loader refuses never land there either.
849
1013
  try {
850
- writeFleetOverlay(result.review.path, (prior) => {
851
- const stale = stalePreview(write, result.review.after, prior);
852
- if (stale !== null)
853
- throw new ConfigError(stale);
1014
+ writeFleetOverlay(userPath, (prior) => {
1015
+ const refusal = result.review.path === userPath
1016
+ ? stalePreview(pending, result.review.after, prior) ?? loaderGuard(result.review.after)
1017
+ : `the reviewed destination ${result.review.path} is not the user overlay ${userPath}`;
1018
+ if (refusal !== null)
1019
+ throw new ConfigError(refusal);
854
1020
  return result.review.after;
855
1021
  });
856
1022
  }
@@ -873,7 +1039,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
873
1039
  unprobed.push(`${adapter}:${model}`);
874
1040
  }
875
1041
  }
876
- const head = `fleet: wrote ${result.review.path}`;
1042
+ // B2: a repo-shadowed save says what still applies here — never claimed applied
1043
+ const head = [`fleet: wrote ${userPath}`, ...repoShadowNotes(result.review.before, result.review.after).map((note) => `fleet: ${note}`)].join("\n");
877
1044
  if (!unprobed.length)
878
1045
  return head;
879
1046
  const named = unprobed.slice(0, 3).join(", ") + (unprobed.length > 3 ? `, +${unprobed.length - 3} more` : "");
@@ -111,7 +111,7 @@ Before \`tickmarkr compile\` or \`tickmarkr run\`: run \`tickmarkr version\`, re
111
111
 
112
112
  ### Tip-verify-before-green
113
113
 
114
- A run is green only when ALL of these hold: the run-end event exists in the journal, tip verify is not "failed", AND the run-end summary's \`failed\`, \`human\`, \`blocked\` and \`pending\` buckets are every one of them empty. The shorter "run-end plus tip verify" form is NOT sufficient: a run ending \`done=[T1,T3,T4] human=[T2] tipVerify=passed\` is a closed PARTIAL run — three of four delivered, one parked — and calling it green is how a park becomes invisible. Never report green to the operator, tab titles, or records until every clause holds; when one does not, name the bucket that is not empty.
114
+ A run is green only when ALL of these hold: the run-end event exists in the journal, tip verify is not "failed", the run-end summary's \`failed\`, \`human\`, \`blocked\` and \`pending\` buckets are every one of them empty, AND the owed checks in CURRENT \`tickmarkr status <runId>\` are outstanding empty AND known (\`outstanding 0\`; \`outstanding unknown\` is never empty). The shorter "run-end plus tip verify" form is NOT sufficient: a run ending \`done=[T1,T3,T4] human=[T2] tipVerify=passed\` is a closed PARTIAL run — three of four delivered, one parked — and calling it green is how a park becomes invisible. Empty execution buckets are not sufficient either (D-660): a run ending with every execution bucket empty and \`tipVerify=passed\` after an operator waived one review still owes that accepted-risk review check — status reads \`outstanding 1 (T7 review)\`, and \`tickmarkr run\` and \`resume\` exit 0 on execution alone — so it is execution complete, not green. Read the debt from CURRENT status, never from the run-end record: a \`tickmarkr verify --record <runId>\` discharge that lands after run-end moves current status to \`outstanding 0\` while the historical run-end row still carries 1, and only then is that run green; a legacy waiver without a recorded obligation reads \`outstanding unknown\` and is never green. Never report green to the operator, tab titles, or records until every clause holds; when one does not, name the bucket that is not empty, the outstanding checks, or the unknown debt.
115
115
 
116
116
  ### Verified handoffs
117
117
 
@@ -634,7 +634,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
634
634
  const output = io.output ?? process.stdout;
635
635
  const interactive = input.isTTY === true && output.isTTY === true && !(values.yes ?? false);
636
636
  // Act 1 — preferences. Only when the repo config does not exist yet: init never rewrites an
637
- // operator's config; re-tuning is `tickmarkr fleet` (act 3 below still runs and writes overlays).
637
+ // operator's config; re-tuning is `tickmarkr fleet` (act 3 below still runs). Act 1 writes only
638
+ // project execution preferences; model choices are act 3's, saved to the USER overlay (B2).
638
639
  if (!repoConfigExists) {
639
640
  if (interactive) {
640
641
  emitBanner();
@@ -671,7 +672,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
671
672
  : await doctor([], cwd, undefined, { banner: false, compact: interactive });
672
673
  // Act 3 — fleet. The compact discovery surface prints BETWEEN the acts so the operator reads
673
674
  // what the fleet browser (and its presets overlay, raised on the first Shapes entry) ranks
674
- // with; the final summary then carries a pointer, not a repeat.
675
+ // with; the final summary then carries a pointer, not a repeat. Its confirmation is the shared
676
+ // Fleet actuator's: the reviewed choices land in the user overlay (gdir), never the repo's.
675
677
  if (interactive) {
676
678
  output.write(`${doc}\n`);
677
679
  doc = "discovery shown above — full matrix any time with `tickmarkr doctor`";