tickmarkr 2.6.5 → 2.6.6

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,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, relative } from "node:path";
3
4
  import { parseArgs } from "node:util";
4
5
  import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions, PREFERENCE_ROLES } from "../../adapters/registry.js";
5
6
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
@@ -7,6 +8,7 @@ import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog
7
8
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
8
9
  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
10
  import { exclusionReason, projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
11
+ import { overlayHoldingLine, overlayHoldsMap } from "../../config/fleet-overlay.js";
10
12
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
13
  import { doctor } from "./doctor.js";
12
14
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
@@ -87,6 +89,9 @@ function formatFleetSteering(cfg) {
87
89
  return blocks.length ? `${blocks.join("\n")}\n` : "";
88
90
  }
89
91
  // v1.51 T4: one gloss per routing mode on the fleet mode screen — mirrors the preset compiler.
92
+ // T10: what every review says about where its bytes land and when they take effect
93
+ const REPO_DESTINATION_NOTE = (path) => `destination: the repository overlay ${path} holds this repository's routing.allow/deny membership, so the edit lands there — the file is tracked: Fleet never stages or commits it; commit it to share the change`;
94
+ const FUTURE_LOADS_NOTE = "the save changes future config loads only — a tickmarkr run already in progress keeps the config it loaded";
90
95
  const MODE_GLOSS = {
91
96
  "partner-led": "every shape frontier · explore off",
92
97
  "risk-based": "risk-tiered default floors",
@@ -218,10 +223,15 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
218
223
  * overlay write. */
219
224
  export async function assembleFleetEditor(cwd, adapters, io, opts) {
220
225
  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.
226
+ // B2: the editor's choices are the operator's MACHINE choices — a save lands in the USER overlay,
227
+ // which every repository on this machine inherits. T10 (D-925/D-926): except a membership edit the
228
+ // repository overlay holds — it lands where it is effective (destinationEdits below).
223
229
  const userPath = join(globalDir, "config.yaml");
230
+ const repoPath = repoOverlayPath(cwd);
224
231
  const currentUserText = () => (existsSync(userPath) ? readFileSync(userPath, "utf8") : "");
232
+ const pathOf = (destination) => (destination === "repo" ? repoPath : userPath);
233
+ // the candidate bytes stand in for the destination layer in the one loader path
234
+ const layerOf = (destination, bytes) => (destination === "repo" ? { repoOverlayText: bytes } : { userOverlayText: bytes });
225
235
  const { reuse, health: cached } = initDoctorReuse(cwd, false);
226
236
  if (!reuse || !cached) {
227
237
  return {
@@ -363,8 +373,6 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
363
373
  const channels = discoverChannels(cfg, adapters, health);
364
374
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
365
375
  const stagedDenyOf = (editable) => Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), editable[scope.key] ?? []]));
366
- // T8: staged classifications and efforts as the editable tiers/efforts a review writes — one fold for
367
- // the review and every preview, so the mode/shape/picker callbacks rank exactly what w would write.
368
376
  const stagedMetadataOf = (stage) => {
369
377
  const tiers = structuredClone(initial.tiers);
370
378
  const today = new Date().toISOString().slice(0, 10);
@@ -377,20 +385,21 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
377
385
  }
378
386
  // OBS-1182: staged efforts replace the loaded ones; none staged anywhere ⇒ no key, as loaded
379
387
  const efforts = Object.fromEntries(Object.entries(stage.efforts ?? {}).filter(([, models]) => Object.keys(models).length));
380
- return { tiers, ...(Object.keys(efforts).length ? { efforts } : {}) };
388
+ return { tiers, ...(Object.keys(efforts).length ? { efforts } : {}), seeds: stage.seededAllowOut ?? [] };
381
389
  };
382
390
  // B2: the classifications and efforts of the last reviewed state — what a preview called without a
383
391
  // stage (the browser's staged routing, --why) ranks; the editor passes its current stage (T8).
384
- let reviewedStage = { tiers: initial.tiers, efforts: initial.efforts };
385
- // OBS-1182: the layers under the written overlay — B2: the defaults alone under the USER overlay —
386
- // read raw, so clearing an effort masks an inherited one. OBS-1188: re-read at every review and
387
- // save, never an assembly-time snapshot; an unreadable read keeps the last good one for the
388
- // preview only — the save guard below refuses it.
389
- let lowerOverrides = {};
392
+ let reviewedStage = { tiers: initial.tiers, efforts: initial.efforts, seeds: [] };
393
+ // OBS-1182: the layers under the written overlay — B2: the defaults alone under the USER overlay;
394
+ // T10: defaults + user under the repository overlay — read raw, so clearing an effort masks an
395
+ // inherited one. OBS-1188: re-read at every review and save, never an assembly-time snapshot; an
396
+ // unreadable read keeps the last good one for the preview only — the save guard below refuses it.
397
+ const lowerOverrides = { user: {}, repo: {} };
390
398
  const stagedEditable = (map, deny, metadata) => ({
391
399
  ...structuredClone(initial),
392
- efforts: undefined, // metadata carries efforts only when some are staged — as a review writes them
393
- ...structuredClone(metadata),
400
+ // metadata carries efforts only when some are staged — as a review writes them
401
+ tiers: structuredClone(metadata.tiers),
402
+ efforts: metadata.efforts && structuredClone(metadata.efforts),
394
403
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, deny[stagedDenyKeyOf(scope)]])),
395
404
  // OBS-1046: the allow complement is staged beside the deny lists, never folded into them
