tickmarkr 2.5.4 → 2.5.5

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.
Files changed (59) hide show
  1. package/dist/adapters/registry.js +6 -1
  2. package/dist/adapters/types.d.ts +3 -0
  3. package/dist/cli/commands/approve.d.ts +1 -0
  4. package/dist/cli/commands/approve.js +54 -6
  5. package/dist/cli/commands/doctor.js +44 -29
  6. package/dist/cli/commands/fleet.js +195 -33
  7. package/dist/cli/commands/init.js +196 -6
  8. package/dist/cli/commands/resume.js +4 -2
  9. package/dist/cli/commands/run.js +11 -2
  10. package/dist/cli/commands/verify.js +1 -1
  11. package/dist/cli/help.d.ts +2 -0
  12. package/dist/cli/help.js +3 -1
  13. package/dist/config/config.d.ts +14 -8
  14. package/dist/config/config.js +29 -26
  15. package/dist/config/fleet-overlay.d.ts +3 -9
  16. package/dist/config/fleet-overlay.js +68 -11
  17. package/dist/config/fleet-why.d.ts +7 -0
  18. package/dist/config/fleet-why.js +5 -0
  19. package/dist/drivers/index.d.ts +15 -1
  20. package/dist/drivers/index.js +38 -10
  21. package/dist/drivers/orca.d.ts +99 -10
  22. package/dist/drivers/orca.js +586 -97
  23. package/dist/gates/baseline.d.ts +3 -0
  24. package/dist/gates/baseline.js +2 -1
  25. package/dist/gates/cache.d.ts +100 -0
  26. package/dist/gates/cache.js +389 -0
  27. package/dist/gates/run-gates.d.ts +3 -0
  28. package/dist/gates/run-gates.js +117 -10
  29. package/dist/gates/test-manifest.d.ts +99 -0
  30. package/dist/gates/test-manifest.js +389 -0
  31. package/dist/gates/test-reporter.d.ts +4 -0
  32. package/dist/gates/test-reporter.js +49 -0
  33. package/dist/route/preference.d.ts +22 -1
  34. package/dist/route/preference.js +123 -25
  35. package/dist/route/router.js +31 -6
  36. package/dist/run/daemon.d.ts +13 -0
  37. package/dist/run/daemon.js +160 -70
  38. package/dist/run/git.d.ts +10 -1
  39. package/dist/run/git.js +43 -9
  40. package/dist/run/journal.d.ts +14 -2
  41. package/dist/run/journal.js +78 -10
  42. package/dist/run/lease.d.ts +14 -0
  43. package/dist/run/lease.js +87 -0
  44. package/dist/run/merge.d.ts +2 -0
  45. package/dist/run/merge.js +91 -3
  46. package/dist/run/operator-state.d.ts +11 -0
  47. package/dist/run/operator-state.js +17 -3
  48. package/dist/tui/cockpit/board.d.ts +96 -0
  49. package/dist/tui/cockpit/board.js +346 -0
  50. package/dist/tui/cockpit/decision-actions.js +2 -0
  51. package/dist/tui/cockpit/layout.d.ts +5 -1
  52. package/dist/tui/cockpit/layout.js +8 -3
  53. package/dist/tui/cockpit/live-runtime.js +71 -21
  54. package/dist/tui/cockpit/run-view.d.ts +7 -5
  55. package/dist/tui/cockpit/run-view.js +12 -11
  56. package/dist/tui/ink/fleet-app.d.ts +41 -27
  57. package/dist/tui/ink/fleet-app.js +204 -31
  58. package/package.json +1 -1
  59. package/skills/tickmarkr-overseer/SKILL.md +173 -113
@@ -578,7 +578,12 @@ export function discoverChannels(cfg, adapters, health, role = "worker") {
578
578
  // channel — routing an unknown or 403 into dispatch is fail-closed. Operators with pre-v1.21 doctor.json
579
579
  // can opt into the prior unknown-is-routable behavior with routing.allowUnverifiedModels.
580
580
  // OBS-117: configured aliases absent from the adapter CLI's own model list are not dispatchable.
581
- return a.channels(cfg).filter((c) => !invalid.has(c.model) && modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)));
581
+ return a.channels(cfg)
582
+ .filter((c) => !invalid.has(c.model) && modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)))
583
+ .map((c) => {
584
+ const identity = h?.modelAuth?.[c.model]?.identity ?? h?.modelIdentities?.[c.model];
585
+ return identity ? { ...c, identity } : c;
586
+ });
582
587
  });
583
588
  if (!cfg.routing.allow && !cfg.routing.deny)
584
589
  return base;
