tickmarkr 2.6.5 → 2.6.7
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/claude-code.js +8 -2
- package/dist/adapters/codex-commit-check.d.ts +16 -4
- package/dist/adapters/codex-commit-check.js +150 -26
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +20 -8
- package/dist/adapters/types.d.ts +4 -1
- package/dist/cli/commands/beat.d.ts +6 -1
- package/dist/cli/commands/beat.js +123 -76
- package/dist/cli/commands/doctor.js +3 -1
- package/dist/cli/commands/fleet.js +368 -85
- package/dist/cli/commands/init.js +23 -9
- package/dist/cli/commands/plan.js +12 -1
- package/dist/cli/commands/verify.js +37 -9
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +10 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/config/fleet-overlay.d.ts +14 -0
- package/dist/config/fleet-overlay.js +154 -25
- package/dist/drivers/orca.d.ts +17 -3
- package/dist/drivers/orca.js +64 -20
- package/dist/gates/baseline.d.ts +9 -0
- package/dist/gates/baseline.js +14 -3
- package/dist/gates/cache.js +1 -1
- package/dist/gates/review.js +15 -0
- package/dist/gates/run-gates.d.ts +20 -7
- package/dist/gates/run-gates.js +345 -210
- package/dist/gates/test-manifest.d.ts +11 -0
- package/dist/gates/test-manifest.js +151 -111
- package/dist/gates/timeout-shaped.d.ts +3 -0
- package/dist/gates/timeout-shaped.js +10 -0
- package/dist/run/beat-lifecycle.d.ts +154 -0
- package/dist/run/beat-lifecycle.js +825 -0
- package/dist/run/daemon.js +202 -126
- package/dist/run/git-trust.d.ts +27 -0
- package/dist/run/git-trust.js +178 -0
- package/dist/run/git.js +34 -14
- package/dist/run/journal.js +15 -3
- package/dist/run/lease.d.ts +23 -0
- package/dist/run/lease.js +232 -38
- package/dist/run/merge.js +2 -2
- package/dist/run/stall.js +20 -1
- package/dist/run/supervision.d.ts +8 -0
- package/dist/run/supervision.js +24 -4
- package/dist/run/verification-job.d.ts +51 -0
- package/dist/run/verification-job.js +137 -0
- package/dist/tui/ink/fleet-app.d.ts +35 -5
- package/dist/tui/ink/fleet-app.js +244 -124
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +19 -10
- package/skills/tickmarkr-loop/SKILL.md +19 -10
- package/skills/tickmarkr-overseer/SKILL.md +25 -16
- 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 {
|
|
@@ -271,8 +281,15 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
271
281
|
: readClaudeAliasIdentity(cwd, model) ?? CLAUDE_ALIAS_IDENTITY_STAMPS[model];
|
|
272
282
|
// One ranking universe for the whole screen: every unclassified row bands fleet-relatively
|
|
273
283
|
// against the same set, so a suggestion never depends on which adapter group renders first.
|
|
284
|
+
// T5: a displayed row's real members — every id doctor actually served among its collapsed variants
|
|
285
|
+
// (the bare id only when served, never the display label alone) — folded constituents append theirs.
|
|
286
|
+
// Each real id is ONE member even when doctor's list repeats it, so one act takes one reason from it.
|
|
274
287
|
const detectedRows = fleetUnclassifiedModels(cfg, health, adapters)
|
|
275
|
-
.map((row) => ({
|
|
288
|
+
.map((row) => ({
|
|
289
|
+
...row,
|
|
290
|
+
resolvedModel: resolvedCatalogModel(row.adapter, row.model),
|
|
291
|
+
members: [...new Set([row.model, ...(row.variants ?? [])])].filter((id) => health[row.adapter]?.models?.includes(id)),
|
|
292
|
+
}));
|
|
276
293
|
const catalogRanking = catalogTierRanking(cfg, catalog, detectedRows, resolvedCatalogModel);
|
|
277
294
|
const foldedRows = [];
|
|
278
295
|
const folds = new Map();
|
|
@@ -286,6 +303,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
286
303
|
if (first) {
|
|
287
304
|
first.foldedModels ??= [first.model];
|
|
288
305
|
first.foldedModels.push(row.model);
|
|
306
|
+
// T5: a collapsed constituent keeps every variant when it is catalog-folded
|
|
307
|
+
first.members = [...new Set([...first.members, ...row.members])];
|
|
289
308
|
}
|
|
290
309
|
else {
|
|
291
310
|
foldedRows.push(next);
|
|
@@ -337,9 +356,26 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
337
356
|
...unclassified
|
|
338
357
|
.filter((row) => !editable.tiers[adapter.id]?.[row.model])
|
|
339
358
|
.map((row) => {
|
|
340
|
-
const
|
|
359
|
+
const found = rowEvidence(adapter.id, row.classifyModel ?? row.model, row.advisory.coverage === "covered" ? row.advisory.evidence : undefined);
|
|
360
|
+
// T5: each member keeps its own recorded identity and auth verdict; a group is unauthed as a
|
|
361
|
+
// row only when every member is — one member's failed probe never blocks the others' reach
|
|
362
|
+
const grouped = row.members.length > 1 || (row.members.length === 1 && row.members[0] !== row.model);
|
|
363
|
+
const members = grouped ? row.members.map((model) => {
|
|
364
|
+
const identity = resolvedModelIdentity(adapter.id, model);
|
|
365
|
+
const probed = health[adapter.id]?.modelAuth?.[model];
|
|
366
|
+
return {
|
|
367
|
+
model,
|
|
368
|
+
...(identity !== undefined ? { identity } : {}),
|
|
369
|
+
...(probed?.authed === false ? { unauthed: probed.reason ?? "probe failed" } : {}),
|
|
370
|
+
};
|
|
371
|
+
}) : undefined;
|
|
372
|
+
const { unauthed: _unauthed, ...authedEvidence } = found ?? {};
|
|
373
|
+
const evidence = members?.some((member) => member.unauthed === undefined) && found?.unauthed !== undefined
|
|
374
|
+
? (Object.keys(authedEvidence).length ? authedEvidence : undefined)
|
|
375
|
+
: found;
|
|
341
376
|
return {
|
|
342
377
|
model: row.model,
|
|
378
|
+
...(members ? { members } : {}),
|
|
343
379
|
detectedAt: row.detectedAt,
|
|
344
380
|
...(row.classifyModel ? { classifyModel: row.classifyModel } : {}),
|
|
345
381
|
...(row.variants ? { variants: row.variants } : {}),
|
|
@@ -363,8 +399,6 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
363
399
|
const channels = discoverChannels(cfg, adapters, health);
|
|
364
400
|
const profile = loadRoutingProfile(cwd, cfg, { preview: true });
|
|
365
401
|
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
402
|
const stagedMetadataOf = (stage) => {
|
|
369
403
|
const tiers = structuredClone(initial.tiers);
|
|
370
404
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -377,20 +411,21 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
377
411
|
}
|
|
378
412
|
// OBS-1182: staged efforts replace the loaded ones; none staged anywhere ⇒ no key, as loaded
|
|
379
413
|
const efforts = Object.fromEntries(Object.entries(stage.efforts ?? {}).filter(([, models]) => Object.keys(models).length));
|
|
380
|
-
return { tiers, ...(Object.keys(efforts).length ? { efforts } : {}) };
|
|
414
|
+
return { tiers, ...(Object.keys(efforts).length ? { efforts } : {}), seeds: stage.seededAllowOut ?? [] };
|
|
381
415
|
};
|
|
382
416
|
// B2: the classifications and efforts of the last reviewed state — what a preview called without a
|
|
383
417
|
// 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
|
|
387
|
-
// save, never an assembly-time snapshot; an
|
|
388
|
-
// preview only — the save guard below refuses it.
|
|
389
|
-
|
|
418
|
+
let reviewedStage = { tiers: initial.tiers, efforts: initial.efforts, seeds: [] };
|
|
419
|
+
// OBS-1182: the layers under the written overlay — B2: the defaults alone under the USER overlay;
|
|
420
|
+
// T10: defaults + user under the repository overlay — read raw, so clearing an effort masks an
|
|
421
|
+
// inherited one. OBS-1188: re-read at every review and save, never an assembly-time snapshot; an
|
|
422
|
+
// unreadable read keeps the last good one for the preview only — the save guard below refuses it.
|
|
423
|
+
const lowerOverrides = { user: {}, repo: {} };
|
|
390
424
|
const stagedEditable = (map, deny, metadata) => ({
|
|
391
425
|
...structuredClone(initial),
|
|
392
|
-
|
|
393
|
-
|
|
426
|
+
// metadata carries efforts only when some are staged — as a review writes them
|
|
427
|
+
tiers: structuredClone(metadata.tiers),
|
|
428
|
+
efforts: metadata.efforts && structuredClone(metadata.efforts),
|
|
394
429
|
...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, deny[stagedDenyKeyOf(scope)]])),
|
|
395
430
|
// OBS-1046: the allow complement is staged beside the deny lists, never folded into them
|
|
396
431
|
allowOut: deny.allowOut ?? initial.allowOut,
|
|
@@ -406,13 +441,17 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
406
441
|
// it in every other repository.
|
|
407
442
|
const membershipKeys = [...DENY_SCOPES.filter((scope) => scope.path.length === 3).map((scope) => scope.key), "allowOut"];
|
|
408
443
|
const sameSet = (a = [], b = []) => [...new Set(a)].sort().join() === [...new Set(b)].sort().join();
|
|
409
|
-
|
|
444
|
+
// T10: a repository destination diffs against the effective layers (defaults + user + repo).
|
|
445
|
+
const destinationInitial = (base, edited, destination, text, target) => {
|
|
410
446
|
let userCfg;
|
|
411
447
|
try {
|
|
412
|
-
userCfg = loadConfigWithMode(cwd, {
|
|
448
|
+
userCfg = loadConfigWithMode(cwd, {
|
|
449
|
+
globalDir,
|
|
450
|
+
...(destination === "repo" ? { repoOverlayText: text } : { userOverlayText: text, repoOverlayText: "" }),
|
|
451
|
+
}).cfg;
|
|
413
452
|
}
|
|
414
453
|
catch {
|
|
415
|
-
return base; // ponytail:
|
|
454
|
+
return base; // ponytail: bytes that load only beside another layer — the reload guard refuses the write
|
|
416
455
|
}
|
|
417
456
|
const userLayer = userCfg.routing.map;
|
|
418
457
|
const editedMap = edited.map;
|
|
@@ -429,11 +468,11 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
429
468
|
};
|
|
430
469
|
}
|
|
431
470
|
const membershipTouched = membershipKeys.some((key) => !sameSet(base[key], edited[key]));
|
|
432
|
-
const
|
|
471
|
+
const layer = membershipTouched ? fleetEditableFromConfig(userCfg, target) : undefined;
|
|
433
472
|
return {
|
|
434
473
|
...base,
|
|
435
474
|
map,
|
|
436
|
-
...(
|
|
475
|
+
...(layer ? Object.fromEntries(membershipKeys.map((key) => [key, layer[key] ?? []])) : {}),
|
|
437
476
|
};
|
|
438
477
|
};
|
|
439
478
|
// B2: a changed slot staged with no declaration (auto, no prefer) where the defaults under the user
|
|
@@ -454,24 +493,212 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
454
493
|
}
|
|
455
494
|
return { ...staged, map };
|
|
456
495
|
};
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
496
|
+
// T10 (D-939): ONE pure baseline — the session's initial allow complement plus the allow entry Ink
|
|
497
|
+
// seeded beside each classification it staged (FleetSeededAllow) — serves every preview, the review
|
|
498
|
+
// and the destination choice. `initial` is never mutated.
|
|
499
|
+
const baselineOf = (seeds) => (seeds.length
|
|
500
|
+
? { ...initial, allowOut: [...new Set([...(initial.allowOut ?? []), ...seeds.map((seed) => seed.entry)])].sort() }
|
|
501
|
+
: initial);
|
|
502
|
+
const FLAT_SCOPES = DENY_SCOPES.filter((scope) => scope.path.length === 3);
|
|
503
|
+
const seedChannel = (seed) => ({ adapter: seed.adapter, model: seed.model, ...(seed.identity !== undefined ? { identity: seed.identity } : {}) });
|
|
504
|
+
// an allow-complement entry excludes a seeded channel by the allow form's own matching (adapter id,
|
|
505
|
+
// model, adapter:model, recorded identity) — the seed's spelling is one such entry, never the only one
|
|
506
|
+
const seedRow = (seed) => ({
|
|
507
|
+
adapter: seed.adapter,
|
|
508
|
+
models: [seed.model],
|
|
509
|
+
...(seed.identity !== undefined ? { identities: { [seed.model]: seed.identity } } : {}),
|
|
510
|
+
});
|
|
511
|
+
const allowExcludes = (staged, seed) => (staged.allowOut ?? []).some((entry) => universeEntryMatches(seedRow(seed), seed.model, entry));
|
|
512
|
+
// T5: the real channels a displayed row stands for (fleet's own derivation above); an ungrouped row is one
|
|
513
|
+
const rowMembers = (row) => ("members" in row && row.members)
|
|
514
|
+
|| [{ model: ("classifyModel" in row && row.classifyModel) || row.model, ...(row.evidence?.identity !== undefined ? { identity: row.evidence.identity } : {}) }];
|
|
515
|
+
// membership keeps the channel out: its allow complement or a flat (all-seats) deny entry
|
|
516
|
+
const excludedIn = (staged, seed) => allowExcludes(staged, seed)
|
|
517
|
+
|| FLAT_SCOPES.some((scope) => (staged[scope.key] ?? []).some((entry) => entryMatchesChannel(entry, seedChannel(seed), true)));
|
|
518
|
+
// a seed cleared while another staged complement entry (its adapter's, say) still excludes its
|
|
519
|
+
// channel admitted nothing: the press is a no-op, so the seed stays staged and the shared exclusion
|
|
520
|
+
// is no membership edit — its classification stays a lone tier
|
|
521
|
+
const settled = (staged, seeds) => {
|
|
522
|
+
const kept = seeds.filter((seed) => !(staged.allowOut ?? []).includes(seed.entry) && allowExcludes(staged, seed));
|
|
523
|
+
return kept.length
|
|
524
|
+
? { ...staged, allowOut: [...new Set([...(staged.allowOut ?? []), ...kept.map((seed) => seed.entry)])].sort() }
|
|
525
|
+
: staged;
|
|
526
|
+
};
|
|
527
|
+
// an END state, re-derived per call: no staged complement entry and no flat deny excludes the seed's
|
|
528
|
+
// channel any more — an admission undone by out · all is no admission
|
|
529
|
+
const admittedSeeds = (staged, seeds) => seeds.filter((seed) => !excludedIn(staged, seed));
|
|
530
|
+
// ponytail: rows re-render per key press, each naming its scopes' holders — memoized per (bytes, path),
|
|
531
|
+
// cleared wholesale past 256 entries; a keyed LRU if a fleet ever outgrows it
|
|
532
|
+
const holdingMemo = new Map();
|
|
533
|
+
const holdingLine = (text, path) => {
|
|
534
|
+
const key = `${path.join(".")}\0${text}`;
|
|
535
|
+
if (!holdingMemo.has(key)) {
|
|
536
|
+
if (holdingMemo.size > 256)
|
|
537
|
+
holdingMemo.clear();
|
|
538
|
+
holdingMemo.set(key, overlayHoldingLine(text, path));
|
|
539
|
+
}
|
|
540
|
+
return holdingMemo.get(key);
|
|
541
|
+
};
|
|
542
|
+
// routing.allow is held through its leaves: a bare `allow: {}` declares none, so the merge keeps the
|
|
543
|
+
// lower layer's leaves effective; a null mask at a leaf or above still holds it
|
|
544
|
+
const heldLine = (text, path) => path.join(".") === "routing.allow" && ["adapters", "models"].every((leaf) => holdingLine(text, [...path, leaf]) === undefined)
|
|
545
|
+
? undefined
|
|
546
|
+
: holdingLine(text, path);
|
|
547
|
+
// T10: the repository holds a path when it declares it ([] included) or masks it with null at it or above
|
|
548
|
+
const repoHolds = (path) => heldLine(currentRepoOverlayText(cwd), path) !== undefined;
|
|
549
|
+
// T10: where a scope lives in this repository's merge — `path:line` of the repository overlay when it
|
|
550
|
+
// holds it, else of the user overlay when that one does; undefined = the defaults alone. Paths are
|
|
551
|
+
// short (repo-relative, ~ for home) so the row's detail line keeps them on screen.
|
|
552
|
+
const home = homedir();
|
|
553
|
+
const shown = (path) => (path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
|
|
554
|
+
const holderOf = (dotted) => {
|
|
555
|
+
const path = dotted.split(".");
|
|
556
|
+
const repoLine = heldLine(currentRepoOverlayText(cwd), path);
|
|
557
|
+
if (repoLine !== undefined)
|
|
558
|
+
return `${relative(cwd, repoPath)}:${repoLine}`;
|
|
559
|
+
const userLine = heldLine(currentUserText(), path);
|
|
560
|
+
return userLine === undefined ? undefined : `${shown(userPath)}:${userLine}`;
|
|
561
|
+
};
|
|
562
|
+
// the allow + flat-deny family is ONE coupled write (the allow form is
|
|
563
|
+
// regenerated from the whole staged fleet), so it has one holder: the repository when it holds any of
|
|
564
|
+
// its leaves, else the user. A bare repository `allow: {}` holds no leaf, yet its presence alone keeps
|
|
565
|
+
// the allowlist on: a user save that drops the user allow form would fail-close this repository, so
|
|
566
|
+
// only the repository (which masks it) can take that save. Whether the user form goes away is read off
|
|
567
|
+
// the one writer's user bytes — a staged flat deny, an allow exclusion, or a retained authored entry
|
|
568
|
+
// for a channel this probe never served each keep it, and with it the save, in the user overlay.
|
|
569
|
+
const userFormDropped = (staged, seeds) => {
|
|
570
|
+
const text = currentUserText();
|
|
571
|
+
try {
|
|
572
|
+
const bytes = renderFleetOverlayWrite(text, layerWrite(staged, seeds, "user", text, { lowerOverrides: lowerOverrides.user }));
|
|
573
|
+
return !overlayHoldsMap(bytes, ["routing", "allow"]);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return false; // the review renders the same write and names its refusal
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
const familyHolder = (staged, seeds) => [["routing", "allow"], ...FLAT_SCOPES.map((scope) => scope.path)].some(repoHolds)
|
|
580
|
+
|| (holdingLine(currentRepoOverlayText(cwd), ["routing", "allow"]) !== undefined && userFormDropped(staged, seeds))
|
|
581
|
+
? "repo" : "user";
|
|
582
|
+
const destinationEdits = (pressed, seeds, controls) => {
|
|
583
|
+
const staged = settled(pressed, seeds);
|
|
584
|
+
const baseline = baselineOf(seeds);
|
|
585
|
+
const admitted = admittedSeeds(staged, seeds);
|
|
586
|
+
let held;
|
|
587
|
+
const family = () => (held ??= familyHolder(staged, seeds));
|
|
588
|
+
const edits = [];
|
|
589
|
+
if (admitted.length || membershipKeys.some((key) => !sameSet(baseline[key], staged[key]))) {
|
|
590
|
+
edits.push({ name: "routing.allow/routing.deny membership", destination: family() });
|
|
591
|
+
}
|
|
592
|
+
// a worker leaf follows the layer that holds it
|
|
593
|
+
for (const scope of DENY_SCOPES.filter((candidate) => candidate.path.length > 3)) {
|
|
594
|
+
if (!sameSet(initial[scope.key], staged[scope.key]))
|
|
595
|
+
edits.push({ name: scope.dotted, destination: repoHolds(scope.path) ? "repo" : "user" });
|
|
596
|
+
}
|
|
597
|
+
for (const [adapter, models] of Object.entries(staged.tiers)) {
|
|
598
|
+
for (const [model, value] of Object.entries(models)) {
|
|
599
|
+
if (JSON.stringify(value) === JSON.stringify(initial.tiers[adapter]?.[model]))
|
|
600
|
+
continue;
|
|
601
|
+
// a classification co-locates only with its own admission — its seed cleared, or (T10 review) its
|
|
602
|
+
// own flat-deny lift: a row the baseline membership kept out that the staged one admits; a lone or
|
|
603
|
+
// unrelated tier stays user
|
|
604
|
+
const own = admitted.some((seed) => seed.adapter === adapter && seed.model === model)
|
|
605
|
+
|| (modelGroups.find((group) => group.adapter === adapter)?.rows ?? []).flatMap(rowMembers)
|
|
606
|
+
.filter((member) => member.model === model)
|
|
607
|
+
.some((member) => excludedIn(baseline, { adapter, ...member }) && !excludedIn(staged, { adapter, ...member }));
|
|
608
|
+
edits.push({ name: `tiers.${adapter}.models.${model}`, destination: own ? family() : "user" });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// {} ≡ absent: pin-then-auto leaves an empty entry that writes nothing (fleet-app stagedCount)
|
|
612
|
+
if ([...new Set([...Object.keys(initial.map), ...Object.keys(staged.map)])]
|
|
613
|
+
.some((shape) => JSON.stringify(initial.map[shape] ?? {}) !== JSON.stringify(staged.map[shape] ?? {}))) {
|
|
614
|
+
edits.push({ name: "routing.map", destination: "user" });
|
|
615
|
+
}
|
|
616
|
+
if (JSON.stringify(initial.efforts ?? {}) !== JSON.stringify(staged.efforts ?? {}))
|
|
617
|
+
edits.push({ name: "model efforts", destination: "user" });
|
|
618
|
+
return [...edits, ...controls.map((name) => ({ name, destination: "user" }))];
|
|
619
|
+
};
|
|
620
|
+
// ONE review confirms ONE destination; a batch needing both is refused before any write (no
|
|
621
|
+
// multi-file transaction) and names what to save in separate sessions
|
|
622
|
+
const chooseDestination = (edits) => {
|
|
623
|
+
if (new Set(edits.map((edit) => edit.destination)).size <= 1)
|
|
624
|
+
return { ok: true, destination: edits[0]?.destination ?? "user" };
|
|
625
|
+
const side = (destination) => `${edits.filter((edit) => edit.destination === destination).map((edit) => edit.name).join(", ")} → ${pathOf(destination)}`;
|
|
626
|
+
return {
|
|
627
|
+
ok: false,
|
|
628
|
+
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)`,
|
|
629
|
+
};
|
|
630
|
+
};
|
|
631
|
+
// T10: the universe a write edits — the candidate's own discovery plus every seeded channel, so an
|
|
632
|
+
// unprobed classified model can still be admitted (membership ≠ routability). A seed whose channel
|
|
633
|
+
// the browser never showed, or whose spelling names no channel, is refused.
|
|
634
|
+
const editingUniverse = (candidate, seeds) => {
|
|
635
|
+
const target = universeOf(candidateChannels(candidate));
|
|
636
|
+
// T5 review: the seed's OWN channel must be in the universe — its entry matching another member's
|
|
637
|
+
// recorded identity (C-high recorded as C-low) never stands for an unauthed or unprobed C-low
|
|
638
|
+
const covers = (seed) => target.some((row) => row.adapter === seed.adapter
|
|
639
|
+
&& row.models.includes(seed.model) && universeEntryMatches(row, seed.model, seed.entry));
|
|
640
|
+
for (const seed of seeds) {
|
|
641
|
+
if (covers(seed))
|
|
642
|
+
continue;
|
|
643
|
+
// T5: a seed names a real member of the row it was staged from — never that row's display label
|
|
644
|
+
const shown = modelGroups.some((group) => group.adapter === seed.adapter
|
|
645
|
+
&& group.rows.some((row) => row.model === seed.displayModel && rowMembers(row).some((member) => member.model === seed.model)));
|
|
646
|
+
const row = target.find((candidateRow) => candidateRow.adapter === seed.adapter);
|
|
647
|
+
if (shown && row) {
|
|
648
|
+
if (!row.models.includes(seed.model))
|
|
649
|
+
row.models = [...row.models, seed.model];
|
|
650
|
+
if (seed.identity !== undefined)
|
|
651
|
+
row.identities = { ...row.identities, [seed.model]: seed.identity };
|
|
652
|
+
}
|
|
653
|
+
if (!covers(seed))
|
|
654
|
+
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`);
|
|
655
|
+
}
|
|
656
|
+
return target;
|
|
657
|
+
};
|
|
658
|
+
// T10: the routing.allow the layers BELOW the destination declare (the defaults declare none; under
|
|
659
|
+
// the repository, the user overlay's, read now). "Whole fleet in" masks it, never resurrects it, and
|
|
660
|
+
// while the repository declares no allow of its own, that inherited form's unserved entries are
|
|
661
|
+
// effective here, so the regenerated repository form keeps them.
|
|
662
|
+
const lowerAllow = (destination) => {
|
|
663
|
+
if (destination === "user")
|
|
664
|
+
return DEFAULT_CONFIG.routing.allow;
|
|
665
|
+
const user = readOverlayFile(userPath);
|
|
666
|
+
return user.routing?.allow ?? undefined;
|
|
667
|
+
};
|
|
668
|
+
// B2: the one write builder the previews and the review share, so a preview ranks the bytes y would
|
|
669
|
+
// land. A touched membership serializes against the universe the candidate itself serves (current
|
|
670
|
+
// layers plus staged classifications), rendered first with membership unchanged; a channel only that
|
|
671
|
+
// universe adds keeps the verdict the current layers' allow form gives it — T10: unless it is an
|
|
672
|
+
// admitted seed, which the operator just let in.
|
|
673
|
+
const layerWrite = (pressed, seeds, destination, text, rest) => {
|
|
674
|
+
const staged = settled(pressed, seeds);
|
|
675
|
+
const baseline = baselineOf(seeds);
|
|
676
|
+
const admitted = admittedSeeds(staged, seeds);
|
|
677
|
+
const lower = lowerAllow(destination);
|
|
678
|
+
// per leaf: an inherited allow leaf the repository neither declares nor masks (at it or above) is
|
|
679
|
+
// effective here, so its unserved entries ride the regenerated form even beside a declared sibling
|
|
680
|
+
const inherited = lower && Object.fromEntries(["adapters", "models"]
|
|
681
|
+
.filter((leaf) => lower[leaf] != null && !repoHolds(["routing", "allow", leaf]))
|
|
682
|
+
.map((leaf) => [leaf, lower[leaf]]));
|
|
683
|
+
const fixed = {
|
|
684
|
+
lowerMap: DEFAULT_CONFIG.routing.map,
|
|
685
|
+
...(lower !== undefined ? { lowerAllow: lower } : {}),
|
|
686
|
+
...(inherited && Object.keys(inherited).length ? { preservedAllow: inherited } : {}),
|
|
687
|
+
};
|
|
462
688
|
let edited = maskClearedSlots(initial, staged);
|
|
463
|
-
let target =
|
|
464
|
-
if (membershipKeys.some((key) => !sameSet(
|
|
465
|
-
const unchanged = { ...edited, ...Object.fromEntries(membershipKeys.map((key) => [key,
|
|
689
|
+
let target = editingUniverse(cfg, seeds);
|
|
690
|
+
if (membershipKeys.some((key) => !sameSet(baseline[key], edited[key]))) {
|
|
691
|
+
const unchanged = { ...edited, ...Object.fromEntries(membershipKeys.map((key) => [key, baseline[key]])) };
|
|
466
692
|
try {
|
|
467
|
-
const bytes = renderFleetOverlayWrite(
|
|
468
|
-
...rest, initial: destinationInitial(
|
|
469
|
-
lowerMap: DEFAULT_CONFIG.routing.map,
|
|
693
|
+
const bytes = renderFleetOverlayWrite(text, {
|
|
694
|
+
...rest, ...fixed, initial: destinationInitial(baseline, unchanged, destination, text, target), edited: unchanged, universe: target,
|
|
470
695
|
});
|
|
471
|
-
const candidate = loadConfigWithMode(cwd, { globalDir,
|
|
472
|
-
target =
|
|
696
|
+
const candidate = loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, bytes) }).cfg;
|
|
697
|
+
target = editingUniverse(candidate, seeds);
|
|
473
698
|
const known = (entry) => universe.some((row) => entry === row.adapter || row.models.some((model) => universeEntryMatches(row, model, entry)));
|
|
474
|
-
const
|
|
699
|
+
const admits = (entry) => admitted.some((seed) => target.some((row) => row.adapter === seed.adapter
|
|
700
|
+
&& row.models.some((model) => universeEntryMatches(row, model, seed.entry) && universeEntryMatches(row, model, entry))));
|
|
701
|
+
const grown = (fleetEditableFromConfig(candidate, target).allowOut ?? []).filter((entry) => !known(entry) && !admits(entry));
|
|
475
702
|
if (grown.length)
|
|
476
703
|
edited = { ...edited, allowOut: [...new Set([...(edited.allowOut ?? []), ...grown])].sort() };
|
|
477
704
|
}
|
|
@@ -479,8 +706,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
479
706
|
// the full render below (or the reload guard) names the refusal
|
|
480
707
|
}
|
|
481
708
|
}
|
|
482
|
-
|
|
483
|
-
return { ...rest, initial: destinationInitial(initial, edited, userText, target), edited, universe: target, lowerMap: DEFAULT_CONFIG.routing.map };
|
|
709
|
+
return { ...rest, ...fixed, initial: destinationInitial(baseline, edited, destination, text, target), edited, universe: target };
|
|
484
710
|
};
|
|
485
711
|
const candidateMemo = new Map();
|
|
486
712
|
const previewCfg = (mode, map, deny, stage) => {
|
|
@@ -489,17 +715,24 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
489
715
|
// T8: so are the staged tiers/efforts — a metadata edit never serves the preview cached before it
|
|
490
716
|
const metadata = stage ? stagedMetadataOf(stage) : reviewedStage;
|
|
491
717
|
const userText = currentUserText();
|
|
492
|
-
const
|
|
718
|
+
const repoText = currentRepoOverlayText(cwd);
|
|
719
|
+
const key = JSON.stringify([mode, map, deny, metadata, userText, repoText]);
|
|
493
720
|
const hit = candidateMemo.get(key);
|
|
494
721
|
if (hit)
|
|
495
722
|
return hit;
|
|
496
723
|
let preview;
|
|
497
724
|
try {
|
|
498
|
-
const
|
|
499
|
-
|
|
725
|
+
const staged = stagedEditable(map, deny, metadata);
|
|
726
|
+
// T10: a preview loads the bytes the chosen destination would hold; a mixed batch (refused at w)
|
|
727
|
+
// previews with the repository side, so a row's reach never turns unknown mid-session
|
|
728
|
+
const chosen = chooseDestination(destinationEdits(staged, metadata.seeds, mode !== rm.mode.mode ? ["routing.mode"] : []));
|
|
729
|
+
const destination = chosen.ok ? chosen.destination : "repo";
|
|
730
|
+
const text = destination === "repo" ? repoText : userText;
|
|
731
|
+
const bytes = renderFleetOverlayWrite(text, layerWrite(staged, metadata.seeds, destination, text, {
|
|
732
|
+
lowerOverrides: lowerOverrides[destination],
|
|
500
733
|
...(mode !== rm.mode.mode ? { mode } : {}),
|
|
501
734
|
}));
|
|
502
|
-
preview = { ok: true, ...loadConfigWithMode(cwd, { globalDir,
|
|
735
|
+
preview = { ok: true, ...loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, bytes) }) };
|
|
503
736
|
}
|
|
504
737
|
catch (error) {
|
|
505
738
|
preview = { ok: false, error: error.message.replace(/\s+/g, " ").trim() };
|
|
@@ -510,8 +743,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
510
743
|
return preview;
|
|
511
744
|
};
|
|
512
745
|
const previewUnavailable = (error) => `preview unavailable (${error}) — the staged overlay does not load, so w would be refused`;
|
|
513
|
-
|
|
514
|
-
|
|
746
|
+
// T10: the staged metadata rides along, so a seeded classification's admission reads `in` before w
|
|
747
|
+
const stagedRouting = (deny, stage) => {
|
|
748
|
+
const preview = previewCfg(rm.mode.mode, editable.map, deny, stage);
|
|
515
749
|
return preview.ok ? { ok: true, routing: preview.cfg.routing } : preview;
|
|
516
750
|
};
|
|
517
751
|
// B2: the candidate config's own discovery, blind to both membership scopes — classifications and
|
|
@@ -866,35 +1100,39 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
866
1100
|
// discovered seats universe plus a keep-default row, and a write is staged only on a real change.
|
|
867
1101
|
const initialJudge = `${cfg.judge.adapter}:${cfg.judge.model}`;
|
|
868
1102
|
let pendingWrite = null;
|
|
869
|
-
const lowerLayers = () => lowerLayerModelOverrides({ globalDir, below:
|
|
1103
|
+
const lowerLayers = (destination) => lowerLayerModelOverrides({ globalDir, below: destination });
|
|
870
1104
|
// OBS-1188: the reviewed bytes are the only bytes that may land. Any byte of the user or repo overlay
|
|
871
1105
|
// that moved since the review is a stale preview — a re-render alone would overwrite a same-key edit
|
|
872
1106
|
// with the staged value and still match — then the lower layers are re-rendered as they are NOW.
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1107
|
+
// T10: both overlays are guarded whichever one is written, so a holder that moved after the review
|
|
1108
|
+
// (the destination itself) refuses instead of retargeting. Null = still current.
|
|
1109
|
+
const stalePreview = (pending, reviewed, prior) => {
|
|
1110
|
+
const destinationNow = prior ?? (pending.destination === "repo" ? currentRepoOverlayText(cwd) : currentUserText());
|
|
1111
|
+
const repoNow = pending.destination === "repo" ? destinationNow : currentRepoOverlayText(cwd);
|
|
1112
|
+
const userNow = pending.destination === "user" ? destinationNow : currentUserText();
|
|
1113
|
+
if (repoNow !== pending.repo) {
|
|
1114
|
+
return `stale preview — the repository overlay ${repoPath} changed after the review diff was rendered; press w to review what would be written now`;
|
|
877
1115
|
}
|
|
878
|
-
if (
|
|
1116
|
+
if (userNow !== pending.user) {
|
|
879
1117
|
return `stale preview — the user overlay ${userPath} changed after the review diff was rendered; press w to review what would be written now`;
|
|
880
1118
|
}
|
|
881
|
-
const lower = lowerLayers();
|
|
1119
|
+
const lower = lowerLayers(pending.destination);
|
|
882
1120
|
if (!lower.ok)
|
|
883
1121
|
return lower.error;
|
|
884
1122
|
try {
|
|
885
|
-
if (renderFleetOverlayWrite(
|
|
1123
|
+
if (renderFleetOverlayWrite(destinationNow, { ...pending.write, lowerOverrides: lower.overrides }) === reviewed)
|
|
886
1124
|
return null;
|
|
887
1125
|
}
|
|
888
1126
|
catch (error) {
|
|
889
1127
|
return error.message;
|
|
890
1128
|
}
|
|
891
|
-
return `stale preview — the
|
|
1129
|
+
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
1130
|
};
|
|
893
1131
|
// B2: a saved user key the repository overlay also decides does not apply in THIS repository —
|
|
894
1132
|
// each is named with the value that still applies here, never reported as applied.
|
|
895
1133
|
const repoShadowNotes = (before, after) => repoShadowedUserKeys(cwd, before, after, { globalDir }).map(({ path, value }) => {
|
|
896
1134
|
const shown = value === undefined ? "absent" : typeof value === "string" ? value : JSON.stringify(value);
|
|
897
|
-
return `repo-shadowed: ${path} stays ${shown} in this repository — ${
|
|
1135
|
+
return `repo-shadowed: ${path} stays ${shown} in this repository — ${repoPath} sets it above the user overlay`;
|
|
898
1136
|
});
|
|
899
1137
|
const reviewOverlay = (state) => {
|
|
900
1138
|
// B2: a review re-reads every layer, so no preview rendered before it is served after it
|
|
@@ -914,10 +1152,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
914
1152
|
reviewedStage = metadata;
|
|
915
1153
|
// This callback is the sole candidate-overlay builder. The Ink component renders
|
|
916
1154
|
// 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
|
|
918
|
-
const before = currentUserText();
|
|
919
|
-
const repo = currentRepoOverlayText(cwd);
|
|
920
|
-
const path = userPath;
|
|
1155
|
+
// B2: defaults/user/repo are re-read here, at review. T10: so is the destination.
|
|
921
1156
|
const modeChanged = state.selectedMode !== rm.mode.mode;
|
|
922
1157
|
const steeringChanged = ["review", "consult"].some((key) => JSON.stringify(state.steering[key]) !== JSON.stringify(initialSteering[key]));
|
|
923
1158
|
const judgeSeat = state.judgeSeat;
|
|
@@ -927,54 +1162,87 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
927
1162
|
pendingWrite = null;
|
|
928
1163
|
return { kind: "empty" };
|
|
929
1164
|
}
|
|
930
|
-
const
|
|
1165
|
+
const chosen = chooseDestination(destinationEdits(staged, metadata.seeds, [
|
|
1166
|
+
...(modeChanged ? ["routing.mode"] : []),
|
|
1167
|
+
...(steeringChanged ? ["review/consult prefer"] : []),
|
|
1168
|
+
...(judgeChanged ? ["judge"] : []),
|
|
1169
|
+
]));
|
|
1170
|
+
if (!chosen.ok) {
|
|
1171
|
+
pendingWrite = null;
|
|
1172
|
+
return { kind: "refused", reason: chosen.reason };
|
|
1173
|
+
}
|
|
1174
|
+
const destination = chosen.destination;
|
|
1175
|
+
const user = currentUserText();
|
|
1176
|
+
const repo = currentRepoOverlayText(cwd);
|
|
1177
|
+
const before = destination === "repo" ? repo : user;
|
|
1178
|
+
const path = pathOf(destination);
|
|
1179
|
+
const lower = lowerLayers(destination);
|
|
931
1180
|
if (lower.ok)
|
|
932
|
-
lowerOverrides = lower.overrides;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1181
|
+
lowerOverrides[destination] = lower.overrides;
|
|
1182
|
+
let write;
|
|
1183
|
+
let after;
|
|
1184
|
+
try {
|
|
1185
|
+
write = layerWrite(staged, metadata.seeds, destination, before, {
|
|
1186
|
+
lowerOverrides: lowerOverrides[destination],
|
|
1187
|
+
...(modeChanged ? { mode: state.selectedMode } : {}),
|
|
1188
|
+
...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
|
|
1189
|
+
steering: { initial: initialSteering, edited: state.steering },
|
|
1190
|
+
});
|
|
1191
|
+
after = renderFleetOverlayWrite(before, write); // T10 (D-987): a merge-key path refuses here
|
|
1192
|
+
}
|
|
1193
|
+
catch (error) {
|
|
1194
|
+
if (!(error instanceof ConfigError))
|
|
1195
|
+
throw error;
|
|
1196
|
+
pendingWrite = null;
|
|
1197
|
+
return { kind: "refused", reason: error.message };
|
|
1198
|
+
}
|
|
940
1199
|
if (before === after) {
|
|
941
1200
|
pendingWrite = null;
|
|
942
1201
|
return { kind: "empty" };
|
|
943
1202
|
}
|
|
944
|
-
pendingWrite = { write, repo, user
|
|
1203
|
+
pendingWrite = { write, repo, user, destination, path };
|
|
945
1204
|
// OBS-1144: the review names every dead pool entry the written config carries, with its reason and
|
|
946
1205
|
// what still routes — before y. Unloadable bytes carry no notes: the reload guard refuses them anyway.
|
|
947
1206
|
let poolNotes = [];
|
|
948
1207
|
try {
|
|
949
|
-
const written = loadConfigWithMode(cwd, { globalDir,
|
|
1208
|
+
const written = loadConfigWithMode(cwd, { globalDir, ...layerOf(destination, after) }).cfg;
|
|
950
1209
|
poolNotes = deadPoolEntries(written, undefined, health).map(deadPoolEntryLine);
|
|
951
1210
|
}
|
|
952
1211
|
catch {
|
|
953
1212
|
poolNotes = [];
|
|
954
1213
|
}
|
|
955
|
-
const notes = [
|
|
1214
|
+
const notes = [
|
|
1215
|
+
// T10: the review names where the edit lands and what that file is
|
|
1216
|
+
...(destination === "repo" ? [REPO_DESTINATION_NOTE(repoPath)] : repoShadowNotes(before, after)),
|
|
1217
|
+
FUTURE_LOADS_NOTE,
|
|
1218
|
+
...(lower.ok ? [] : [`${lower.error} — y will be refused`]),
|
|
1219
|
+
...poolNotes,
|
|
1220
|
+
];
|
|
956
1221
|
return {
|
|
957
1222
|
kind: "diff",
|
|
958
1223
|
before,
|
|
959
1224
|
after,
|
|
960
1225
|
diff: unifiedYamlDiff(before, after, path),
|
|
961
1226
|
path,
|
|
962
|
-
|
|
1227
|
+
notes,
|
|
963
1228
|
};
|
|
964
1229
|
};
|
|
965
1230
|
// 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
|
-
|
|
1231
|
+
// a tiers entry whose vendor/channel only this repository declares would break every other one.
|
|
1232
|
+
// T10: repository bytes load through the same loader in the repository layer.
|
|
1233
|
+
const loaderGuard = (destination) => io.reloadGuard
|
|
968
1234
|
?? ((bytes) => {
|
|
1235
|
+
if (destination === "repo")
|
|
1236
|
+
return overlayBytesLoadError(cwd, bytes, { globalDir, layer: "repo" });
|
|
969
1237
|
const here = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user" });
|
|
970
1238
|
if (here !== null)
|
|
971
1239
|
return here;
|
|
972
1240
|
const alone = overlayBytesLoadError(cwd, bytes, { globalDir, layer: "user", repoOverlayText: "" });
|
|
973
1241
|
return alone === null ? null
|
|
974
|
-
: `the user overlay ${userPath} loads only beside ${
|
|
1242
|
+
: `the user overlay ${userPath} loads only beside ${repoPath} — every repository inherits it, and without that overlay it fails: ${alone}`;
|
|
975
1243
|
});
|
|
976
1244
|
// 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);
|
|
1245
|
+
const reloadGuard = (bytes) => (pendingWrite && stalePreview(pendingWrite, bytes)) || loaderGuard(pendingWrite?.destination ?? "user")(bytes);
|
|
978
1246
|
const props = {
|
|
979
1247
|
ageMs: doctorAgeMs(cwd),
|
|
980
1248
|
adapters,
|
|
@@ -999,6 +1267,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
999
1267
|
reviewOverlay,
|
|
1000
1268
|
reloadGuard,
|
|
1001
1269
|
stagedRouting,
|
|
1270
|
+
holderOf,
|
|
1002
1271
|
entry: opts.entry,
|
|
1003
1272
|
initialJudge,
|
|
1004
1273
|
judgeSeats: seats,
|
|
@@ -1024,12 +1293,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
1024
1293
|
throw new Error("fleet write reached confirmation without a staged overlay mutation");
|
|
1025
1294
|
const write = pending.write;
|
|
1026
1295
|
// OBS-1188: re-checked against the bytes on disk at write time; a refusal throws before the temp file exists.
|
|
1027
|
-
//
|
|
1296
|
+
// T10: the one destination is the one the review confirmed, and bytes the loader refuses never land there.
|
|
1028
1297
|
try {
|
|
1029
|
-
writeFleetOverlay(
|
|
1030
|
-
const refusal = result.review.path ===
|
|
1031
|
-
? stalePreview(pending, result.review.after, prior) ?? loaderGuard(result.review.after)
|
|
1032
|
-
: `the reviewed destination ${result.review.path} is not the
|
|
1298
|
+
writeFleetOverlay(pending.path, (prior) => {
|
|
1299
|
+
const refusal = result.review.path === pending.path
|
|
1300
|
+
? stalePreview(pending, result.review.after, prior) ?? loaderGuard(pending.destination)(result.review.after)
|
|
1301
|
+
: `the reviewed destination ${result.review.path} is not the staged destination ${pending.path}`;
|
|
1033
1302
|
if (refusal !== null)
|
|
1034
1303
|
throw new ConfigError(refusal);
|
|
1035
1304
|
return result.review.after;
|
|
@@ -1044,22 +1313,36 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
|
|
|
1044
1313
|
// and it was not configured at probe time), so it stays unroutable — invisible in every
|
|
1045
1314
|
// picker — until the next probe. Name the step, or the classify flow reads as broken.
|
|
1046
1315
|
const unprobed = [];
|
|
1316
|
+
// T5: a grouped classification admits every member's real id, yet a member doctor recorded unauthed
|
|
1317
|
+
// stays unroutable — named, never implied routable by its group's admission
|
|
1318
|
+
const unauthed = [];
|
|
1047
1319
|
for (const [adapter, models] of Object.entries(write.edited.tiers)) {
|
|
1048
1320
|
for (const [model, assigned] of Object.entries(models)) {
|
|
1049
1321
|
if (assigned === null || assigned === undefined)
|
|
1050
1322
|
continue;
|
|
1051
1323
|
if (JSON.stringify(write.initial.tiers[adapter]?.[model]) === JSON.stringify(assigned))
|
|
1052
1324
|
continue;
|
|
1053
|
-
|
|
1325
|
+
const probed = health[adapter]?.modelAuth?.[model];
|
|
1326
|
+
if (probed === undefined)
|
|
1054
1327
|
unprobed.push(`${adapter}:${model}`);
|
|
1328
|
+
else if (probed.authed === false)
|
|
1329
|
+
unauthed.push(`${adapter}:${model}`);
|
|
1055
1330
|
}
|
|
1056
1331
|
}
|
|
1057
1332
|
// B2: a repo-shadowed save says what still applies here — never claimed applied
|
|
1058
|
-
const head = [`fleet: wrote ${
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
const
|
|
1062
|
-
return
|
|
1333
|
+
const head = [`fleet: wrote ${pending.path}`, ...(pending.destination === "user"
|
|
1334
|
+
? repoShadowNotes(result.review.before, result.review.after).map((note) => `fleet: ${note}`)
|
|
1335
|
+
: [])].join("\n");
|
|
1336
|
+
const listed = (ids) => ids.slice(0, 3).join(", ") + (ids.length > 3 ? `, +${ids.length - 3} more` : "");
|
|
1337
|
+
return [
|
|
1338
|
+
head,
|
|
1339
|
+
...(unprobed.length
|
|
1340
|
+
? [`fleet: ${unprobed.length} newly classified model(s) have no probe verdict yet (${listed(unprobed)}) — run \`tickmarkr fleet --fresh\` to probe them; unverified models stay unroutable`]
|
|
1341
|
+
: []),
|
|
1342
|
+
...(unauthed.length
|
|
1343
|
+
? [`fleet: ${unauthed.length} newly classified model(s) are unauthed (${listed(unauthed)}) — re-probe with tickmarkr doctor; unauthed models stay unroutable`]
|
|
1344
|
+
: []),
|
|
1345
|
+
].join("\n");
|
|
1063
1346
|
};
|
|
1064
1347
|
return {
|
|
1065
1348
|
props,
|