396
405
  allowOut: deny.allowOut ?? initial.allowOut,
@@ -406,13 +415,17 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
406
415
  // it in every other repository.
407
416
  const membershipKeys = [...DENY_SCOPES.filter((scope) => scope.path.length === 3).map((scope) => scope.key), "allowOut"];
408
417
  const sameSet = (a = [], b = []) => [...new Set(a)].sort().join() === [...new Set(b)].sort().join();
409
- const destinationInitial = (base, edited, userText, target) => {
418
+ // T10: a repository destination diffs against the effective layers (defaults + user + repo).
419
+ const destinationInitial = (base, edited, destination, text, target) => {
410
420
  let userCfg;
411
421
  try {
412
- userCfg = loadConfigWithMode(cwd, { globalDir, userOverlayText: userText, repoOverlayText: "" }).cfg;
422
+ userCfg = loadConfigWithMode(cwd, {
423
+ globalDir,
424
+ ...(destination === "repo" ? { repoOverlayText: text } : { userOverlayText: text, repoOverlayText: "" }),
425
+ }).cfg;
413
426
  }
414
427
  catch {
415
- return base; // ponytail: user bytes that load only beside this repo — the reload guard refuses the write
428
+ return base; // ponytail: bytes that load only beside another layer — the reload guard refuses the write
416
429
  }
417
430
  const userLayer = userCfg.routing.map;
418
431
  const editedMap = edited.map;
@@ -429,11 +442,11 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
429
442
  };
430
443
  }
431
444
  const membershipTouched = membershipKeys.some((key) => !sameSet(base[key], edited[key]));
432
- const destination = membershipTouched ? fleetEditableFromConfig(userCfg, target) : undefined;
445
+ const layer = membershipTouched ? fleetEditableFromConfig(userCfg, target) : undefined;
433
446
  return {
434
447
  ...base,
435
448
  map,
436
- ...(destination ? Object.fromEntries(membershipKeys.map((key) => [key, destination[key] ?? []])) : {}),
449
+ ...(layer ? Object.fromEntries(membershipKeys.map((key) => [key, layer[key] ?? []])) : {}),
437
450
  };
438
451
  };
439
452
  // B2: a changed slot staged with no declaration (auto, no prefer) where the defaults under the user
@@ -454,24 +467,207 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
454
467
  }
455
468
  return { ...staged, map };
456
469
  };
