tickmarkr 2.6.4 → 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.
- package/README.md +7 -3
- package/dist/adapters/catalog-remote.d.ts +23 -0
- package/dist/adapters/catalog-remote.js +38 -0
- package/dist/adapters/claude-code.d.ts +1 -1
- package/dist/adapters/claude-code.js +4 -1
- package/dist/adapters/codex-commit-check.d.ts +36 -0
- package/dist/adapters/codex-commit-check.js +205 -0
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +12 -6
- package/dist/adapters/model-discovery-check.d.ts +33 -0
- package/dist/adapters/model-discovery-check.js +123 -0
- package/dist/adapters/model-lints.d.ts +1 -1
- package/dist/adapters/model-lints.js +4 -0
- package/dist/adapters/prompt.js +3 -2
- package/dist/adapters/types.d.ts +10 -0
- package/dist/cli/commands/approve.js +10 -4
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +17 -3
- package/dist/cli/commands/fleet.d.ts +10 -1
- package/dist/cli/commands/fleet.js +363 -106
- package/dist/cli/commands/init.js +10 -3
- package/dist/cli/commands/plan.js +76 -4
- package/dist/cli/commands/verify.d.ts +1 -1
- package/dist/cli/commands/verify.js +65 -15
- package/dist/config/fleet-overlay.d.ts +16 -1
- package/dist/config/fleet-overlay.js +230 -28
- package/dist/drivers/orca.d.ts +34 -2
- package/dist/drivers/orca.js +206 -32
- package/dist/gates/cache.d.ts +4 -0
- package/dist/gates/cache.js +10 -2
- package/dist/gates/llm.d.ts +4 -1
- package/dist/gates/llm.js +6 -1
- package/dist/gates/review.js +8 -3
- package/dist/gates/run-gates.d.ts +5 -3
- package/dist/gates/run-gates.js +112 -56
- package/dist/gates/test-manifest.d.ts +3 -0
- package/dist/gates/test-manifest.js +1 -1
- package/dist/gates/timeout-shaped.d.ts +3 -0
- package/dist/gates/timeout-shaped.js +10 -0
- package/dist/run/daemon.js +326 -130
- package/dist/run/git.js +9 -0
- package/dist/run/journal.d.ts +6 -2
- package/dist/run/journal.js +22 -7
- package/dist/run/launchservices-check.d.ts +56 -0
- package/dist/run/launchservices-check.js +117 -0
- package/dist/run/operator-state.d.ts +22 -2
- package/dist/run/operator-state.js +20 -3
- package/dist/run/title-environment.d.ts +45 -0
- package/dist/run/title-environment.js +127 -0
- package/dist/run/title-preload.cjs +38 -0
- package/dist/run/title-preload.d.cts +1 -0
- package/dist/tui/cockpit/board.js +12 -6
- package/dist/tui/cockpit/keys.d.ts +8 -0
- package/dist/tui/cockpit/keys.js +4 -1
- package/dist/tui/cockpit/layout.d.ts +2 -2
- package/dist/tui/cockpit/layout.js +2 -2
- package/dist/tui/cockpit/live-runtime.js +46 -20
- package/dist/tui/cockpit/live-store.d.ts +24 -1
- package/dist/tui/cockpit/live-store.js +99 -8
- package/dist/tui/cockpit/log-view.d.ts +28 -0
- package/dist/tui/cockpit/log-view.js +60 -0
- package/dist/tui/cockpit/run-view.js +4 -0
- package/dist/tui/cockpit/shell.d.ts +2 -1
- package/dist/tui/cockpit/shell.js +6 -4
- package/dist/tui/ink/fleet-app.d.ts +37 -10
- package/dist/tui/ink/fleet-app.js +65 -21
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +32 -2
- package/skills/tickmarkr-loop/SKILL.md +46 -2
- package/skills/tickmarkr-overseer/SKILL.md +83 -8
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
- package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
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 —
|
|
222
|
-
//
|
|
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,20 +373,33 @@ 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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
376
|
+
const stagedMetadataOf = (stage) => {
|
|
377
|
+
const tiers = structuredClone(initial.tiers);
|
|
378
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
379
|
+
for (const classification of stage.classifications) {
|
|
380
|
+
tiers[classification.adapter] ??= {};
|
|
381
|
+
tiers[classification.adapter][classification.model] = {
|
|
382
|
+
tier: classification.tier,
|
|
383
|
+
provenance: `${classification.note} — fleet ${today}`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// OBS-1182: staged efforts replace the loaded ones; none staged anywhere ⇒ no key, as loaded
|
|
387
|
+
const efforts = Object.fromEntries(Object.entries(stage.efforts ?? {}).filter(([, models]) => Object.keys(models).length));
|
|
388
|
+
return { tiers, ...(Object.keys(efforts).length ? { efforts } : {}), seeds: stage.seededAllowOut ?? [] };
|
|
389
|
+
};
|
|
390
|
+
// B2: the classifications and efforts of the last reviewed state — what a preview called without a
|
|
391
|
+
// stage (the browser's staged routing, --why) ranks; the editor passes its current stage (T8).
|
|
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: {} };
|
|
398
|
+
const stagedEditable = (map, deny, metadata) => ({
|
|
378
399
|
...structuredClone(initial),
|
|
379
|
-
|
|
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),
|
|
380
403
|
...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, deny[stagedDenyKeyOf(scope)]])),
|
|
381
404
|
// OBS-1046: the allow complement is staged beside the deny lists, never folded into them
|
|
382
405
|
allowOut: deny.allowOut ?? initial.allowOut,
|
|
@@ -392,13 +415,17 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
392
415
|
// it in every other repository.
|
|
393
416
|
const membershipKeys = [...DENY_SCOPES.filter((scope) => scope.path.length === 3).map((scope) => scope.key), "allowOut"];
|
|
394
417
|
const sameSet = (a = [], b = []) => [...new Set(a)].sort().join() === [...new Set(b)].sort().join();
|
|
395
|
-
|
|
418
|
+
// T10: a repository destination diffs against the effective layers (defaults + user + repo).
|
|
419
|
+
const destinationInitial = (base, edited, destination, text, target) => {
|
|
396
420
|
let userCfg;
|
|
397
421
|
try {
|
|
398
|
-
userCfg = loadConfigWithMode(cwd, {
|
|
422
|
+
userCfg = loadConfigWithMode(cwd, {
|
|
423
|
+
globalDir,
|
|
424
|
+
...(destination === "repo" ? { repoOverlayText: text } : { userOverlayText: text, repoOverlayText: "" }),
|
|
425
|
+
}).cfg;
|
|
399
426
|
}
|
|
400
427
|
catch {
|
|
401
|
-
return base; // ponytail:
|
|
428
|
+
return base; // ponytail: bytes that load only beside another layer — the reload guard refuses the write
|
|
402
429
|
}
|
|
403
430
|
const userLayer = userCfg.routing.map;
|
|
404
431
|
const editedMap = edited.map;
|
|
@@ -415,45 +442,232 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
415
442
|
};
|
|
416
443
|
}
|
|
417
444
|
const membershipTouched = membershipKeys.some((key) => !sameSet(base[key], edited[key]));
|
|
418
|
-
const
|
|
445
|
+
const layer = membershipTouched ? fleetEditableFromConfig(userCfg, target) : undefined;
|
|
419
446
|
return {
|
|
420
447
|
...base,
|
|
421
448
|
map,
|
|
422
|
-
...(
|
|
449
|
+
...(layer ? Object.fromEntries(membershipKeys.map((key) => [key, layer[key] ?? []])) : {}),
|
|
423
450
|
};
|
|
424
451
|
};
|
|
425
452
|
// B2: a changed slot staged with no declaration (auto, no prefer) where the defaults under the user
|
|
426
453
|
// 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
|
|
454
|
+
// masked with prefer: [] — a declaration that replaces the lower slot atomically and ranks nothing.
|
|
455
|
+
// T8: a default PIN is masked by the writer's raw pin: null tombstone instead (lowerMap below), so
|
|
456
|
+
// Auto saves no prefer declaration the operator never made.
|
|
428
457
|
const maskClearedSlots = (base, staged) => {
|
|
429
458
|
const map = { ...staged.map };
|
|
430
459
|
for (const shape of new Set([...Object.keys(base.map), ...Object.keys(staged.map)])) {
|
|
431
460
|
const entry = staged.map[shape];
|
|
461
|
+
const lower = mapSlot(DEFAULT_CONFIG.routing.map[shape]);
|
|
432
462
|
if (JSON.stringify(base.map[shape]) === JSON.stringify(entry))
|
|
433
463
|
continue;
|
|
434
|
-
if (Object.keys(mapSlot(entry)).length || !Object.keys(
|
|
464
|
+
if (Object.keys(mapSlot(entry)).length || !Object.keys(lower).length || lower.pin)
|
|
435
465
|
continue;
|
|
436
466
|
map[shape] = { ...entry, prefer: [] };
|
|
437
467
|
}
|
|
438
468
|
return { ...staged, map };
|
|
439
469
|
};
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
};
|
|
445
657
|
let edited = maskClearedSlots(initial, staged);
|
|
446
|
-
let target =
|
|
447
|
-
if (membershipKeys.some((key) => !sameSet(
|
|
448
|
-
const unchanged = { ...edited, ...Object.fromEntries(membershipKeys.map((key) => [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]])) };
|
|
449
661
|
try {
|
|
450
|
-
const bytes = renderFleetOverlayWrite(
|
|
451
|
-
...rest, initial: destinationInitial(
|
|
662
|
+
const bytes = renderFleetOverlayWrite(text, {
|
|
663
|
+
...rest, ...fixed, initial: destinationInitial(baseline, unchanged, destination, text, target), edited: unchanged, universe: target,
|
|
452
664
|
});
|
|
453
|
-
const candidate = loadConfigWithMode(cwd, { globalDir,
|
|
454
|
-
target =
|
|
665
|
+
const candidate = loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, bytes) }).cfg;
|
|
666
|
+
target = editingUniverse(candidate, seeds);
|
|
455
667
|
const known = (entry) => universe.some((row) => entry === row.adapter || row.models.some((model) => universeEntryMatches(row, model, entry)));
|
|
456
|
-
const
|
|
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));
|
|
457
671
|
if (grown.length)
|
|
458
672
|
edited = { ...edited, allowOut: [...new Set([...(edited.allowOut ?? []), ...grown])].sort() };
|
|
459
673
|
}
|
|
@@ -461,24 +675,33 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
461
675
|
// the full render below (or the reload guard) names the refusal
|
|
462
676
|
}
|
|
463
677
|
}
|
|
464
|
-
return { ...rest, initial: destinationInitial(
|
|
678
|
+
return { ...rest, ...fixed, initial: destinationInitial(baseline, edited, destination, text, target), edited, universe: target };
|
|
465
679
|
};
|
|
466
680
|
const candidateMemo = new Map();
|
|
467
|
-
const previewCfg = (mode, map, deny) => {
|
|
681
|
+
const previewCfg = (mode, map, deny, stage) => {
|
|
468
682
|
// 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
|
|
683
|
+
// deny added before a review) re-renders the preview instead of serving the pre-edit one.
|
|
684
|
+
// T8: so are the staged tiers/efforts — a metadata edit never serves the preview cached before it
|
|
685
|
+
const metadata = stage ? stagedMetadataOf(stage) : reviewedStage;
|
|
470
686
|
const userText = currentUserText();
|
|
471
|
-
const
|
|
687
|
+
const repoText = currentRepoOverlayText(cwd);
|
|
688
|
+
const key = JSON.stringify([mode, map, deny, metadata, userText, repoText]);
|
|
472
689
|
const hit = candidateMemo.get(key);
|
|
473
690
|
if (hit)
|
|
474
691
|
return hit;
|
|
475
692
|
let preview;
|
|
476
693
|
try {
|
|
477
|
-
const
|
|
478
|
-
|
|
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],
|
|
479
702
|
...(mode !== rm.mode.mode ? { mode } : {}),
|
|
480
703
|
}));
|
|
481
|
-
preview = { ok: true, ...loadConfigWithMode(cwd, { globalDir,
|
|
704
|
+
preview = { ok: true, ...loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, bytes) }) };
|
|
482
705
|
}
|
|
483
706
|
catch (error) {
|
|
484
707
|
preview = { ok: false, error: error.message.replace(/\s+/g, " ").trim() };
|
|
@@ -489,8 +712,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
489
712
|
return preview;
|
|
490
713
|
};
|
|
491
714
|
const previewUnavailable = (error) => `preview unavailable (${error}) — the staged overlay does not load, so w would be refused`;
|
|
492
|
-
|
|
493
|
-
|
|
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);
|
|
494
718
|
return preview.ok ? { ok: true, routing: preview.cfg.routing } : preview;
|
|
495
719
|
};
|
|
496
720
|
// B2: the candidate config's own discovery, blind to both membership scopes — classifications and
|
|
@@ -509,12 +733,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
509
733
|
// route() never deny-filters its pool — that contract lives in discoverChannels — so every
|
|
510
734
|
// preview call rebuilds the pool the staged deny would discover
|
|
511
735
|
const previewPool = (cfgPreview) => candidateChannels(cfgPreview).filter((c) => disallowedBy(c, cfgPreview.routing) === null);
|
|
512
|
-
const modeSpend = (mode, map, deny) => {
|
|
736
|
+
const modeSpend = (mode, map, deny, stage) => {
|
|
513
737
|
const tierCount = {};
|
|
514
738
|
let subs = 0;
|
|
515
739
|
let apiN = 0;
|
|
516
740
|
let apiUsd = 0;
|
|
517
|
-
const preview = previewCfg(mode, map, deny);
|
|
741
|
+
const preview = previewCfg(mode, map, deny, stage);
|
|
518
742
|
if (!preview.ok)
|
|
519
743
|
return ` mix: ${previewUnavailable(preview.error)}`;
|
|
520
744
|
const cfgPreview = preview.cfg;
|
|
@@ -547,10 +771,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
547
771
|
};
|
|
548
772
|
// B2: the floors the candidate user-layer bytes resolve to — the same config the routed mix ranks
|
|
549
773
|
// against, so a repo-declared mode that shadows the user's pick previews no floor change
|
|
550
|
-
const floorPreview = (mode, map, deny) => {
|
|
774
|
+
const floorPreview = (mode, map, deny, stage) => {
|
|
551
775
|
if (mode === rm.mode.mode)
|
|
552
776
|
return [];
|
|
553
|
-
const preview = previewCfg(mode, map, deny);
|
|
777
|
+
const preview = previewCfg(mode, map, deny, stage);
|
|
554
778
|
if (!preview.ok)
|
|
555
779
|
return []; // modeSpend names the refusal
|
|
556
780
|
const current = cfg.routing.floors;
|
|
@@ -585,8 +809,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
585
809
|
? { declaredAt: `routing.floors.${shape}` }
|
|
586
810
|
: { declaredAt: "routing.mode" };
|
|
587
811
|
};
|
|
588
|
-
const projectedShapeRows = (mode, map, deny) => {
|
|
589
|
-
const preview = previewCfg(mode, map, deny);
|
|
812
|
+
const projectedShapeRows = (mode, map, deny, stage) => {
|
|
813
|
+
const preview = previewCfg(mode, map, deny, stage);
|
|
590
814
|
if (!preview.ok) {
|
|
591
815
|
return projectFleetWhy(SHAPES.map((shape) => ({
|
|
592
816
|
id: shape,
|
|
@@ -611,7 +835,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
611
835
|
// a pin. The declaration is the operator's decision; the winner is today's dice.
|
|
612
836
|
const declaredPool = entry?.pool;
|
|
613
837
|
const poolPrefix = declaredPool ? `pool(${declaredPool.mode}·${declaredPool.channels.length}) → ` : "";
|
|
614
|
-
|
|
838
|
+
// T8: the launch effort the routed channel carries — a staged effort shows here before w
|
|
839
|
+
const effort = assignment.effort ? `, effort ${assignment.effort}` : "";
|
|
840
|
+
const effective = `${poolPrefix}${assignment.adapter}:${assignment.model} (${assignment.channel}, ${assignment.tier}${effort}) ${costSignal(assignment, cfgPreview.pricing)}${shadowNote}`;
|
|
615
841
|
const mapProducedValue = entry?.pin !== undefined || entry?.pool !== undefined
|
|
616
842
|
|| routed.provenance.includes("via prefer");
|
|
617
843
|
return {
|
|
@@ -633,7 +859,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
633
859
|
});
|
|
634
860
|
return projectFleetWhy(values, { repoRoot: cwd, globalDir });
|
|
635
861
|
};
|
|
636
|
-
const routedShapeRows = (mode, map, deny) => projectedShapeRows(mode, map, deny).map(({ id, label }) => ({ id, label }));
|
|
862
|
+
const routedShapeRows = (mode, map, deny, stage) => projectedShapeRows(mode, map, deny, stage).map(({ id, label }) => ({ id, label }));
|
|
637
863
|
// OBS-530: the picker silently omitted every excluded channel — the operator asked "why is X
|
|
638
864
|
// missing" three times in one session and the answer lived only in config archaeology. One dim
|
|
639
865
|
// line now names each bucket. Static buckets (unauthed, unclassified) computed once; the
|
|
@@ -802,8 +1028,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
802
1028
|
const order = (key) => (carried.includes(key) ? carried.indexOf(key) : carried.length);
|
|
803
1029
|
return [...lines, ...unnamed].sort((a, b) => order(a.key) - order(b.key)).map((row) => row.line);
|
|
804
1030
|
};
|
|
805
|
-
const candidatesForShape = (shape, mode, map, deny) => {
|
|
806
|
-
const preview = previewCfg(mode, map, deny);
|
|
1031
|
+
const candidatesForShape = (shape, mode, map, deny, stage) => {
|
|
1032
|
+
const preview = previewCfg(mode, map, deny, stage);
|
|
807
1033
|
if (!preview.ok)
|
|
808
1034
|
return { rows: [], excludedNote: previewUnavailable(preview.error) };
|
|
809
1035
|
const cfgPreview = preview.cfg;
|
|
@@ -843,35 +1069,39 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
843
1069
|
// discovered seats universe plus a keep-default row, and a write is staged only on a real change.
|
|
844
1070
|
const initialJudge = `${cfg.judge.adapter}:${cfg.judge.model}`;
|
|
845
1071
|
let pendingWrite = null;
|
|
846
|
-
const lowerLayers = () => lowerLayerModelOverrides({ globalDir, below:
|
|
1072
|
+
const lowerLayers = (destination) => lowerLayerModelOverrides({ globalDir, below: destination });
|
|
847
1073
|
// OBS-1188: the reviewed bytes are the only bytes that may land. Any byte of the user or repo overlay
|
|
848
1074
|
// that moved since the review is a stale preview — a re-render alone would overwrite a same-key edit
|
|
849
1075
|
// with the staged value and still match — then the lower layers are re-rendered as they are NOW.
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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`;
|
|
854
1084
|
}
|
|
855
|
-
if (
|
|
1085
|
+
if (userNow !== pending.user) {
|
|
856
1086
|
return `stale preview — the user overlay ${userPath} changed after the review diff was rendered; press w to review what would be written now`;
|
|
857
1087
|
}
|
|
858
|
-
const lower = lowerLayers();
|
|
1088
|
+
const lower = lowerLayers(pending.destination);
|
|
859
1089
|
if (!lower.ok)
|
|
860
1090
|
return lower.error;
|
|
861
1091
|
try {
|
|
862
|
-
if (renderFleetOverlayWrite(
|
|
1092
|
+
if (renderFleetOverlayWrite(destinationNow, { ...pending.write, lowerOverrides: lower.overrides }) === reviewed)
|
|
863
1093
|
return null;
|
|
864
1094
|
}
|
|
865
1095
|
catch (error) {
|
|
866
1096
|
return error.message;
|
|
867
1097
|
}
|
|
868
|
-
return `stale preview — the
|
|
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`;
|
|
869
1099
|
};
|
|
870
1100
|
// B2: a saved user key the repository overlay also decides does not apply in THIS repository —
|
|
871
1101
|
// each is named with the value that still applies here, never reported as applied.
|
|
872
1102
|
const repoShadowNotes = (before, after) => repoShadowedUserKeys(cwd, before, after, { globalDir }).map(({ path, value }) => {
|
|
873
1103
|
const shown = value === undefined ? "absent" : typeof value === "string" ? value : JSON.stringify(value);
|
|
874
|
-
return `repo-shadowed: ${path} stays ${shown} in this repository — ${
|
|
1104
|
+
return `repo-shadowed: ${path} stays ${shown} in this repository — ${repoPath} sets it above the user overlay`;
|
|
875
1105
|
});
|
|
876
1106
|
const reviewOverlay = (state) => {
|
|
877
1107
|
// B2: a review re-reads every layer, so no preview rendered before it is served after it
|
|
@@ -882,27 +1112,16 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
882
1112
|
staged[scope.key] = state[scope.key];
|
|
883
1113
|
staged.allowOut = state.allowOut ?? initial.allowOut;
|
|
884
1114
|
staged.map = state.map;
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if (
|
|
888
|
-
staged.efforts = efforts;
|
|
1115
|
+
const metadata = stagedMetadataOf(state);
|
|
1116
|
+
staged.tiers = metadata.tiers;
|
|
1117
|
+
if (metadata.efforts)
|
|
1118
|
+
staged.efforts = metadata.efforts;
|
|
889
1119
|
else
|
|
890
1120
|
delete staged.efforts;
|
|
891
|
-
|
|
892
|
-
for (const classification of state.classifications) {
|
|
893
|
-
staged.tiers[classification.adapter] ??= {};
|
|
894
|
-
staged.tiers[classification.adapter][classification.model] = {
|
|
895
|
-
tier: classification.tier,
|
|
896
|
-
provenance: `${classification.note} — fleet ${today}`,
|
|
897
|
-
};
|
|
898
|
-
}
|
|
899
|
-
reviewedStage = { tiers: staged.tiers, efforts: staged.efforts };
|
|
1121
|
+
reviewedStage = metadata;
|
|
900
1122
|
// This callback is the sole candidate-overlay builder. The Ink component renders
|
|
901
1123
|
// the diff and asks for confirmation, but owns neither filesystem access nor a writer.
|
|
902
|
-
// B2: defaults/user/repo are re-read here, at review
|
|
903
|
-
const before = currentUserText();
|
|
904
|
-
const repo = currentRepoOverlayText(cwd);
|
|
905
|
-
const path = userPath;
|
|
1124
|
+
// B2: defaults/user/repo are re-read here, at review. T10: so is the destination.
|
|
906
1125
|
const modeChanged = state.selectedMode !== rm.mode.mode;
|
|
907
1126
|
const steeringChanged = ["review", "consult"].some((key) => JSON.stringify(state.steering[key]) !== JSON.stringify(initialSteering[key]));
|
|
908
1127
|
const judgeSeat = state.judgeSeat;
|
|
@@ -912,54 +1131,87 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
912
1131
|
pendingWrite = null;
|
|
913
1132
|
return { kind: "empty" };
|
|
914
1133
|
}
|
|
915
|
-
const
|
|
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);
|
|
916
1149
|
if (lower.ok)
|
|
917
|
-
lowerOverrides = lower.overrides;
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
+
}
|
|
925
1168
|
if (before === after) {
|
|
926
1169
|
pendingWrite = null;
|
|
927
1170
|
return { kind: "empty" };
|
|
928
1171
|
}
|
|
929
|
-
pendingWrite = { write, repo, user
|
|
1172
|
+
pendingWrite = { write, repo, user, destination, path };
|
|
930
1173
|
// OBS-1144: the review names every dead pool entry the written config carries, with its reason and
|
|
931
1174
|
// what still routes — before y. Unloadable bytes carry no notes: the reload guard refuses them anyway.
|
|
932
1175
|
let poolNotes = [];
|
|
933
1176
|
try {
|
|
934
|
-
const written = loadConfigWithMode(cwd, { globalDir,
|
|
1177
|
+
const written = loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, after) }).cfg;
|
|
935
1178
|
poolNotes = deadPoolEntries(written, undefined, health).map(deadPoolEntryLine);
|
|
936
1179
|
}
|
|
937
1180
|
catch {
|
|
938
1181
|
poolNotes = [];
|
|
939
1182
|
}
|
|
940
|
-
const notes = [
|
|
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
|
+
];
|
|
941
1190
|
return {
|
|
942
1191
|
kind: "diff",
|
|
943
1192
|
before,
|
|
944
1193
|
after,
|
|
945
1194
|
diff: unifiedYamlDiff(before, after, path),
|
|
946
1195
|
path,
|
|
947
|
-
|
|
1196
|
+
notes,
|
|
948
1197
|
};
|
|
949
1198
|
};
|
|
950
1199
|
// 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
|
|
952
|
-
|
|
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
|
|
953
1203
|
?? ((bytes) => {
|
|
1204
|
+
if (destination === "repo")
|
|
1205
|
+
return overlayBytesLoadError(cwd, bytes, { globalDir, layer: "repo" });
|
|
954
1206
|
const here = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user" });
|
|
955
1207
|
if (here !== null)
|
|
956
1208
|
return here;
|
|
957
1209
|
const alone = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user", repoOverlayText: "" });
|
|
958
1210
|
return alone === null ? null
|
|
959
|
-
: `the user overlay ${userPath} loads only beside ${
|
|
1211
|
+
: `the user overlay ${userPath} loads only beside ${repoPath} — every repository inherits it, and without that overlay it fails: ${alone}`;
|
|
960
1212
|
});
|
|
961
1213
|
// OBS-1188: a stale preview or an unreadable lower layer answers on the same error channel as the loader
|
|
962
|
-
const reloadGuard = (bytes) => (pendingWrite && stalePreview(pendingWrite, bytes)) || loaderGuard(bytes);
|
|
1214
|
+
const reloadGuard = (bytes) => (pendingWrite && stalePreview(pendingWrite, bytes)) || loaderGuard(pendingWrite?.destination ?? "user")(bytes);
|
|
963
1215
|
const props = {
|
|
964
1216
|
ageMs: doctorAgeMs(cwd),
|
|
965
1217
|
adapters,
|
|
@@ -971,7 +1223,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
971
1223
|
initialMode: rm.mode.mode,
|
|
972
1224
|
modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
|
|
973
1225
|
initialMap: editable.map,
|
|
974
|
-
modePreview: (mode, map, deny) => [modeSpend(mode, map, deny), ...floorPreview(mode, map, deny)],
|
|
1226
|
+
modePreview: (mode, map, deny, stage) => [modeSpend(mode, map, deny, stage), ...floorPreview(mode, map, deny, stage)],
|
|
975
1227
|
shapeRows: routedShapeRows,
|
|
976
1228
|
candidatesForShape,
|
|
977
1229
|
preferOptionsForShape: (_shape, current) => [
|
|
@@ -984,6 +1236,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
984
1236
|
reviewOverlay,
|
|
985
1237
|
reloadGuard,
|
|
986
1238
|
stagedRouting,
|
|
1239
|
+
holderOf,
|
|
987
1240
|
entry: opts.entry,
|
|
988
1241
|
initialJudge,
|
|
989
1242
|
judgeSeats: seats,
|
|
@@ -1009,12 +1262,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
1009
1262
|
throw new Error("fleet write reached confirmation without a staged overlay mutation");
|
|
1010
1263
|
const write = pending.write;
|
|
1011
1264
|
// OBS-1188: re-checked against the bytes on disk at write time; a refusal throws before the temp file exists.
|
|
1012
|
-
//
|
|
1265
|
+
// T10: the one destination is the one the review confirmed, and bytes the loader refuses never land there.
|
|
1013
1266
|
try {
|
|
1014
|
-
writeFleetOverlay(
|
|
1015
|
-
const refusal = result.review.path ===
|
|
1016
|
-
? stalePreview(pending, result.review.after, prior) ?? loaderGuard(result.review.after)
|
|
1017
|
-
: `the reviewed destination ${result.review.path} is not the
|
|
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}`;
|
|
1018
1271
|
if (refusal !== null)
|
|
1019
1272
|
throw new ConfigError(refusal);
|
|
1020
1273
|
return result.review.after;
|
|
@@ -1040,7 +1293,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
1040
1293
|
}
|
|
1041
1294
|
}
|
|
1042
1295
|
// B2: a repo-shadowed save says what still applies here — never claimed applied
|
|
1043
|
-
const head = [`fleet: wrote ${
|
|
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");
|
|
1044
1299
|
if (!unprobed.length)
|
|
1045
1300
|
return head;
|
|
1046
1301
|
const named = unprobed.slice(0, 3).join(", ") + (unprobed.length > 3 ? `, +${unprobed.length - 3} more` : "");
|
|
@@ -1050,5 +1305,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
1050
1305
|
props,
|
|
1051
1306
|
commit,
|
|
1052
1307
|
renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, stagedDenyOf(editable))),
|
|
1308
|
+
// T8: the memoized candidate every preview callback ranks — read-only, so its identity is checkable
|
|
1309
|
+
previewConfig: previewCfg,
|
|
1053
1310
|
};
|
|
1054
1311
|
}
|