@@ -22,6 +22,7 @@ export interface BillingChannel {
22
22
  model: string;
23
23
  channel: "sub" | "api";
24
24
  tier: Tier;
25
+ identity?: string;
25
26
  }
26
27
  export declare const MODEL_PROBE_ERRORS: readonly ["EMFILE", "EAGAIN", "ENFILE", "ENOMEM", "ENOSPC"];
27
28
  export type ModelProbeError = typeof MODEL_PROBE_ERRORS[number];
@@ -30,6 +31,7 @@ export interface ModelAuth {
30
31
  reason?: string;
31
32
  probeError?: ModelProbeError;
32
33
  probedAt: string;
34
+ identity?: string;
33
35
  }
34
36
  export interface AuthHealth {
35
37
  installed: boolean;
@@ -40,6 +42,7 @@ export interface AuthHealth {
40
42
  modelsDetectedAt?: string;
41
43
  servable?: string[];
42
44
  modelAuth?: Record<string, ModelAuth>;
45
+ modelIdentities?: Record<string, string>;
43
46
  }
44
47
  export declare function modelAuthed(health: AuthHealth | undefined, model: string, allowUnverifiedModels?: boolean): boolean;
45
48
  export interface Invocation {
@@ -21,6 +21,7 @@ export interface NewestPark {
21
21
  /** The daemon-recorded kind (task-human data.kind), never inferred from prose. */
22
22
  kind: string | undefined;
23
23
  reason: string | undefined;
24
+ approveCommand?: string;
24
25
  /** The newest failed gate before the park — the gate a waive would satisfy. */
25
26
  failedGate: string | undefined;
26
27
  /** A pre-dispatch human gate whose reason marks it permanent by design (see isTombstonePark). */
@@ -1,6 +1,8 @@
1
+ import { graphDefinitionHash, loadGraph, saveGraph } from "../../graph/graph.js";
1
2
  import { userInfo } from "node:os";
3
+ import { separabilityErrors } from "../../compile/collateral.js";
2
4
  import { GATE_NAMES } from "../../graph/schema.js";
3
- import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
5
+ import { applyScopeAmendments, engagementComparable, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
4
6
  export const APPROVAL_DISPOSITIONS = ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
5
7
  /**
6
8
  * What each disposition's release actually buys — the clause every operator-facing sentence about
@@ -52,6 +54,7 @@ sourceIndexes) {
52
54
  const reason = typeof e.data.reason === "string" ? e.data.reason : undefined;
53
55
  return {
54
56
  index: i, line: (sourceIndexes?.[i] ?? i) + 1, ts: typeof e.ts === "string" ? e.ts : undefined, kind, reason,
57
+ approveCommand: typeof e.data.approveCommand === "string" ? e.data.approveCommand : undefined,
55
58
  failedGate: failedGateForNewestPark(events, taskId, i), tombstone: isTombstonePark(kind, reason),
56
59
  };
57
60
  }
@@ -73,7 +76,7 @@ export function readJournalEvents(journal) {
73
76
  * surface may read it from, so what a menu offers and what the command accepts cannot drift.
74
77
  */
75
78
  export function permittedDecisionVerbs(park) {
76
- if (!park || park.tombstone)
79
+ if (!park || park.tombstone || park.kind === "scope-request")
77
80
  return [];
78
81
  if (park.kind === "gate-fail") {
79
82
  if (park.failedGate === undefined)
@@ -123,7 +126,7 @@ export function approvalEnactment(token, run) {
123
126
  }
124
127
  /** The production command registered in COMMANDS; its returned bytes are what the CLI prints. */
125
128
  export async function approve(argv, cwd = process.cwd()) {
126
- const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling } = parseArgs(argv);
129
+ const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling, files } = parseArgs(argv);
127
130
  const decisions = [waive, uphold, recheck].filter(Boolean).length;
128
131
  if (decisions > 1)
129
132
  throw new Error("--waive, --uphold and --recheck are different decisions — pass one");
@@ -148,6 +151,41 @@ export async function approve(argv, cwd = process.cwd()) {
148
151
  const gateFailPark = park?.kind === "gate-fail";
149
152
  const infraPark = park?.kind === "infra";
150
153
  const failedGate = gateFailPark ? park?.failedGate : undefined;
154
+ if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
155
+ if (park?.kind !== "scope-request")
156
+ throw new Error("--files requires a scope-request park");
157
+ if (!files?.length)
158
+ throw new Error(`scope-request for ${taskId} requires --files <glob,…>`);
159
+ if (decisions)
160
+ throw new Error("--files cannot be combined with --waive, --uphold or --recheck");
161
+ const graph = applyScopeAmendments(loadGraph(cwd), journal);
162
+ const from = graphDefinitionHash(graph);
163
+ if (!engagementComparable(journal.read(), from).comparable
164
+ || (lastHuman?.data.graphDefinitionHash !== undefined && lastHuman.data.graphDefinitionHash !== from)) {
165
+ throw new Error(`refusing stale scope-request approval for ${taskId}: graph revision changed`);
166
+ }
167
+ const task = graph.tasks.find((t) => t.id === taskId);
168
+ if (!task)
169
+ throw new Error(`unknown task ${taskId}`);
170
+ const amendedFiles = [...new Set([...task.files, ...files])];
171
+ if (amendedFiles.length === task.files.length)
172
+ throw new Error("--files must extend the parked files[] revision");
173
+ const amended = { ...graph, tasks: graph.tasks.map((t) => t.id === taskId ? { ...t, files: amendedFiles } : t) };
174
+ const conflicts = separabilityErrors(amended.tasks);
175
+ if (conflicts.length)
176
+ throw new Error(`refusing scope-request approval for ${taskId}: ${conflicts.join("\n")}`);
177
+ journal.append("task-approved", taskId, {
178
+ by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
179
+ amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line },
180
+ });
181
+ // Do not write graph.json from this process while the daemon owns it: its sweep materializes
182
+ // the amendment without replacing a sibling's running state with this command's snapshot.
183
+ const projected = applyScopeAmendments(graph, journal);
184
+ const owner = approvalRunOwner(cwd, runId);
185
+ if (!owner.live && !owner.blockingRunId)
186
+ saveGraph(cwd, projected);
187
+ return disposition(cwd, runId, "dispatch", `approved files[] for ${taskId} in ${runId} — by ${by}`, serialization.contended);
188
+ }
151
189
  if (gateFailPark && !failedGate) {
152
190
  throw new Error(`task ${taskId} is parked on gate-fail but has no failed gate result on the newest park — refusing to infer one`);
153
191
  }
@@ -247,11 +285,12 @@ function disposition(cwd, runId, token, message, contended) {
247
285
  };
248
286
  return `${out}\nTICKMARKR_APPROVAL ${JSON.stringify(record)}`;
249
287
  }
250
- const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
288
+ const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--files <glob,…>] [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
251
289
  // hand-parsed argv — no CLI framework (house style). Positionals are runId then taskId; decision,
252
290
  // ceiling, actor and reason are flags. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
253
291
  function parseArgs(argv) {
254
292
  const positionals = [];
293
+ let files;
255
294
  let by;
256
295
  let reason;
257
296
  let waive = false;
@@ -260,7 +299,16 @@ function parseArgs(argv) {
260
299
  let reviewRoundCeiling;
261
300
  for (let i = 0; i < argv.length; i++) {
262
301
  const a = argv[i];
263
- if (a === "--by") {
302
+ if (a === "--files") {
303
+ const value = argv[++i];
304
+ if (!value || value.startsWith("--"))
305
+ throw new Error("--files requires <glob,…>");
306
+ files = value.split(",").map((file) => file.trim());
307
+ if (files.some((file) => !file || file.startsWith("/") || file.startsWith("!") || file.split("/").includes(".."))) {
308
+ throw new Error("--files requires non-empty repository-relative globs");
309
+ }
310
+ }
311
+ else if (a === "--by") {
264
312
  by = argv[++i];
265
313
  if (!by)
266
314
  throw new Error(USAGE);
@@ -296,7 +344,7 @@ function parseArgs(argv) {
296
344
  if (!runId || !taskId) {
297
345
  throw new Error(USAGE);
298
346
  }
299
- return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling };
347
+ return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling, files };
300
348
  }
301
349
  function failedGateForNewestPark(events, taskId, lastHumanIndex) {
302
350
  for (let i = lastHumanIndex - 1; i >= 0; i -= 1) {
@@ -613,6 +613,46 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
613
613
  if (probeProgressTTY && probeOk + probeBad > 0) {
614
614
  process.stderr.write(`\r\x1b[2K probed ${probeOk + probeBad} channels · ${probeOk} ok${probeBad ? ` · ${probeBad} failed` : ""}\n`);
615
615
  }
616
+ const identityResolver = opts.resolveClaudeAliasIdentity
617
+ ?? (adapters.some((a) => a.id === claudeCode.id) ? resolveClaudeAliasIdentity : undefined);
618
+ const aliasDriftFindings = [];
619
+ // Single pre-write snapshot of the resolver's outcome per alias (including undefined/failure).
620
+ // Catalog rendering below reads only this map — never calls the resolver again — so a transient
621
+ // resolver that succeeds on a later call can never produce a result that stays display-only
622
+ // while doctor.json and discovery keep the earlier undefined/failed outcome.
623
+ const resolvedIdentitySnapshot = {};
624
+ if (identityResolver && health["claude-code"]?.installed) {
625
+ const configured = cfg.tiers["claude-code"]?.models ?? {};
626
+ for (const [alias, stampedIdentity] of Object.entries(CLAUDE_ALIAS_IDENTITY_STAMPS)) {
627
+ if (!(alias in configured))
628
+ continue;
629
+ let resolvedIdentity;
630
+ try {
631
+ resolvedIdentity = identityResolver(cwd, alias);
632
+ }
633
+ catch {
634
+ resolvedIdentitySnapshot[alias] = undefined;
635
+ continue; // advisory source failure is unknown, never a doctor failure
636
+ }
637
+ resolvedIdentitySnapshot[alias] = resolvedIdentity;
638
+ if (resolvedIdentity) {
639
+ if (!health["claude-code"].modelAuth) {
640
+ health["claude-code"].modelAuth = {};
641
+ }
642
+ const existing = health["claude-code"].modelAuth[alias] ?? {
643
+ authed: true,
644
+ probedAt: new Date().toISOString(),
645
+ };
646
+ health["claude-code"].modelAuth[alias] = {
647
+ ...existing,
648
+ identity: resolvedIdentity,
649
+ };
650
+ }
651
+ if (resolvedIdentity && resolvedIdentity !== stampedIdentity) {
652
+ aliasDriftFindings.push(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`);
653
+ }
654
+ }
655
+ }
616
656
  writeDoctor(cwd, health);
617
657
  const rows = adapters.map((a) => {
618
658
  const h = health[a.id];
@@ -752,37 +792,12 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
752
792
  // v1.65 T3: hardcoded-flag drift — advisory warn rows only. Runs AFTER writeDoctor so the verdicts
753
793
  // can never leak into doctor.json, and discoverChannels/routing never read them.
754
794
  lintRows.push(...flagDriftWarnings(adapters, health).map(attentionRow));
755
- // OBS-145: resolved-identity drift is the same class of display-only doctor warning. Stamps live
756
- // beside the alias-owning adapter; the comparison runs only for configured floating aliases and
757
- // never enters health/doctor.json, config, channel discovery, learned profiles, or route().
758
- const identityResolver = opts.resolveClaudeAliasIdentity
759
- ?? (adapters.includes(claudeCode) ? resolveClaudeAliasIdentity : undefined);
760
- if (identityResolver && health["claude-code"]?.installed) {
761
- const configured = cfg.tiers["claude-code"]?.models ?? {};
762
- for (const [alias, stampedIdentity] of Object.entries(CLAUDE_ALIAS_IDENTITY_STAMPS)) {
763
- if (!(alias in configured))
764
- continue;
765
- let resolvedIdentity;
766
- try {
767
- resolvedIdentity = identityResolver(cwd, alias);
768
- }
769
- catch {
770
- continue; // advisory source failure is unknown, never a doctor failure
771
- }
772
- if (resolvedIdentity && resolvedIdentity !== stampedIdentity) {
773
- lintRows.push(attentionRow(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`));
774
- }
775
- }
776
- }
795
+ // OBS-145: resolved-identity drift is advisory display warning.
796
+ lintRows.push(...aliasDriftFindings.map(attentionRow));
777
797
  const resolvedCatalogModel = (adapter, model) => {
778
- if (adapter !== "claude-code" || !identityResolver || !(model in CLAUDE_ALIAS_IDENTITY_STAMPS))
798
+ if (adapter !== "claude-code" || !(model in CLAUDE_ALIAS_IDENTITY_STAMPS))
779
799
  return undefined;
780
- try {
781
- return identityResolver(cwd, model);
782
- }
783
- catch {
784
- return undefined;
785
- }
800
+ return resolvedIdentitySnapshot[model];
786
801
  };
787
802
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, tickmarkr NEVER applies it.
788
803
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,
@@ -5,13 +5,13 @@ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthE
5
5
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
6
  import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
7
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
8
- import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, overlayBytesLoadError, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, unifiedYamlDiff, } from "../../config/config.js";
9
- import { projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
8
+ import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, loadConfigWithMode, overlayBytesLoadError, readOverlayFile, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
9
+ import { exclusionReason, projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { doctor } from "./doctor.js";
12
12
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
13
13
  import { route } from "../../route/router.js";
14
- import { disallowedBy } from "../../route/preference.js";
14
+ import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, entryMatchesChannel, exclusionCollector } from "../../route/preference.js";
15
15
  import { resolveRunMode } from "../../run/daemon.js";
16
16
  import { loadRoutingProfile } from "../../run/journal.js";
17
17
  const initialFetch = globalThis.fetch;
@@ -202,10 +202,17 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
202
202
  const previewChannels = discoverChannels({ ...cfg, routing: routingScopeBlind }, adapters, health);
203
203
  const universe = adapters
204
204
  .filter((adapter) => health[adapter.id]?.installed)
205
- .map((adapter) => ({
206
- adapter: adapter.id,
207
- models: [...new Set(previewChannels.filter((c) => c.adapter === adapter.id).map((c) => c.model))],
208
- }));
205
+ .map((adapter) => {
206
+ const served = previewChannels.filter((c) => c.adapter === adapter.id);
207
+ // LEG2-T3 round 2 finding 3: the recorded identity per channel, so the editable membership
208
+ // matches allow/deny entries with the same authority the collector and the browser use
209
+ const identities = Object.fromEntries(served.flatMap((c) => (c.identity ? [[c.model, c.identity]] : [])));
210
+ return {
211
+ adapter: adapter.id,
212
+ models: [...new Set(served.map((c) => c.model))],
213
+ ...(Object.keys(identities).length ? { identities } : {}),
214
+ };
215
+ });
209
216
  const initial = fleetEditableFromConfig(cfg, universe);
210
217
  const editable = structuredClone(initial);
211
218
  // OBS-508: the same catalog evidence doctor's drift overlay prints now rides each unclassified
@@ -250,9 +257,14 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
250
257
  || a.model.localeCompare(b.model));
251
258
  // OBS-508 follow-through: the browser renders the metadata the assembler always had — catalog
252
259
  // ctx/price evidence and doctor's model-probe wall clock — as columns instead of dropping them.
260
+ // OBS-972/FL-1: the same alias→identity doctor recorded (modelAuth.identity, falling back to
261
+ // the coarser per-adapter modelIdentities map registry.ts/preference.ts already read for
262
+ // routing exclusion) — a floating alias's resolved concrete model id.
263
+ const resolvedModelIdentity = (adapter, model) => health[adapter]?.modelAuth?.[model]?.identity ?? health[adapter]?.modelIdentities?.[model];
253
264
  const rowEvidence = (adapter, model, evidence) => {
254
265
  const probed = health[adapter]?.modelAuth?.[model];
255
266
  const contextWindow = evidence?.contextWindow ?? declaredModelWindow(cfg, adapter, model);
267
+ const identity = resolvedModelIdentity(adapter, model);
256
268
  const out = {
257
269
  ...(contextWindow !== undefined ? { contextWindow } : {}),
258
270
  ...(evidence?.outputWindow !== undefined ? { outputWindow: evidence.outputWindow } : {}),
@@ -262,6 +274,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
262
274
  // OBS-519: doctor already recorded the failed verdict — a rate-limited/unauthed model must
263
275
  // not render identically to a healthy row on the surface that edits its fleet membership.
264
276
  ...(probed?.authed === false ? { unauthed: probed.reason ?? "probe failed" } : {}),
277
+ // the browser decides deny coverage itself, over the STAGED policy (LEG2-T3 finding 1)
278
+ ...(identity !== undefined ? { identity } : {}),
265
279
  };
266
280
  return Object.keys(out).length > 0 ? out : undefined;
267
281
  };
@@ -306,24 +320,42 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
306
320
  ]));
307
321
  const channels = discoverChannels(cfg, adapters, health);
308
322
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
323
+ const stagedEditable = (map, deny) => ({
324
+ ...structuredClone(initial),
325
+ denyAdapters: deny.adapters,
326
+ denyModels: deny.models,
327
+ denyWorkersAdapters: deny.workersAdapters,
328
+ denyWorkersModels: deny.workersModels,
329
+ map,
330
+ });
331
+ const candidateMemo = new Map();
309
332
  const previewCfg = (mode, map, deny) => {
310
- // mirror the writer: only the adapters/models scopes are fleet-editable; workers deny survives
311
- const nextDeny = { ...cfg.routing.deny };
312
- if (deny.adapters.length)
313
- nextDeny.adapters = deny.adapters;
314
- else
315
- delete nextDeny.adapters;
316
- if (deny.models.length)
317
- nextDeny.models = deny.models;
318
- else
319
- delete nextDeny.models;
320
- const routing = {
321
- ...routingScopeBlind,
322
- map,
323
- floors: modeCfgs[mode].cfg.routing.floors,
324
- ...(Object.keys(nextDeny).length ? { deny: nextDeny } : {}),
325
- };
326
- return { ...cfg, routing };
333
+ const key = JSON.stringify([mode, map, deny]);
334
+ const hit = candidateMemo.get(key);
335
+ if (hit)
336
+ return hit;
337
+ let preview;
338
+ try {
339
+ const bytes = renderFleetOverlayWrite(currentRepoOverlayText(cwd), {
340
+ initial,
341
+ edited: stagedEditable(map, deny),
342
+ universe,
343
+ ...(mode !== rm.mode.mode ? { mode } : {}),
344
+ });
345
+ preview = { ok: true, cfg: loadConfigWithMode(cwd, { globalDir, repoOverlayText: bytes }).cfg };
346
+ }
347
+ catch (error) {
348
+ preview = { ok: false, error: error.message.replace(/\s+/g, " ").trim() };
349
+ }
350
+ if (candidateMemo.size > 32)
351
+ candidateMemo.clear();
352
+ candidateMemo.set(key, preview);
353
+ return preview;
354
+ };
355
+ const previewUnavailable = (error) => `preview unavailable (${error}) — the staged overlay does not load, so w would be refused`;
356
+ const stagedRouting = (deny) => {
357
+ const preview = previewCfg(rm.mode.mode, editable.map, deny);
358
+ return preview.ok ? { ok: true, routing: preview.cfg.routing } : preview;
327
359
  };
328
360
  // route() never deny-filters its pool — that contract lives in discoverChannels — so every
329
361
  // preview call rebuilds the pool the staged deny would discover
@@ -333,7 +365,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
333
365
  let subs = 0;
334
366
  let apiN = 0;
335
367
  let apiUsd = 0;
336
- const cfgPreview = previewCfg(mode, map, deny);
368
+ const preview = previewCfg(mode, map, deny);
369
+ if (!preview.ok)
370
+ return ` mix: ${previewUnavailable(preview.error)}`;
371
+ const cfgPreview = preview.cfg;
337
372
  const pool = previewPool(cfgPreview);
338
373
  for (const shape of SHAPES) {
339
374
  try {
@@ -393,7 +428,15 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
393
428
  : { declaredAt: "routing.mode" };
394
429
  };
395
430
  const projectedShapeRows = (mode, map, deny) => {
396
- const cfgPreview = previewCfg(mode, map, deny);
431
+ const preview = previewCfg(mode, map, deny);
432
+ if (!preview.ok) {
433
+ return projectFleetWhy(SHAPES.map((shape) => ({
434
+ id: shape,
435
+ effective: previewUnavailable(preview.error),
436
+ ...whyDeclaration(shape, mode, map, false),
437
+ })), { repoRoot: cwd, globalDir });
438
+ }
439
+ const cfgPreview = preview.cfg;
397
440
  const pool = previewPool(cfgPreview);
398
441
  const values = SHAPES.map((shape) => {
399
442
  try {
@@ -433,13 +476,13 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
433
476
  const unauthedClis = adapters
434
477
  .filter((adapter) => health[adapter.id]?.installed && health[adapter.id]?.authed === false)
435
478
  .map((adapter) => adapter.id);
436
- const unauthedModelCount = modelAuthExclusions(cfg, adapters, health).length;
437
- const excludedNoteFor = (deny) => {
479
+ const unauthedModels = modelAuthExclusions(cfg, adapters, health);
480
+ const unauthedModelCount = unauthedModels.length;
481
+ const excludedNoteFor = (cfgPreview) => {
438
482
  let stagedOut = 0;
439
483
  let denied = 0;
440
- const stagedRouting = previewCfg(rm.mode.mode, {}, deny).routing;
441
484
  for (const channel of previewChannels) {
442
- if (disallowedBy(channel, stagedRouting) === null)
485
+ if (disallowedBy(channel, cfgPreview.routing) === null)
443
486
  continue;
444
487
  if (disallowedBy(channel, cfg.routing) === null)
445
488
  stagedOut += 1;
@@ -459,14 +502,122 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
459
502
  parts.push(`${unclassifiedRows.length} unclassified (never routed)`);
460
503
  return parts.length ? `not offered: ${parts.join(" · ")} — the models view explains each row` : undefined;
461
504
  };
505
+ // OBS-994/FL-1: the nine mechanisms outside the deny scopes that can keep a channel off a
506
+ // shape's candidate list each get a reason or an explicit "not manageable here" caption — a
507
+ // silent omission is indistinguishable from a bug (OBS-530's lesson, extended past deny/allow).
508
+ // allow and the unauthed probe already carry a reason through excludedNoteFor above; the rest
509
+ // are shape-scoped (a pin/pool/floor/prefer excludes only for THIS shape) and belong here.
510
+ const shapeExclusionCaptions = (shape, map, cfgPreview) => {
511
+ const pool = previewPool(cfgPreview);
512
+ const entry = map[shape];
513
+ const captions = [];
514
+ // 2. pin or pool — a closed candidate set declared for this shape; a channel outside it is
515
+ // not manageable from the models view, it is a Shapes-view declaration.
516
+ if (entry?.pin) {
517
+ captions.push(`pin: routing.map.${shape}.pin fixes this shape to ${entry.pin.via}:${entry.pin.model} — not manageable here, edit it in Shapes`);
518
+ }
519
+ else if (entry?.pool) {
520
+ const declared = new Set(entry.pool.channels);
521
+ const outside = pool.filter((c) => !declared.has(`${c.adapter}:${c.model}`));
522
+ if (outside.length) {
523
+ captions.push(`pool: ${outside.length} channel(s) outside routing.map.${shape}.pool's declared set — not manageable here, edit it in Shapes`);
524
+ }
525
+ }
526
+ // 3. floor — channels below this shape's minimum tier.
527
+ const floor = cfgPreview.routing.floors[shape];
528
+ if (floor) {
529
+ const belowFloor = pool.filter((c) => TIER_RANK[c.tier] < TIER_RANK[floor]);
530
+ if (belowFloor.length) {
531
+ captions.push(`floor: ${belowFloor.length} channel(s) below routing.floors.${shape} (${floor}) — not manageable here, edit the floor in Shapes`);
532
+ }
533
+ }
534
+ // 5. tombstones — the repo overlay's OWN null over this shape's map entry, masking a lower
535
+ // (global/default) layer's declaration rather than expressing a value of its own.
536
+ const repoRaw = readOverlayFile(repoOverlayPath(cwd));
537
+ const rawEntry = repoRaw.routing?.map?.[shape];
538
+ if (rawEntry === null) {
539
+ captions.push(`tombstone: routing.map.${shape}: null masks a lower layer's declaration — not manageable here, edit the repo overlay directly`);
540
+ }
541
+ else if (rawEntry && "pool" in rawEntry && rawEntry.pool === null) {
542
+ captions.push(`tombstone: routing.map.${shape}.pool: null masks a lower layer's pool — not manageable here, edit the repo overlay directly`);
543
+ }
544
+ else if (rawEntry && "pin" in rawEntry && rawEntry.pin === null) {
545
+ captions.push(`tombstone: routing.map.${shape}.pin: null masks a lower layer's pin — not manageable here, edit the repo overlay directly`);
546
+ }
547
+ // 6. explore/learned knobs — global routing settings, never a per-channel fleet toggle.
548
+ if (cfgPreview.routing.explore?.mode === "off") {
549
+ captions.push("explore: routing.explore.mode is off — a global knob, not manageable here");
550
+ }
551
+ if (cfgPreview.routing.learned === "off") {
552
+ captions.push("learned: routing.learned is off — a global knob, not manageable here");
553
+ }
554
+ // 7. task hint — a shape's prefer bias is authored in Shapes/spec, not per-channel in fleet.
555
+ if (entry?.prefer?.length) {
556
+ captions.push(`task hint: routing.map.${shape}.prefer ranks ${entry.prefer.join(", ")} — not manageable here, edit it in Shapes`);
557
+ }
558
+ // 8. deny∩prefer collision — a standing lint on this shape's declaration, never a fleet toggle.
559
+ for (const collision of denyPreferCollisions(cfgPreview, [shape])) {
560
+ captions.push(denyPreferCollisionLine(collision));
561
+ }
562
+ // 9. first-match provenance — the per-channel ledger below lists EVERY collector scope.
563
+ return captions;
564
+ };
565
+ // LEG2-T3 finding 1: each channel a mechanism touches on this shape gets its own ledger row
566
+ // naming every reason — collector scopes with their config paths (allow, flat and workers deny,
567
+ // all of them, never a first match), the unauthed probe, pin/pool/floor, the task hint and a
568
+ // deny∩prefer collision it is the target of — or that leaves the shape with no candidate at all.
569
+ const channelLedger = (shape, map, cfgPreview, offered) => {
570
+ const entry = map[shape];
571
+ const floor = cfgPreview.routing.floors[shape];
572
+ const collisions = denyPreferCollisions(cfgPreview, [shape]);
573
+ const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
574
+ // discovery already dropped a channel whose model probe failed — it still gets its own row
575
+ const unauthedRows = [
576
+ ...unauthedClis.map((adapter) => `${adapter} — CLI unauthed — re-probe with tickmarkr doctor`),
577
+ ...unauthedModels.filter(({ key }) => !discovered.has(key))
578
+ .map(({ key, reason }) => `${key.replace(":", "/")} — unauthed (${reason}) — re-probe with tickmarkr doctor`),
579
+ ];
580
+ return [...unauthedRows, ...previewChannels.flatMap((c) => {
581
+ const key = `${c.adapter}:${c.model}`;
582
+ const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
583
+ if (health[c.adapter]?.authed === false)
584
+ reasons.push(`${c.adapter} CLI unauthed — re-probe with tickmarkr doctor`);
585
+ else if (health[c.adapter]?.modelAuth?.[c.model]?.authed === false)
586
+ reasons.push("unauthed — re-probe with tickmarkr doctor");
587
+ if (entry?.pin && key !== `${entry.pin.via}:${entry.pin.model}`) {
588
+ reasons.push(`not routing.map.${shape}.pin (${entry.pin.via}:${entry.pin.model}) — not manageable here`);
589
+ }
590
+ if (entry?.pool && !entry.pool.channels.includes(key)) {
591
+ reasons.push(`outside routing.map.${shape}.pool — not manageable here`);
592
+ }
593
+ if (floor && TIER_RANK[c.tier] < TIER_RANK[floor]) {
594
+ reasons.push(`below routing.floors.${shape} (${floor}) — not manageable here`);
595
+ }
596
+ if (entry?.prefer?.some((p) => entryMatchesChannel(p, c))) {
597
+ reasons.push(`task hint: routing.map.${shape}.prefer names it — not manageable here`);
598
+ }
599
+ for (const collision of collisions) {
600
+ const named = collision.detail.split(" > ").some((p) => entryMatchesChannel(p, c));
601
+ if (named || offered.size === 0)
602
+ reasons.push(denyPreferCollisionLine(collision));
603
+ }
604
+ // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
605
+ return reasons.length ? [`${c.adapter}/${c.model} — ${reasons.join("; ")}`] : [];
606
+ })];
607
+ };
462
608
  const candidatesForShape = (shape, mode, map, deny) => {
463
- const cfgPreview = previewCfg(mode, map, deny);
609
+ const preview = previewCfg(mode, map, deny);
610
+ if (!preview.ok)
611
+ return { rows: [], excludedNote: previewUnavailable(preview.error) };
612
+ const cfgPreview = preview.cfg;
464
613
  const rows = shapeCandidates(previewTask(shape), cfgPreview, previewPool(cfgPreview), profile).map((candidate) => ({
465
614
  id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
466
615
  label: candidateRow(candidate, cfg.pricing),
467
616
  pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
468
617
  }));
469
- return { rows, excludedNote: excludedNoteFor(deny) };
618
+ const offered = new Set(rows.map((row) => row.id));
619
+ const notes = [excludedNoteFor(cfgPreview), ...shapeExclusionCaptions(shape, map, cfgPreview)].filter((note) => note !== undefined);
620
+ return { rows, excludedNote: notes.length ? notes.join("\n") : undefined, ledger: channelLedger(shape, map, cfgPreview, offered) };
470
621
  };
471
622
  const preferUniverse = [
472
623
  ...new Set(channels.flatMap((channel) => [
@@ -493,6 +644,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
493
644
  const staged = structuredClone(initial);
494
645
  staged.denyAdapters = state.denyAdapters;
495
646
  staged.denyModels = state.denyModels;
647
+ // OBS-994/FL-1: the workers-only deny scope rides the same review/write funnel as the flat one.
648
+ staged.denyWorkersAdapters = state.denyWorkersAdapters;
649
+ staged.denyWorkersModels = state.denyWorkersModels;
496
650
  staged.map = state.map;
497
651
  const today = new Date().toISOString().slice(0, 10);
498
652
  for (const classification of state.classifications) {
@@ -545,6 +699,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
545
699
  health,
546
700
  initialDenyAdapters: editable.denyAdapters,
547
701
  initialDenyModels: editable.denyModels,
702
+ initialDenyWorkersAdapters: editable.denyWorkersAdapters,
703
+ initialDenyWorkersModels: editable.denyWorkersModels,
548
704
  modelGroups,
549
705
  initialMode: rm.mode.mode,
550
706
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
@@ -560,6 +716,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
560
716
  steeringOptionsFor,
561
717
  reviewOverlay,
562
718
  reloadGuard,
719
+ stagedRouting,
563
720
  entry: opts.entry,
564
721
  initialJudge,
565
722
  judgeSeats: seats,
@@ -607,6 +764,11 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
607
764
  return {
608
765
  props,
609
766
  commit,
610
- renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, { adapters: editable.denyAdapters, models: editable.denyModels })),
767
+ renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, {
768
+ adapters: editable.denyAdapters,
769
+ models: editable.denyModels,
770
+ workersAdapters: editable.denyWorkersAdapters ?? [],
771
+ workersModels: editable.denyWorkersModels ?? [],
772
+ })),
611
773
  };
612
774
  }