457
- // B2: the one user-layer write builder the previews and the review share, so a preview ranks the
458
- // bytes y would land. A touched membership serializes against the universe the candidate itself
459
- // serves (current layers plus staged classifications), rendered first with membership unchanged;
460
- // a channel only that universe adds keeps the verdict the current layers' allow form gives it.
461
- const userLayerWrite = (staged, userText, rest) => {
470
+ // T10 (D-939): ONE pure baseline — the session's initial allow complement plus the allow entry Ink
471
+ // seeded beside each classification it staged (FleetSeededAllow) — serves every preview, the review
472
+ // and the destination choice. `initial` is never mutated.
473
+ const baselineOf = (seeds) => (seeds.length
474
+ ? { ...initial, allowOut: [...new Set([...(initial.allowOut ?? []), ...seeds.map((seed) => seed.entry)])].sort() }
475
+ : initial);
476
+ const FLAT_SCOPES = DENY_SCOPES.filter((scope) => scope.path.length === 3);
477
+ const seedChannel = (seed) => ({ adapter: seed.adapter, model: seed.displayModel, ...(seed.identity !== undefined ? { identity: seed.identity } : {}) });
478
+ // an allow-complement entry excludes a seeded channel by the allow form's own matching (adapter id,
479
+ // model, adapter:model, recorded identity) — the seed's spelling is one such entry, never the only one
480
+ const seedRow = (seed) => ({
481
+ adapter: seed.adapter,
482
+ models: [seed.displayModel],
483
+ ...(seed.identity !== undefined ? { identities: { [seed.displayModel]: seed.identity } } : {}),
484
+ });
485
+ const allowExcludes = (staged, seed) => (staged.allowOut ?? []).some((entry) => universeEntryMatches(seedRow(seed), seed.displayModel, entry));
486
+ // membership keeps the channel out: its allow complement or a flat (all-seats) deny entry
487
+ const excludedIn = (staged, seed) => allowExcludes(staged, seed)
488
+ || FLAT_SCOPES.some((scope) => (staged[scope.key] ?? []).some((entry) => entryMatchesChannel(entry, seedChannel(seed), true)));
489
+ // a seed cleared while another staged complement entry (its adapter's, say) still excludes its
490
+ // channel admitted nothing: the press is a no-op, so the seed stays staged and the shared exclusion
491
+ // is no membership edit — its classification stays a lone tier
492
+ const settled = (staged, seeds) => {
493
+ const kept = seeds.filter((seed) => !(staged.allowOut ?? []).includes(seed.entry) && allowExcludes(staged, seed));
494
+ return kept.length
495
+ ? { ...staged, allowOut: [...new Set([...(staged.allowOut ?? []), ...kept.map((seed) => seed.entry)])].sort() }
496
+ : staged;
497
+ };
498
+ // an END state, re-derived per call: no staged complement entry and no flat deny excludes the seed's
499
+ // channel any more — an admission undone by out · all is no admission
500
+ const admittedSeeds = (staged, seeds) => seeds.filter((seed) => !excludedIn(staged, seed));
501
+ // ponytail: rows re-render per key press, each naming its scopes' holders — memoized per (bytes, path),
502
+ // cleared wholesale past 256 entries; a keyed LRU if a fleet ever outgrows it
503
+ const holdingMemo = new Map();
504
+ const holdingLine = (text, path) => {
505
+ const key = `${path.join(".")}\0${text}`;
506
+ if (!holdingMemo.has(key)) {
507
+ if (holdingMemo.size > 256)
508
+ holdingMemo.clear();
509
+ holdingMemo.set(key, overlayHoldingLine(text, path));
510
+ }
511
+ return holdingMemo.get(key);
512
+ };
513
+ // routing.allow is held through its leaves: a bare `allow: {}` declares none, so the merge keeps the
514
+ // lower layer's leaves effective; a null mask at a leaf or above still holds it
515
+ const heldLine = (text, path) => path.join(".") === "routing.allow" && ["adapters", "models"].every((leaf) => holdingLine(text, [...path, leaf]) === undefined)
516
+ ? undefined
517
+ : holdingLine(text, path);
518
+ // T10: the repository holds a path when it declares it ([] included) or masks it with null at it or above
519
+ const repoHolds = (path) => heldLine(currentRepoOverlayText(cwd), path) !== undefined;
520
+ // T10: where a scope lives in this repository's merge — `path:line` of the repository overlay when it
521
+ // holds it, else of the user overlay when that one does; undefined = the defaults alone. Paths are
522
+ // short (repo-relative, ~ for home) so the row's detail line keeps them on screen.
523
+ const home = homedir();
524
+ const shown = (path) => (path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
525
+ const holderOf = (dotted) => {
526
+ const path = dotted.split(".");
527
+ const repoLine = heldLine(currentRepoOverlayText(cwd), path);
528
+ if (repoLine !== undefined)
529
+ return `${relative(cwd, repoPath)}:${repoLine}`;
530
+ const userLine = heldLine(currentUserText(), path);
531
+ return userLine === undefined ? undefined : `${shown(userPath)}:${userLine}`;
532
+ };
533
+ // the allow + flat-deny family is ONE coupled write (the allow form is
534
+ // regenerated from the whole staged fleet), so it has one holder: the repository when it holds any of
535
+ // its leaves, else the user. A bare repository `allow: {}` holds no leaf, yet its presence alone keeps
536
+ // the allowlist on: a user save that drops the user allow form would fail-close this repository, so
537
+ // only the repository (which masks it) can take that save. Whether the user form goes away is read off
538
+ // the one writer's user bytes — a staged flat deny, an allow exclusion, or a retained authored entry
539
+ // for a channel this probe never served each keep it, and with it the save, in the user overlay.
540
+ const userFormDropped = (staged, seeds) => {
541
+ const text = currentUserText();
542
+ try {
543
+ const bytes = renderFleetOverlayWrite(text, layerWrite(staged, seeds, "user", text, { lowerOverrides: lowerOverrides.user }));
544
+ return !overlayHoldsMap(bytes, ["routing", "allow"]);
545
+ }
546
+ catch {
547
+ return false; // the review renders the same write and names its refusal
548
+ }
549
+ };
550
+ const familyHolder = (staged, seeds) => [["routing", "allow"], ...FLAT_SCOPES.map((scope) => scope.path)].some(repoHolds)
551
+ || (holdingLine(currentRepoOverlayText(cwd), ["routing", "allow"]) !== undefined && userFormDropped(staged, seeds))
552
+ ? "repo" : "user";
553
+ const destinationEdits = (pressed, seeds, controls) => {
554
+ const staged = settled(pressed, seeds);
555
+ const baseline = baselineOf(seeds);
556
+ const admitted = admittedSeeds(staged, seeds);
557
+ let held;
558
+ const family = () => (held ??= familyHolder(staged, seeds));
559
+ const edits = [];
560
+ if (admitted.length || membershipKeys.some((key) => !sameSet(baseline[key], staged[key]))) {
561
+ edits.push({ name: "routing.allow/routing.deny membership", destination: family() });
562
+ }
563
+ // a worker leaf follows the layer that holds it
564
+ for (const scope of DENY_SCOPES.filter((candidate) => candidate.path.length > 3)) {
565
+ if (!sameSet(initial[scope.key], staged[scope.key]))
566
+ edits.push({ name: scope.dotted, destination: repoHolds(scope.path) ? "repo" : "user" });
567
+ }
568
+ for (const [adapter, models] of Object.entries(staged.tiers)) {
569
+ for (const [model, value] of Object.entries(models)) {
570
+ if (JSON.stringify(value) === JSON.stringify(initial.tiers[adapter]?.[model]))
571
+ continue;
572
+ // a classification co-locates only with its own admission — its seed cleared, or (T10 review) its
573
+ // own flat-deny lift: a row the baseline membership kept out that the staged one admits; a lone or
574
+ // unrelated tier stays user
575
+ const own = admitted.some((seed) => seed.adapter === adapter && seed.model === model)
576
+ || (modelGroups.find((group) => group.adapter === adapter)?.rows ?? [])
577
+ .filter((row) => row.model === model || ("classifyModel" in row && row.classifyModel === model))
578
+ .some((row) => {
579
+ const channel = { adapter, displayModel: row.model, ...(row.evidence?.identity !== undefined ? { identity: row.evidence.identity } : {}) };
580
+ return excludedIn(baseline, channel) && !excludedIn(staged, channel);
581
+ });
582
+ edits.push({ name: `tiers.${adapter}.models.${model}`, destination: own ? family() : "user" });
583
+ }
584
+ }
585
+ // {} ≡ absent: pin-then-auto leaves an empty entry that writes nothing (fleet-app stagedCount)
586
+ if ([...new Set([...Object.keys(initial.map), ...Object.keys(staged.map)])]
587
+ .some((shape) => JSON.stringify(initial.map[shape] ?? {}) !== JSON.stringify(staged.map[shape] ?? {}))) {
588
+ edits.push({ name: "routing.map", destination: "user" });
589
+ }
590
+ if (JSON.stringify(initial.efforts ?? {}) !== JSON.stringify(staged.efforts ?? {}))
591
+ edits.push({ name: "model efforts", destination: "user" });
592
+ return [...edits, ...controls.map((name) => ({ name, destination: "user" }))];
593
+ };
594
+ // ONE review confirms ONE destination; a batch needing both is refused before any write (no
595
+ // multi-file transaction) and names what to save in separate sessions
596
+ const chooseDestination = (edits) => {
597
+ if (new Set(edits.map((edit) => edit.destination)).size <= 1)
598
+ return { ok: true, destination: edits[0]?.destination ?? "user" };
599
+ const side = (destination) => `${edits.filter((edit) => edit.destination === destination).map((edit) => edit.name).join(", ")} → ${pathOf(destination)}`;
600
+ return {
601
+ ok: false,
602
+ reason: `mixed destinations — one review writes one overlay: ${side("repo")}; ${side("user")} — save them in separate sessions (stage one side, w, y; then the other)`,
603
+ };
604
+ };
605
+ // T10: the universe a write edits — the candidate's own discovery plus every seeded channel, so an
606
+ // unprobed classified model can still be admitted (membership ≠ routability). A seed whose channel
607
+ // the browser never showed, or whose spelling names no channel, is refused.
608
+ const editingUniverse = (candidate, seeds) => {
609
+ const target = universeOf(candidateChannels(candidate));
610
+ const covers = (seed) => target.some((row) => row.adapter === seed.adapter
611
+ && row.models.some((model) => universeEntryMatches(row, model, seed.entry)));
612
+ for (const seed of seeds) {
613
+ if (covers(seed))
614
+ continue;
615
+ const shown = modelGroups.some((group) => group.adapter === seed.adapter && group.rows.some((row) => row.model === seed.displayModel));
616
+ const row = target.find((candidateRow) => candidateRow.adapter === seed.adapter);
617
+ if (shown && row) {
618
+ row.models = [...row.models, seed.displayModel];
619
+ if (seed.identity !== undefined)
620
+ row.identities = { ...row.identities, [seed.displayModel]: seed.identity };
621
+ }
622
+ if (!covers(seed))
623
+ throw new ConfigError(`the seeded allow entry ${seed.entry} names no channel of the discovered fleet — re-probe with tickmarkr doctor, then classify it again`);
624
+ }
625
+ return target;
626
+ };
627
+ // T10: the routing.allow the layers BELOW the destination declare (the defaults declare none; under
628
+ // the repository, the user overlay's, read now). "Whole fleet in" masks it, never resurrects it, and
629
+ // while the repository declares no allow of its own, that inherited form's unserved entries are
630
+ // effective here, so the regenerated repository form keeps them.
631
+ const lowerAllow = (destination) => {
632
+ if (destination === "user")
633
+ return DEFAULT_CONFIG.routing.allow;
634
+ const user = readOverlayFile(userPath);
635
+ return user.routing?.allow ?? undefined;
636
+ };
637
+ // B2: the one write builder the previews and the review share, so a preview ranks the bytes y would
638
+ // land. A touched membership serializes against the universe the candidate itself serves (current
639
+ // layers plus staged classifications), rendered first with membership unchanged; a channel only that
640
+ // universe adds keeps the verdict the current layers' allow form gives it — T10: unless it is an
641
+ // admitted seed, which the operator just let in.
642
+ const layerWrite = (pressed, seeds, destination, text, rest) => {
643
+ const staged = settled(pressed, seeds);
644
+ const baseline = baselineOf(seeds);
645
+ const admitted = admittedSeeds(staged, seeds);
646
+ const lower = lowerAllow(destination);
647
+ // per leaf: an inherited allow leaf the repository neither declares nor masks (at it or above) is
648
+ // effective here, so its unserved entries ride the regenerated form even beside a declared sibling
649
+ const inherited = lower && Object.fromEntries(["adapters", "models"]
650
+ .filter((leaf) => lower[leaf] != null && !repoHolds(["routing", "allow", leaf]))
651
+ .map((leaf) => [leaf, lower[leaf]]));
652
+ const fixed = {
653
+ lowerMap: DEFAULT_CONFIG.routing.map,
654
+ ...(lower !== undefined ? { lowerAllow: lower } : {}),
655
+ ...(inherited && Object.keys(inherited).length ? { preservedAllow: inherited } : {}),
656
+ };
462
657
  let edited = maskClearedSlots(initial, staged);
463
- let target = universe;
464
- if (membershipKeys.some((key) => !sameSet(initial[key], edited[key]))) {
465
- const unchanged = { ...edited, ...Object.fromEntries(membershipKeys.map((key) => [key, initial[key]])) };
658
+ let target = editingUniverse(cfg, seeds);
659
+ if (membershipKeys.some((key) => !sameSet(baseline[key], edited[key]))) {
660
+ const unchanged = { ...edited, ...Object.fromEntries(membershipKeys.map((key) => [key, baseline[key]])) };
466
661
  try {
467
- const bytes = renderFleetOverlayWrite(userText, {
468
- ...rest, initial: destinationInitial(initial, unchanged, userText, universe), edited: unchanged, universe,
469
- lowerMap: DEFAULT_CONFIG.routing.map,
662
+ const bytes = renderFleetOverlayWrite(text, {
663
+ ...rest, ...fixed, initial: destinationInitial(baseline, unchanged, destination, text, target), edited: unchanged, universe: target,
470
664
  });
471
- const candidate = loadConfigWithMode(cwd, { globalDir, userOverlayText: bytes }).cfg;
472
- target = universeOf(candidateChannels(candidate));
665
+ const candidate = loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, bytes) }).cfg;
666
+ target = editingUniverse(candidate, seeds);
473
667
  const known = (entry) => universe.some((row) => entry === row.adapter || row.models.some((model) => universeEntryMatches(row, model, entry)));
474
- const grown = (fleetEditableFromConfig(candidate, target).allowOut ?? []).filter((entry) => !known(entry));
668
+ const admits = (entry) => admitted.some((seed) => target.some((row) => row.adapter === seed.adapter
669
+ && row.models.some((model) => universeEntryMatches(row, model, seed.entry) && universeEntryMatches(row, model, entry))));
670
+ const grown = (fleetEditableFromConfig(candidate, target).allowOut ?? []).filter((entry) => !known(entry) && !admits(entry));
475
671
  if (grown.length)
476
672
  edited = { ...edited, allowOut: [...new Set([...(edited.allowOut ?? []), ...grown])].sort() };
477
673
  }
@@ -479,8 +675,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
479
675
  // the full render below (or the reload guard) names the refusal
480
676
  }
481
677
  }
482
- // T8: the user overlay's lower layer is the defaults alone (B2) — a pin cleared over one is tombstoned
483
- return { ...rest, initial: destinationInitial(initial, edited, userText, target), edited, universe: target, lowerMap: DEFAULT_CONFIG.routing.map };
678
+ return { ...rest, ...fixed, initial: destinationInitial(baseline, edited, destination, text, target), edited, universe: target };
484
679
  };
485
680
  const candidateMemo = new Map();
486
681
  const previewCfg = (mode, map, deny, stage) => {
@@ -489,17 +684,24 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
489
684
  // T8: so are the staged tiers/efforts — a metadata edit never serves the preview cached before it
490
685
  const metadata = stage ? stagedMetadataOf(stage) : reviewedStage;
491
686
  const userText = currentUserText();
492
- const key = JSON.stringify([mode, map, deny, metadata, userText, currentRepoOverlayText(cwd)]);
687
+ const repoText = currentRepoOverlayText(cwd);
688
+ const key = JSON.stringify([mode, map, deny, metadata, userText, repoText]);
493
689
  const hit = candidateMemo.get(key);
494
690
  if (hit)
495
691
  return hit;
496
692
  let preview;
497
693
  try {
498
- const bytes = renderFleetOverlayWrite(userText, userLayerWrite(stagedEditable(map, deny, metadata), userText, {
499
- lowerOverrides,
694
+ const staged = stagedEditable(map, deny, metadata);
695
+ // T10: a preview loads the bytes the chosen destination would hold; a mixed batch (refused at w)
696
+ // previews with the repository side, so a row's reach never turns unknown mid-session
697
+ const chosen = chooseDestination(destinationEdits(staged, metadata.seeds, mode !== rm.mode.mode ? ["routing.mode"] : []));
698
+ const destination = chosen.ok ? chosen.destination : "repo";
699
+ const text = destination === "repo" ? repoText : userText;
700
+ const bytes = renderFleetOverlayWrite(text, layerWrite(staged, metadata.seeds, destination, text, {
701
+ lowerOverrides: lowerOverrides[destination],
500
702
  ...(mode !== rm.mode.mode ? { mode } : {}),
501
703
  }));
502
- preview = { ok: true, ...loadConfigWithMode(cwd, { globalDir, userOverlayText: bytes }) };
704
+ preview = { ok: true, ...loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, bytes) }) };
503
705
  }
504
706
  catch (error) {
505
707
  preview = { ok: false, error: error.message.replace(/\s+/g, " ").trim() };
@@ -510,8 +712,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
510
712
  return preview;
511
713
  };
512
714
  const previewUnavailable = (error) => `preview unavailable (${error}) — the staged overlay does not load, so w would be refused`;
513
- const stagedRouting = (deny) => {
514
- const preview = previewCfg(rm.mode.mode, editable.map, deny);
715
+ // T10: the staged metadata rides along, so a seeded classification's admission reads `in` before w
716
+ const stagedRouting = (deny, stage) => {
717
+ const preview = previewCfg(rm.mode.mode, editable.map, deny, stage);
515
718
  return preview.ok ? { ok: true, routing: preview.cfg.routing } : preview;
516
719
  };
517
720
  // B2: the candidate config's own discovery, blind to both membership scopes — classifications and
@@ -866,35 +1069,39 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
866
1069
  // discovered seats universe plus a keep-default row, and a write is staged only on a real change.
867
1070
  const initialJudge = `${cfg.judge.adapter}:${cfg.judge.model}`;
868
1071
  let pendingWrite = null;
869
- const lowerLayers = () => lowerLayerModelOverrides({ globalDir, below: "user" });
1072
+ const lowerLayers = (destination) => lowerLayerModelOverrides({ globalDir, below: destination });
870
1073
  // OBS-1188: the reviewed bytes are the only bytes that may land. Any byte of the user or repo overlay
871
1074
  // that moved since the review is a stale preview — a re-render alone would overwrite a same-key edit
872
1075
  // with the staged value and still match — then the lower layers are re-rendered as they are NOW.
873
- // Null = still current.
874
- const stalePreview = (pending, reviewed, prior = currentUserText()) => {
875
- if (currentRepoOverlayText(cwd) !== pending.repo) {
876
- return `stale preview — the repository overlay ${repoOverlayPath(cwd)} changed after the review diff was rendered; press w to review what would be written now`;
1076
+ // T10: both overlays are guarded whichever one is written, so a holder that moved after the review
1077
+ // (the destination itself) refuses instead of retargeting. Null = still current.
1078
+ const stalePreview = (pending, reviewed, prior) => {
1079
+ const destinationNow = prior ?? (pending.destination === "repo" ? currentRepoOverlayText(cwd) : currentUserText());
1080
+ const repoNow = pending.destination === "repo" ? destinationNow : currentRepoOverlayText(cwd);
1081
+ const userNow = pending.destination === "user" ? destinationNow : currentUserText();
1082
+ if (repoNow !== pending.repo) {
1083
+ return `stale preview — the repository overlay ${repoPath} changed after the review diff was rendered; press w to review what would be written now`;
877
1084
  }
878
- if (prior !== pending.user) {
1085
+ if (userNow !== pending.user) {
879
1086
  return `stale preview — the user overlay ${userPath} changed after the review diff was rendered; press w to review what would be written now`;
880
1087
  }
881
- const lower = lowerLayers();
1088
+ const lower = lowerLayers(pending.destination);
882
1089
  if (!lower.ok)
883
1090
  return lower.error;
884
1091
  try {
885
- if (renderFleetOverlayWrite(prior, { ...pending.write, lowerOverrides: lower.overrides }) === reviewed)
1092
+ if (renderFleetOverlayWrite(destinationNow, { ...pending.write, lowerOverrides: lower.overrides }) === reviewed)
886
1093
  return null;
887
1094
  }
888
1095
  catch (error) {
889
1096
  return error.message;
890
1097
  }
891
- 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`;
1098
+ return `stale preview — the overlay ${pending.path} or a lower config layer changed after the review diff was rendered; press w to review what would be written now`;
892
1099
  };
893
1100
  // B2: a saved user key the repository overlay also decides does not apply in THIS repository —
894
1101
  // each is named with the value that still applies here, never reported as applied.
895
1102
  const repoShadowNotes = (before, after) => repoShadowedUserKeys(cwd, before, after, { globalDir }).map(({ path, value }) => {
896
1103
  const shown = value === undefined ? "absent" : typeof value === "string" ? value : JSON.stringify(value);
897
- return `repo-shadowed: ${path} stays ${shown} in this repository — ${repoOverlayPath(cwd)} sets it above the user overlay`;
1104
+ return `repo-shadowed: ${path} stays ${shown} in this repository — ${repoPath} sets it above the user overlay`;
898
1105
  });
899
1106
  const reviewOverlay = (state) => {
900
1107
  // B2: a review re-reads every layer, so no preview rendered before it is served after it
@@ -914,10 +1121,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
914
1121
  reviewedStage = metadata;
915
1122
  // This callback is the sole candidate-overlay builder. The Ink component renders
916
1123
  // the diff and asks for confirmation, but owns neither filesystem access nor a writer.
917
- // B2: defaults/user/repo are re-read here, at review — the destination is the user overlay.
918
- const before = currentUserText();
919
- const repo = currentRepoOverlayText(cwd);
920
- const path = userPath;
1124
+ // B2: defaults/user/repo are re-read here, at review. T10: so is the destination.
921
1125
  const modeChanged = state.selectedMode !== rm.mode.mode;
922
1126
  const steeringChanged = ["review", "consult"].some((key) => JSON.stringify(state.steering[key]) !== JSON.stringify(initialSteering[key]));
923
1127
  const judgeSeat = state.judgeSeat;
@@ -927,54 +1131,87 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
927
1131
  pendingWrite = null;
928
1132
  return { kind: "empty" };
929
1133
  }
930
- const lower = lowerLayers();
1134
+ const chosen = chooseDestination(destinationEdits(staged, metadata.seeds, [
1135
+ ...(modeChanged ? ["routing.mode"] : []),
1136
+ ...(steeringChanged ? ["review/consult prefer"] : []),
1137
+ ...(judgeChanged ? ["judge"] : []),
1138
+ ]));
1139
+ if (!chosen.ok) {
1140
+ pendingWrite = null;
1141
+ return { kind: "refused", reason: chosen.reason };
1142
+ }
1143
+ const destination = chosen.destination;
1144
+ const user = currentUserText();
1145
+ const repo = currentRepoOverlayText(cwd);
1146
+ const before = destination === "repo" ? repo : user;
1147
+ const path = pathOf(destination);
1148
+ const lower = lowerLayers(destination);
931
1149
  if (lower.ok)
932
- lowerOverrides = lower.overrides;
933
- const write = userLayerWrite(staged, before, {
934
- lowerOverrides,
935
- ...(modeChanged ? { mode: state.selectedMode } : {}),
936
- ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
937
- steering: { initial: initialSteering, edited: state.steering },
938
- });
939
- const after = renderFleetOverlayWrite(before, write);
1150
+ lowerOverrides[destination] = lower.overrides;
1151
+ let write;
1152
+ let after;
1153
+ try {
1154
+ write = layerWrite(staged, metadata.seeds, destination, before, {
1155
+ lowerOverrides: lowerOverrides[destination],
1156
+ ...(modeChanged ? { mode: state.selectedMode } : {}),
1157
+ ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
1158
+ steering: { initial: initialSteering, edited: state.steering },
1159
+ });
1160
+ after = renderFleetOverlayWrite(before, write); // T10 (D-987): a merge-key path refuses here
1161
+ }
1162
+ catch (error) {
1163
+ if (!(error instanceof ConfigError))
1164
+ throw error;
1165
+ pendingWrite = null;
1166
+ return { kind: "refused", reason: error.message };
1167
+ }
940
1168
  if (before === after) {
941
1169
  pendingWrite = null;
942
1170
  return { kind: "empty" };
943
1171
  }
944
- pendingWrite = { write, repo, user: before };
1172
+ pendingWrite = { write, repo, user, destination, path };
945
1173
  // OBS-1144: the review names every dead pool entry the written config carries, with its reason and
946
1174
  // what still routes — before y. Unloadable bytes carry no notes: the reload guard refuses them anyway.
947
1175
  let poolNotes = [];
948
1176
  try {
949
- const written = loadConfigWithMode(cwd, { globalDir, userOverlayText: after }).cfg;
1177
+ const written = loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, after) }).cfg;
950
1178
  poolNotes = deadPoolEntries(written, undefined, health).map(deadPoolEntryLine);
951
1179
  }
952
1180
  catch {
953
1181
  poolNotes = [];
954
1182
  }
955
- const notes = [...repoShadowNotes(before, after), ...(lower.ok ? [] : [`${lower.error} — y will be refused`]), ...poolNotes];
1183
+ const notes = [
1184
+ // T10: the review names where the edit lands and what that file is
1185
+ ...(destination === "repo" ? [REPO_DESTINATION_NOTE(repoPath)] : repoShadowNotes(before, after)),
1186
+ FUTURE_LOADS_NOTE,
1187
+ ...(lower.ok ? [] : [`${lower.error} — y will be refused`]),
1188
+ ...poolNotes,
1189
+ ];
956
1190
  return {
957
1191
  kind: "diff",
958
1192
  before,
959
1193
  after,
960
1194
  diff: unifiedYamlDiff(before, after, path),
961
1195
  path,
962
- ...(notes.length ? { notes } : {}),
1196
+ notes,
963
1197
  };
964
1198
  };
965
1199
  // B2: every repository inherits the user overlay, so its bytes must load here AND on their own —
966
- // a tiers entry whose vendor/channel only this repository declares would break every other one
967
- const loaderGuard = io.reloadGuard
1200
+ // a tiers entry whose vendor/channel only this repository declares would break every other one.
1201
+ // T10: repository bytes load through the same loader in the repository layer.
1202
+ const loaderGuard = (destination) => io.reloadGuard
968
1203
  ?? ((bytes) => {
1204
+ if (destination === "repo")
1205
+ return overlayBytesLoadError(cwd, bytes, { globalDir, layer: "repo" });
969
1206
  const here = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user" });
970
1207
  if (here !== null)
971
1208
  return here;
972
1209
  const alone = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user", repoOverlayText: "" });
973
1210
  return alone === null ? null
974
- : `the user overlay ${userPath} loads only beside ${repoOverlayPath(cwd)} — every repository inherits it, and without that overlay it fails: ${alone}`;
1211
+ : `the user overlay ${userPath} loads only beside ${repoPath} — every repository inherits it, and without that overlay it fails: ${alone}`;
975
1212
  });
976
1213
  // OBS-1188: a stale preview or an unreadable lower layer answers on the same error channel as the loader
977
- const reloadGuard = (bytes) => (pendingWrite && stalePreview(pendingWrite, bytes)) || loaderGuard(bytes);
1214
+ const reloadGuard = (bytes) => (pendingWrite && stalePreview(pendingWrite, bytes)) || loaderGuard(pendingWrite?.destination ?? "user")(bytes);
978
1215
  const props = {
979
1216
  ageMs: doctorAgeMs(cwd),
980
1217
  adapters,
@@ -999,6 +1236,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
999
1236
  reviewOverlay,
1000
1237
  reloadGuard,
1001
1238
  stagedRouting,
1239
+ holderOf,
1002
1240
  entry: opts.entry,
1003
1241
  initialJudge,
1004
1242
  judgeSeats: seats,
@@ -1024,12 +1262,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1024
1262
  throw new Error("fleet write reached confirmation without a staged overlay mutation");
1025
1263
  const write = pending.write;
1026
1264
  // OBS-1188: re-checked against the bytes on disk at write time; a refusal throws before the temp file exists.
1027
- // B2: the one destination is the user overlay, and bytes the loader refuses never land there either.
1265
+ // T10: the one destination is the one the review confirmed, and bytes the loader refuses never land there.
1028
1266
  try {
1029
- writeFleetOverlay(userPath, (prior) => {
1030
- const refusal = result.review.path === userPath
1031
- ? stalePreview(pending, result.review.after, prior) ?? loaderGuard(result.review.after)
1032
- : `the reviewed destination ${result.review.path} is not the user overlay ${userPath}`;
1267
+ writeFleetOverlay(pending.path, (prior) => {
1268
+ const refusal = result.review.path === pending.path
1269
+ ? stalePreview(pending, result.review.after, prior) ?? loaderGuard(pending.destination)(result.review.after)
1270
+ : `the reviewed destination ${result.review.path} is not the staged destination ${pending.path}`;
1033
1271
  if (refusal !== null)
1034
1272
  throw new ConfigError(refusal);
1035
1273
  return result.review.after;
@@ -1055,7 +1293,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
1055
1293
  }
1056
1294
  }
1057
1295
  // B2: a repo-shadowed save says what still applies here — never claimed applied
1058
- const head = [`fleet: wrote ${userPath}`, ...repoShadowNotes(result.review.before, result.review.after).map((note) => `fleet: ${note}`)].join("\n");
1296
+ const head = [`fleet: wrote ${pending.path}`, ...(pending.destination === "user"
1297
+ ? repoShadowNotes(result.review.before, result.review.after).map((note) => `fleet: ${note}`)
1298
+ : [])].join("\n");
1059
1299
  if (!unprobed.length)
1060
1300
  return head;
1061
1301
  const named = unprobed.slice(0, 3).join(", ") + (unprobed.length > 3 ? `, +${unprobed.length - 3} more` : "");
@@ -639,7 +639,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
639
639
  const interactive = input.isTTY === true && output.isTTY === true && !(values.yes ?? false);
640
640
  // Act 1 — preferences. Only when the repo config does not exist yet: init never rewrites an
641
641
  // operator's config; re-tuning is `tickmarkr fleet` (act 3 below still runs). Act 1 writes only
642
- // project execution preferences; model choices are act 3's, saved to the USER overlay (B2).
642
+ // project execution preferences; model choices are act 3's, saved by Fleet's one destination rule:
643
+ // the USER overlay (B2), except a membership edit the repo overlay holds (T10).
643
644
  if (!repoConfigExists) {
644
645
  if (interactive) {
645
646
  emitBanner();
@@ -677,7 +678,9 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
677
678
  // Act 3 — fleet. The compact discovery surface prints BETWEEN the acts so the operator reads
678
679
  // what the fleet browser (and its presets overlay, raised on the first Shapes entry) ranks
679
680
  // with; the final summary then carries a pointer, not a repeat. Its confirmation is the shared
680
- // Fleet actuator's: the reviewed choices land in the user overlay (gdir), never the repo's.
681
+ // Fleet actuator's: a preset (routing.mode) lands in the user overlay (gdir), and so does every
682
+ // choice except a routing.allow/deny membership edit this repo overlay holds — T10 writes that one
683
+ // where it is effective, and the review names the destination before y.
681
684
  if (interactive) {
682
685
  output.write(`${doc}\n`);
683
686
  doc = "discovery shown above — full matrix any time with `tickmarkr doctor`";