tickmarkr 2.5.3 → 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 (71) 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.d.ts +4 -0
  6. package/dist/cli/commands/doctor.js +44 -29
  7. package/dist/cli/commands/fleet.js +195 -33
  8. package/dist/cli/commands/init.js +196 -6
  9. package/dist/cli/commands/plan.js +144 -3
  10. package/dist/cli/commands/resume.js +6 -2
  11. package/dist/cli/commands/run.js +21 -2
  12. package/dist/cli/commands/verify.js +1 -1
  13. package/dist/cli/help.d.ts +2 -0
  14. package/dist/cli/help.js +3 -1
  15. package/dist/compile/collateral.d.ts +2 -0
  16. package/dist/compile/collateral.js +50 -9
  17. package/dist/compile/ownership.d.ts +7 -0
  18. package/dist/compile/ownership.js +59 -22
  19. package/dist/config/config.d.ts +23 -8
  20. package/dist/config/config.js +42 -28
  21. package/dist/config/fleet-overlay.d.ts +3 -9
  22. package/dist/config/fleet-overlay.js +68 -11
  23. package/dist/config/fleet-why.d.ts +7 -0
  24. package/dist/config/fleet-why.js +5 -0
  25. package/dist/drivers/herdr.d.ts +5 -0
  26. package/dist/drivers/herdr.js +14 -3
  27. package/dist/drivers/index.d.ts +15 -1
  28. package/dist/drivers/index.js +38 -10
  29. package/dist/drivers/orca.d.ts +99 -10
  30. package/dist/drivers/orca.js +586 -97
  31. package/dist/drivers/types.d.ts +1 -0
  32. package/dist/gates/baseline.d.ts +3 -0
  33. package/dist/gates/baseline.js +2 -1
  34. package/dist/gates/cache.d.ts +100 -0
  35. package/dist/gates/cache.js +389 -0
  36. package/dist/gates/review.d.ts +35 -2
  37. package/dist/gates/review.js +64 -16
  38. package/dist/gates/run-gates.d.ts +5 -0
  39. package/dist/gates/run-gates.js +134 -18
  40. package/dist/gates/test-manifest.d.ts +99 -0
  41. package/dist/gates/test-manifest.js +389 -0
  42. package/dist/gates/test-reporter.d.ts +4 -0
  43. package/dist/gates/test-reporter.js +49 -0
  44. package/dist/route/preference.d.ts +22 -1
  45. package/dist/route/preference.js +123 -25
  46. package/dist/route/router.d.ts +13 -0
  47. package/dist/route/router.js +95 -17
  48. package/dist/run/daemon.d.ts +13 -0
  49. package/dist/run/daemon.js +398 -96
  50. package/dist/run/git.d.ts +35 -1
  51. package/dist/run/git.js +111 -10
  52. package/dist/run/journal.d.ts +14 -2
  53. package/dist/run/journal.js +78 -10
  54. package/dist/run/lease.d.ts +14 -0
  55. package/dist/run/lease.js +87 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/tui/cockpit/board.d.ts +96 -0
  61. package/dist/tui/cockpit/board.js +346 -0
  62. package/dist/tui/cockpit/decision-actions.js +2 -0
  63. package/dist/tui/cockpit/layout.d.ts +5 -1
  64. package/dist/tui/cockpit/layout.js +8 -3
  65. package/dist/tui/cockpit/live-runtime.js +83 -31
  66. package/dist/tui/cockpit/run-view.d.ts +7 -5
  67. package/dist/tui/cockpit/run-view.js +12 -11
  68. package/dist/tui/ink/fleet-app.d.ts +41 -27
  69. package/dist/tui/ink/fleet-app.js +204 -31
  70. package/package.json +1 -1
  71. package/skills/tickmarkr-overseer/SKILL.md +180 -99
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { extname, join, relative } from "node:path";
2
+ import { extname, join, posix, relative } from "node:path";
3
3
  import { filesGlob } from "../graph/files-glob.js";
4
4
  import { criticalPathHits, DEFAULT_CONFIG, DEFAULT_REVIEW_CRITICAL_PATHS, effectiveReviewPolicy, loadConfig, } from "../config/config.js";
5
5
  import { renderAcceptanceItem } from "../graph/schema.js";
@@ -156,20 +156,61 @@ export function classifyScopeOffenders(taskId, hard, predicted) {
156
156
  const missed = hard.filter((f) => !named.has(f));
157
157
  return { authoring: hard.length > 0 && missed.length === 0, predicted: hit, missed, repair: filesRepair(taskId, hit) };
158
158
  }
159
+ const moduleKey = (path) => path.replace(/\\/g, "/").replace(/\.(?:[cm]?[jt]sx?)$/, "");
160
+ function directImportSpecifiers(text) {
161
+ // Comments cannot create an edge. Keep strings intact because they are the import target.
162
+ const source = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
163
+ const specifiers = new Set();
164
+ for (const match of source.matchAll(/\bimport\s+(?:type\s+)?(?:[\w$*{},\s]+?\s+from\s+)?["']([^"']+)["']/g)) {
165
+ specifiers.add(match[1]);
166
+ }
167
+ for (const match of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) {
168
+ specifiers.add(match[1]);
169
+ }
170
+ return [...specifiers];
171
+ }
172
+ function directlyImports(testPath, testText, sourcePath) {
173
+ const target = moduleKey(sourcePath);
174
+ const testDir = posix.dirname(testPath.replace(/\\/g, "/"));
175
+ return directImportSpecifiers(testText).some((specifier) => {
176
+ const imported = specifier.startsWith(".")
177
+ ? posix.normalize(posix.join(testDir, specifier))
178
+ : specifier.startsWith("src/") ? specifier : "";
179
+ return imported !== "" && moduleKey(imported) === target;
180
+ });
181
+ }
159
182
  /**
160
183
  * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
161
184
  * Each line names the task id and at least one missing collateral test path.
185
+ * OBS-971: uncapped — every predicted path is listed, with direct importers of an owned source
186
+ * sorted first, followed by the total count. The runtime map (collateralHits) remains unchanged.
162
187
  */
163
188
  export function collateralLints(tasks, repoRoot) {
164
189
  const lines = [];
165
- for (const [id, hits] of collateralHits(tasks, repoRoot)) {
166
- const listed = hits.slice(0, MAX_HITS_PER_TASK).join(", ");
167
- // OBS-547: the cap hides names, never predictions. Say so, and say where the hidden ones surface —
168
- // a count with no route is exactly what left one run's victim unreadable.
169
- const tail = hits.length > MAX_HITS_PER_TASK
170
- ? ` (${hits.length} total; ${MAX_HITS_PER_TASK} shown, ${hits.length - MAX_HITS_PER_TASK} capped out of view`
171
- + ` but RETAINED for the scope gate — a matching scope red prints the hidden path with its files[] repair)`
172
- : "";
190
+ const hitsMap = collateralHits(tasks, repoRoot);
191
+ const read = makeReader(repoRoot);
192
+ const tasksById = new Map(tasks.map((t) => [t.id, t]));
193
+ for (const [id, hits] of hitsMap) {
194
+ const t = tasksById.get(id);
195
+ const files = (t?.files ?? []).map((f) => f.replace(/^\.\//, ""));
196
+ const ownedSources = files.filter(isSrcPath);
197
+ const direct = [];
198
+ const indirect = [];
199
+ for (const h of hits) {
200
+ const text = read(h) ?? "";
201
+ const isDirect = ownedSources.some((src) => directlyImports(h, text, src));
202
+ if (isDirect) {
203
+ direct.push(h);
204
+ }
205
+ else {
206
+ indirect.push(h);
207
+ }
208
+ }
209
+ direct.sort((a, b) => a.localeCompare(b));
210
+ indirect.sort((a, b) => a.localeCompare(b));
211
+ const ordered = [...direct, ...indirect];
212
+ const listed = ordered.join(", ");
213
+ const tail = hits.length > MAX_HITS_PER_TASK ? ` (${hits.length} total)` : "";
173
214
  lines.push(`${id}: likely collateral tests not in files[]: ${listed}${tail}`);
174
215
  }
175
216
  return lines;
@@ -31,6 +31,13 @@ export type OwnershipFinding = {
31
31
  ownerTaskId: string;
32
32
  path: string;
33
33
  detail: string;
34
+ } | {
35
+ code: "unowned-source-of-owned-test";
36
+ taskId: string;
37
+ test: string;
38
+ source: string;
39
+ corroboration?: OwnershipCorroboration;
40
+ detail: string;
34
41
  };
35
42
  export declare const SHAPE_ORACLES: readonly ["tests/run/narration.test.ts", "tests/cli/brand-surfaces.test.ts", "tests/run/notify-identity.test.ts", "tests/run/outcome-projections.test.ts", "tests/cockpit/setup.test.ts"];
36
43
  export declare const SHAPE_ORACLE_SOURCES: readonly ["src/run/daemon.ts", "src/cli/commands/run.ts"];
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { basename, extname, join, posix } from "node:path";
3
3
  import { filesGlob } from "../graph/files-glob.js";
4
4
  import { collateralHits } from "./collateral.js";
@@ -17,21 +17,24 @@ export const SHAPE_ORACLE_MAP = {
17
17
  "src/run/daemon.ts": SHAPE_ORACLES,
18
18
  "src/cli/commands/run.ts": SHAPE_ORACLES,
19
19
  };
20
- // Anchored-glob only: a files[] entry touches a mapped source when it names the source (or its
21
- // extensionless stem) exactly, or when its glob's literal head — everything before the first
22
- // wildcard — is the stem plus a literal dot, i.e. the wildcard only ever spans the extension
23
- // ("src/run/daemon.*"). A broad multi-file glob like "src/**" that merely happens to cover the
24
- // source is an unrelated task casting a wide net, not one touching daemon narration — that
25
- // distinction is what broke every fixture using tests/fixtures/sample.prd.md's files: src/** task.
26
- function touchesSource(files, source) {
20
+ // A files[] entry touches a mapped shape-oracle source when it names it literally or by stem, or when
21
+ // the entry's glob — anchored or broad — matches the source AND the source exists in the repository
22
+ // being compiled: "src/run/**" owning this repository's daemon owns the five oracles, while the
23
+ // shipped sample PRD's "src/**" task in a repository holding no mapped source compiles clean.
24
+ // Anchored globs like "src/run/daemon.*" target the source specifically and touch it regardless.
25
+ function touchesSource(files, source, repoRoot) {
27
26
  const stem = source.replace(/\.(?:[cm]?[jt]sx?)$/, "");
28
27
  return files.some((entry) => {
29
28
  if (entry === source || entry === stem)
30
29
  return true;
31
30
  const special = entry.search(/[*?{[]/);
32
- // Leg-2 v2.5.3: the anchored head is necessary, not sufficient — the pattern must also MATCH the
33
- // source under the shared matcher, or "src/run/daemon.{js,jsx}" (never daemon.ts) would count as a touch.
34
- return special !== -1 && entry.slice(0, special) === `${stem}.` && filesGlob([entry])(source);
31
+ if (special === -1)
32
+ return false;
33
+ if (!filesGlob([entry])(source))
34
+ return false;
35
+ if (entry.slice(0, special) === `${stem}.`)
36
+ return true;
37
+ return repoRoot !== undefined && existsSync(join(repoRoot, source));
35
38
  });
36
39
  }
37
40
  const normalize = (path) => path.replace(/^\.\//, "").split("\\").join("/");
@@ -133,20 +136,24 @@ function mentionsCommandEntry(text) {
133
136
  return /(?:^|\/)src\/cli\/index\.(?:ts|js)\b/.test(text)
134
137
  || /["'`]src["'`]\s*,\s*["'`]cli["'`]\s*,\s*["'`]index\.(?:ts|js)["'`]/.test(text);
135
138
  }
136
- function corroboration(test, matches) {
137
- // A .test.ts-shaped collateral fixture is not by itself a dedicated test. Requiring a runner leaf
138
- // keeps import-only scan fixtures advisory while every executable subject in the measured union stays.
139
+ function corroborateSource(test, sourcePath) {
139
140
  const executable = test.text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
140
141
  if (!/\b(?:test|it)(?:\.(?:concurrent|each|fails|only|skip|todo))*\s*\(/.test(executable))
141
142
  return undefined;
142
- for (const match of matches) {
143
- if (directlyImports(test, match.source))
144
- return { kind: "direct-import", source: match.source };
145
- }
143
+ if (directlyImports(test, sourcePath))
144
+ return { kind: "direct-import", source: sourcePath };
146
145
  if (invokesChildProcessSpawn(test.text) && mentionsCommandEntry(test.text)) {
147
- const command = matches.find(({ source }) => /^src\/cli\/commands\/[^/]+\.(?:[cm]?[jt]sx?)$/.test(source));
148
- if (command)
149
- return { kind: "command-entry-spawn", source: command.source, entry: "src/cli/index.ts" };
146
+ if (/^src\/cli\/commands\/[^/]+\.(?:[cm]?[jt]sx?)$/.test(sourcePath)) {
147
+ return { kind: "command-entry-spawn", source: sourcePath, entry: "src/cli/index.ts" };
148
+ }
149
+ }
150
+ return undefined;
151
+ }
152
+ function corroboration(test, matches) {
153
+ for (const match of matches) {
154
+ const evidence = corroborateSource(test, match.source);
155
+ if (evidence)
156
+ return evidence;
150
157
  }
151
158
  return undefined;
152
159
  }
@@ -239,7 +246,7 @@ export function ownershipFindings(tasks, repoRoot) {
239
246
  }
240
247
  for (const entry of indexed) {
241
248
  for (const [source, oracles] of Object.entries(SHAPE_ORACLE_MAP)) {
242
- if (!touchesSource(entry.files, source))
249
+ if (!touchesSource(entry.files, source, repoRoot))
243
250
  continue;
244
251
  for (const oracle of oracles) {
245
252
  if (!entry.owns(oracle)) {
@@ -254,6 +261,35 @@ export function ownershipFindings(tasks, repoRoot) {
254
261
  }
255
262
  }
256
263
  }
264
+ for (const test of sources) {
265
+ const testOwners = owners(test.path);
266
+ if (testOwners.length === 0)
267
+ continue;
268
+ const testStem = basename(test.path).replace(/\.test\.ts$/, "");
269
+ for (const sourcePath of allSources) {
270
+ if (owners(sourcePath).length > 0)
271
+ continue;
272
+ const sourceStem = basename(sourcePath, extname(sourcePath));
273
+ if (testStem !== sourceStem && !testStem.startsWith(`${sourceStem}-`))
274
+ continue;
275
+ const evidence = corroborateSource(test, sourcePath);
276
+ if (!evidence)
277
+ continue;
278
+ for (const owner of testOwners) {
279
+ findings.push({
280
+ code: "unowned-source-of-owned-test",
281
+ taskId: owner.task.id,
282
+ test: test.path,
283
+ source: sourcePath,
284
+ corroboration: evidence,
285
+ detail: `${test.path} owned by ${owner.task.id} is a dedicated test of ${sourcePath} but no task owns ${sourcePath}`
286
+ + (evidence.kind === "direct-import" ? `; it imports ${evidence.source} directly`
287
+ : evidence.kind === "command-entry-spawn"
288
+ ? `; it spawns ${evidence.entry} to exercise ${evidence.source}` : ""),
289
+ });
290
+ }
291
+ }
292
+ }
257
293
  for (const source of sources) {
258
294
  for (const owner of owners(source.path)) {
259
295
  for (const path of repositoryPaths(source.text)) {
@@ -290,6 +326,7 @@ export function ownershipFindings(tasks, repoRoot) {
290
326
  case "unowned-shape-oracle": return f.oracle;
291
327
  case "test-path-outside-allowlist": return f.test;
292
328
  case "unordered-context-write": return f.path;
329
+ case "unowned-source-of-owned-test": return f.test;
293
330
  }
294
331
  };
295
332
  return findings.sort((a, b) => {
@@ -175,6 +175,10 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
175
175
  on: "on";
176
176
  off: "off";
177
177
  }>;
178
+ escalateTier: z.ZodDefault<z.ZodEnum<{
179
+ on: "on";
180
+ off: "off";
181
+ }>>;
178
182
  learnedTuning: z.ZodOptional<z.ZodObject<{
179
183
  halfLifeRuns: z.ZodOptional<z.ZodNumber>;
180
184
  availWeight: z.ZodOptional<z.ZodNumber>;
@@ -275,6 +279,11 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
275
279
  "judge-only": "judge-only";
276
280
  }>>;
277
281
  criticalPaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
282
+ floor: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<"worker">, z.ZodEnum<{
283
+ cheap: "cheap";
284
+ mid: "mid";
285
+ frontier: "frontier";
286
+ }>]>>;
278
287
  }, z.core.$strip>;
279
288
  consult: z.ZodObject<{
280
289
  adapter: z.ZodString;
@@ -355,24 +364,30 @@ export type FleetTierAssignment = {
355
364
  export type FleetEditable = {
356
365
  denyAdapters: string[];
357
366
  denyModels: string[];
367
+ denyWorkersAdapters?: string[];
368
+ denyWorkersModels?: string[];
358
369
  tiers: Record<string, Record<string, FleetTierAssignment | null>>;
359
370
  map: Record<string, MapEntry>;
360
371
  floors: Record<string, Tier>;
361
372
  };
362
373
  export declare function repoOverlayPath(repoRoot: string): string;
363
374
  export declare function readOverlayFile(path: string): Record<string, unknown>;
375
+ /** A discovered fleet universe row: the served models of one adapter and, per model, the resolved
376
+ * identity doctor recorded for a floating alias (LEG2-T3 round 2 finding 3). */
377
+ export type FleetUniverseRow = {
378
+ adapter: string;
379
+ models: string[];
380
+ identities?: Record<string, string>;
381
+ };
382
+ /** Does a deny/allow entry name this universe channel? Grammar A1 (adapter id, bare model id,
383
+ * adapter:model) plus the recorded identity (bare or adapter:identity) — the collector's authority. */
384
+ export declare function universeEntryMatches(row: FleetUniverseRow, model: string, entry: string): boolean;
364
385
  /** Grammar A1 membership test: does any discovered universe row cover this deny/allow entry
365
386
  * (adapter id, bare model id, or adapter:model)? Uncovered entries name channels the probe
366
387
  * did not serve this session (failed auth, rate limit, retired sku) — they are NOT editable
367
388
  * membership state and must survive a membership write verbatim, never be re-derived away. */
368
- export declare function universeCovers(universe: {
369
- adapter: string;
370
- models: string[];
371
- }[], entry: string): boolean;
372
- export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, universe?: {
373
- adapter: string;
374
- models: string[];
375
- }[]): FleetEditable;
389
+ export declare function universeCovers(universe: FleetUniverseRow[], entry: string): boolean;
390
+ export declare function fleetEditableFromConfig(cfg: TickmarkrConfig, universe?: FleetUniverseRow[]): FleetEditable;
376
391
  /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
377
392
  export declare function fleetKeyLayer(repoRoot: string, dotted: string, opts?: {
378
393
  globalDir?: string;
@@ -302,6 +302,8 @@ export const TickmarkrConfigSchema = z.object({
302
302
  }),
303
303
  floors: z.record(z.string(), TierEnum),
304
304
  learned: z.enum(["on", "off"]), // v1.6 ROUTE-09 kill switch; a typo (offf) fails loud via safeParse
305
+ // OBS-986 (ES-1): climb one tier on request when untried channels exist in higher tiers.
306
+ escalateTier: z.enum(["on", "off"]).default("on"),
305
307
  // v1.9 ROUTE-15 — optional overrides for profile.ts HALF_LIFE_RUNS/AVAIL_WEIGHT; absent ⇒ byte-identical defaults.
306
308
  // SIBLING of learned (not nested): routing.learned is the on/off enum switch.
307
309
  learnedTuning: z.object({
@@ -366,6 +368,11 @@ export const TickmarkrConfigSchema = z.object({
366
368
  // R3: globs no task may skip review on (input/demux/lifecycle, gates/run/drivers/adapters, anything
367
369
  // reaching a shell). A judge-only task intersecting one of these fails COMPILE, never silently skips.
368
370
  criticalPaths: z.array(z.string()).optional(),
371
+ // RF-1 (OBS-922 add.2/3): review.floor — `worker` (default) names no tier and leaves the seat
372
+ // ranking to the author's routed seat; a tier is folded in as max(task floor, this) by reviewGate
373
+ // and its retry — it can raise a seat and never lower one. Not in the template: the cockpit
374
+ // fixture pins the fresh-install bytes (see d0e89a9a).
375
+ floor: z.union([z.literal("worker"), TierEnum]).default("worker"),
369
376
  }),
370
377
  // v1.54 T1: prefer — ranked consult seat failover. Entries MUST be adapter:model (unlike
371
378
  // review.prefer's adapter|adapter:model grammar): a consult seat has no channel to inherit a
@@ -445,6 +452,7 @@ export const DEFAULT_CONFIG = {
445
452
  // a workspace that has accumulated ≥MIN_SAMPLES warm telemetry per cell. Preview any workspace's effect
446
453
  // first with `tickmarkr plan` / `tickmarkr report`; flip to "off" to pin exact static routing (the kill switch stands).
447
454
  learned: "on",
455
+ escalateTier: "on",
448
456
  allowUnverifiedModels: false,
449
457
  },
450
458
  // Seed table (spec §13). New models = edit this (or your config.yaml), never code.
@@ -590,7 +598,7 @@ export const DEFAULT_CONFIG = {
590
598
  judge: { adapter: "claude-code", model: "fable" },
591
599
  // R3: no `policy` floor — the neutral floor leaves the compiler's per-task assignment standing, so
592
600
  // the path-keyed rule is reachable out of the box rather than raised to full by construction.
593
- review: { complexityThreshold: 7, timeoutMs: 900_000, required: true, criticalPaths: [...DEFAULT_REVIEW_CRITICAL_PATHS] },
601
+ review: { complexityThreshold: 7, timeoutMs: 900_000, required: true, floor: "worker", criticalPaths: [...DEFAULT_REVIEW_CRITICAL_PATHS] },
594
602
  consult: { adapter: "claude-code", model: "fable", stallMinutes: 15 },
595
603
  // v1.4: gate LLM calls (judge/review/consult) run headless by default; pane opts back into visible agents.
596
604
  // v1.2: workers are the real agent TUI in the pane; "print" restores the -p-rendered-in-pane path.
@@ -784,6 +792,7 @@ export function configTemplate(overlay) {
784
792
  # floors: # tier authority — advisory minimum bands; 'tickmarkr plan' lints violations
785
793
  # migration: frontier
786
794
  # learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
795
+ # escalateTier: on # on | off (default on): climb one tier on request when untried channels exist in higher tiers
787
796
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
788
797
  # explore: { mode: on, excludeShapes: [], excludeComplexityAtOrAbove: null, cap: 5 } # optional; absent ⇒ byte-identical
789
798
  # sla: { implement: 15 } # optional per-shape minutes — advisory plan lint only; absent ⇒ no lint
@@ -817,7 +826,9 @@ export function configTemplate(overlay) {
817
826
  # test: npm test
818
827
  # byShape:
819
828
  # docs: { acceptance: false, review: false } # baseline, evidence, and scope are mandatory
820
- # review: { complexityThreshold: 7, timeoutMs: 900000, required: true, prefer: [codex:gpt-5.6-sol, kimi] }
829
+ # review: { complexityThreshold: 7, timeoutMs: 900000, required: true, floor: worker, prefer: [codex:gpt-5.6-sol, kimi] }
830
+ # # floor: worker (default) | cheap | mid | frontier — the reviewer seats at or above
831
+ # # max(author tier, task floor, this tier, prior reviewer); a tier here only raises it
821
832
  # # prefer: ordered reviewer seat preference (adapter | adapter:model); ranks
822
833
  # # diversity-eligible channels only — never admits a same-vendor/same-model reviewer
823
834
  # consult: { adapter: claude-code, model: fable, stallMinutes: 15, prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] }
@@ -876,6 +887,14 @@ export function readOverlayFile(path) {
876
887
  throw new ConfigError(`invalid overlay at ${path}`);
877
888
  return raw;
878
889
  }
890
+ /** Does a deny/allow entry name this universe channel? Grammar A1 (adapter id, bare model id,
891
+ * adapter:model) plus the recorded identity (bare or adapter:identity) — the collector's authority. */
892
+ export function universeEntryMatches(row, model, entry) {
893
+ if (entry === row.adapter || entry === model || entry === `${row.adapter}:${model}`)
894
+ return true;
895
+ const identity = row.identities?.[model];
896
+ return identity !== undefined && (entry === identity || entry === `${row.adapter}:${identity}`);
897
+ }
879
898
  /** Grammar A1 membership test: does any discovered universe row cover this deny/allow entry
880
899
  * (adapter id, bare model id, or adapter:model)? Uncovered entries name channels the probe
881
900
  * did not serve this session (failed auth, rate limit, retired sku) — they are NOT editable
@@ -884,9 +903,11 @@ export function universeCovers(universe, entry) {
884
903
  return universe.some((row) => entry === row.adapter || row.models.some((m) => entry === m || entry === `${row.adapter}:${m}`));
885
904
  }
886
905
  // v1.92 fleet membership: with a discovered `universe` (classified models only) the deny sets become
887
- // the EFFECTIVE out-of-fleet complement — anything routing.allow does not admit (fail-closed) UNION
888
- // routing.deny adapters/models scopes (never workers). Absent universe ⇒ deny arrays verbatim, so
889
- // non-UI callers stay byte-identical.
906
+ // the EFFECTIVE out-of-fleet set — the routing.allow complement (fail-closed) as adapter ids and
907
+ // adapter:model keys, UNION every authored routing.deny adapters/models entry VERBATIM in its own list
908
+ // (never workers). LEG2-T3 round 2 finding 2: authored entries are never collapsed into a membership
909
+ // key, so one Space press clears one reason; finding 3: allow admission honours recorded identity.
910
+ // Absent universe ⇒ deny arrays verbatim, so non-UI callers stay byte-identical.
890
911
  export function fleetEditableFromConfig(cfg, universe) {
891
912
  const tiers = {};
892
913
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
@@ -898,43 +919,32 @@ export function fleetEditableFromConfig(cfg, universe) {
898
919
  let denyAdapters = [...(cfg.routing.deny?.adapters ?? [])].sort();
899
920
  let denyModels = [...(cfg.routing.deny?.models ?? [])].sort();
900
921
  if (universe !== undefined) {
901
- const { allow, deny } = cfg.routing;
902
- // Grammar A1 (disallowedBy precedent): entries match adapter id, bare model id, or adapter:model.
903
- // Inlined rather than imported — route/preference.ts imports this module.
904
- const denyEntries = [...(deny?.adapters ?? []), ...(deny?.models ?? [])];
922
+ const { allow } = cfg.routing;
905
923
  const allowEntries = allow ? [...(allow.adapters ?? []), ...(allow.models ?? [])] : null;
906
- const outOfFleet = (adapter, model) => {
907
- const matches = (e) => e === adapter || e === model || e === `${adapter}:${model}`;
908
- if (denyEntries.some(matches))
909
- return true;
910
- return allowEntries !== null && !allowEntries.some(matches);
911
- };
924
+ const notAdmitted = (row, model) => allowEntries !== null && !allowEntries.some((entry) => universeEntryMatches(row, model, entry));
912
925
  const adaptersOut = [];
913
926
  const modelsOut = [];
914
927
  for (const row of universe) {
915
- const out = row.models.filter((m) => outOfFleet(row.adapter, m));
916
- if (row.models.length ? out.length === row.models.length : outOfFleet(row.adapter, "")) {
928
+ const out = row.models.filter((m) => notAdmitted(row, m));
929
+ if (row.models.length ? out.length === row.models.length : notAdmitted(row, "")) {
917
930
  adaptersOut.push(row.adapter);
918
931
  }
919
932
  else {
920
933
  modelsOut.push(...out.map((m) => `${row.adapter}:${m}`));
921
934
  }
922
935
  }
923
- // OBS-517: deny entries the probe universe does not cover (failed/rate-limited probes drop the
924
- // channel from discoverChannels) are preserved VERBATIM — deriving membership purely from the
925
- // universe silently un-denied them on the next write (fail-open on the operator's own ban list).
926
- denyAdapters = [...new Set([
927
- ...adaptersOut,
928
- ...(deny?.adapters ?? []).filter((e) => !universeCovers(universe, e)),
929
- ])].sort();
930
- denyModels = [...new Set([
931
- ...modelsOut,
932
- ...(deny?.models ?? []).filter((e) => !universeCovers(universe, e)),
933
- ])].sort();
936
+ // OBS-517: authored deny entries — covered by the probe universe or not — ride verbatim, so a
937
+ // transient probe failure can never un-deny one on the next write (fail-open on the ban list).
938
+ denyAdapters = [...new Set([...adaptersOut, ...denyAdapters])].sort();
939
+ denyModels = [...new Set([...modelsOut, ...denyModels])].sort();
934
940
  }
935
941
  return {
936
942
  denyAdapters,
937
943
  denyModels,
944
+ // OBS-994: workers-only deny is never folded into the all-seats complement above — it is
945
+ // returned verbatim beside the flat scopes so the fleet browser can show and toggle it.
946
+ denyWorkersAdapters: [...(cfg.routing.deny?.workers?.adapters ?? [])].sort(),
947
+ denyWorkersModels: [...(cfg.routing.deny?.workers?.models ?? [])].sort(),
938
948
  tiers,
939
949
  map: structuredClone(cfg.routing.map),
940
950
  floors: { ...cfg.routing.floors },
@@ -977,6 +987,10 @@ export function formatFleetPrint(repoRoot, opts = {}) {
977
987
  annotate(["routing", "deny", "adapters"]);
978
988
  if (effective.routing.deny?.models !== undefined)
979
989
  annotate(["routing", "deny", "models"]);
990
+ if (effective.routing.deny?.workers?.adapters !== undefined)
991
+ annotate(["routing", "deny", "workers", "adapters"]);
992
+ if (effective.routing.deny?.workers?.models !== undefined)
993
+ annotate(["routing", "deny", "workers", "models"]);
980
994
  for (const shape of Object.keys(effective.routing.map))
981
995
  annotate(["routing", "map", shape]);
982
996
  annotate(["routing", "floors"]);
@@ -1,4 +1,4 @@
1
- import { type FleetEditable, type RoutingMode } from "./config.js";
1
+ import { type FleetEditable, type FleetUniverseRow, type RoutingMode } from "./config.js";
2
2
  /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
3
3
  export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
4
4
  export type FleetOverlayWrite = {
@@ -19,10 +19,7 @@ export type FleetOverlayWrite = {
19
19
  consult?: string[];
20
20
  };
21
21
  };
22
- universe?: {
23
- adapter: string;
24
- models: string[];
25
- }[];
22
+ universe?: FleetUniverseRow[];
26
23
  };
27
24
  export type FleetFirstTouch = {
28
25
  vendor: string;
@@ -33,10 +30,7 @@ export declare function fleetFirstTouchProvenance(note: string, firstTouch: Flee
33
30
  * keys, ordering, scalar style, and comments; fresh provenance is written directly on its tier node. */
34
31
  export declare function renderFleetOverlayWrite(priorBytes: string, write: FleetOverlayWrite): string;
35
32
  /** Build the repo overlay fragment fleet would write for edits since session start. */
36
- export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch>>, universe?: {
37
- adapter: string;
38
- models: string[];
39
- }[]): Record<string, unknown>;
33
+ export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch>>, universe?: FleetUniverseRow[]): Record<string, unknown>;
40
34
  export declare function repoOverlayYaml(overlay: Record<string, unknown>): string;
41
35
  export declare function serializeFleetOverlay(overlay: Record<string, unknown>): string;
42
36
  export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
@@ -1,6 +1,6 @@
1
1
  // Fleet-overlay mutation, serialization, and diff rendering for the `tickmarkr fleet` write path.
2
2
  import { isMap, isScalar, isSeq, parseDocument, stringify, visit } from "yaml";
3
- import { universeCovers } from "./config.js";
3
+ import { universeCovers, universeEntryMatches } from "./config.js";
4
4
  /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
5
5
  export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
6
6
  function fleetSubset(obj) {
@@ -21,17 +21,18 @@ function allowFormFromExclusions(universe, edited) {
21
21
  if (!universe.length) {
22
22
  throw new Error("fleet write: universe is empty — no classified models to compute routing.allow from; classify models in `tickmarkr fleet` first");
23
23
  }
24
- const denyAdapters = new Set(edited.denyAdapters);
25
- const denyModels = new Set(edited.denyModels);
24
+ // LEG2-T3 round 2 finding 1: every staged entry excludes what it NAMES — a bare model id every
25
+ // adapter serving it, an identity its alias — never only an adapter id or an adapter:model key.
26
+ const entries = [...edited.denyAdapters, ...edited.denyModels];
26
27
  const adapters = [];
27
28
  const models = [];
28
29
  let excluded = false;
29
30
  for (const row of universe) {
30
- if (denyAdapters.has(row.adapter)) {
31
+ if (entries.includes(row.adapter)) {
31
32
  excluded = true;
32
33
  continue;
33
34
  }
34
- const inFleet = row.models.filter((m) => !denyModels.has(`${row.adapter}:${m}`));
35
+ const inFleet = row.models.filter((m) => !entries.some((entry) => universeEntryMatches(row, m, entry)));
35
36
  if (inFleet.length === row.models.length) {
36
37
  adapters.push(row.adapter);
37
38
  }
@@ -49,6 +50,21 @@ function allowFormFromExclusions(universe, edited) {
49
50
  function residualDeny(universe, entries) {
50
51
  return sortedUnique(entries.filter((entry) => !universeCovers(universe, entry)));
51
52
  }
53
+ // LEG2-T3 round 2 finding 2: the flat deny list a membership write leaves behind. Every entry the
54
+ // repo overlay authored in THIS list that is still staged stays verbatim, in its authored order and
55
+ // with its comments — one cleared reason never takes an independent one with it, and an untouched
56
+ // list keeps its node. A staged entry the allow form cannot express as a membership key (outside the
57
+ // probe universe, or a bare-model/identity spelling) is written verbatim too; canonical keys the
58
+ // session added ride the allow form alone.
59
+ function flatDenyAfterWrite(doc, path, after, universe) {
60
+ const node = doc.getIn(path, true);
61
+ const authored = isSeq(node) ? node.items.flatMap((item) => (isScalar(item) ? [String(item.value)] : [])) : [];
62
+ const canonical = (entry) => universe.some((row) => entry === row.adapter || row.models.some((m) => entry === `${row.adapter}:${m}`));
63
+ const kept = authored.filter((entry) => after.includes(entry));
64
+ const verbatim = sortedUnique(after.filter((entry) => !kept.includes(entry)
65
+ && (!universeCovers(universe, entry) || !canonical(entry))));
66
+ return [...new Set([...kept, ...verbatim])];
67
+ }
52
68
  // fleet.ts deliberately remains the sole overlay builder and writer. Its established classification
53
69
  // seam copies only `tier` and `note` into FleetEditable, so first-touch entry metadata rides inside a
54
70
  // private provenance envelope until this module writes the YAML. The envelope never reaches disk.
@@ -199,18 +215,35 @@ export function renderFleetOverlayWrite(priorBytes, write) {
199
215
  // Whole fleet in: no restriction to express — the allow block goes away entirely.
200
216
  deleteAt(doc, ["routing", "allow"]);
201
217
  }
202
- // Residuals stay in deny; covered scopes are tombstoned so a lower layer can never
203
- // re-exclude behind the operator's back (workers untouched).
204
- const residualAdapters = residualDeny(write.universe, edited.denyAdapters);
205
- const residualModels = residualDeny(write.universe, edited.denyModels);
206
- setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], residualAdapters.length ? residualAdapters : null);
207
- setStringSequencePreservingComments(doc, ["routing", "deny", "models"], residualModels.length ? residualModels : null);
218
+ // Authored and non-canonical entries stay in deny (LEG2-T3 finding 4, round 2 finding 2); a
219
+ // list left with nothing is tombstoned so a lower layer can never re-exclude behind the
220
+ // operator's back (workers untouched).
221
+ for (const [scope, after] of [["adapters", edited.denyAdapters], ["models", edited.denyModels]]) {
222
+ const path = ["routing", "deny", scope];
223
+ const remaining = flatDenyAfterWrite(doc, path, after, write.universe);
224
+ setStringSequencePreservingComments(doc, path, remaining.length ? remaining : null);
225
+ }
208
226
  }
209
227
  else {
210
228
  setStringSequencePreservingComments(doc, ["routing", "deny", "adapters"], edited.denyAdapters.length ? sortedUnique(edited.denyAdapters) : null);
211
229
  setStringSequencePreservingComments(doc, ["routing", "deny", "models"], edited.denyModels.length ? sortedUnique(edited.denyModels) : null);
212
230
  }
213
231
  }
232
+ // OBS-994/FL-1: routing.deny.workers is a literal deny list, never a universe-derived
233
+ // membership scope — it never routes through the allow-complement dance above, in either
234
+ // branch. Same tombstone/comment-preserving rules as the flat scopes.
235
+ const initialWorkersAdapters = initial.denyWorkersAdapters ?? [];
236
+ const editedWorkersAdapters = edited.denyWorkersAdapters ?? [];
237
+ const initialWorkersModels = initial.denyWorkersModels ?? [];
238
+ const editedWorkersModels = edited.denyWorkersModels ?? [];
239
+ // Each sub-path mutates independently — an untouched sibling must not be rewritten (a `null`
240
+ // tombstone over an absent/untouched sibling would mask a lower layer's own workers scope).
241
+ if (sortedUnique(initialWorkersAdapters).join() !== sortedUnique(editedWorkersAdapters).join()) {
242
+ setStringSequencePreservingComments(doc, ["routing", "deny", "workers", "adapters"], editedWorkersAdapters.length ? sortedUnique(editedWorkersAdapters) : null);
243
+ }
244
+ if (sortedUnique(initialWorkersModels).join() !== sortedUnique(editedWorkersModels).join()) {
245
+ setStringSequencePreservingComments(doc, ["routing", "deny", "workers", "models"], editedWorkersModels.length ? sortedUnique(editedWorkersModels) : null);
246
+ }
214
247
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
215
248
  const before = initial.map[shape];
216
249
  const after = edited.map[shape];
@@ -379,6 +412,30 @@ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}, fi
379
412
  }
380
413
  routingTouched = true;
381
414
  }
415
+ // OBS-994/FL-1: workers deny is a literal list, independent of the universe/allow dance above.
416
+ // Each sub-path is included only when it actually changed — an untouched sibling must not be
417
+ // rewritten as a `null` tombstone over whatever the existing repo overlay already held.
418
+ const initialWorkersAdapters = initial.denyWorkersAdapters ?? [];
419
+ const editedWorkersAdapters = edited.denyWorkersAdapters ?? [];
420
+ const initialWorkersModels = initial.denyWorkersModels ?? [];
421
+ const editedWorkersModels = edited.denyWorkersModels ?? [];
422
+ const workersAdaptersChanged = sortedUnique(initialWorkersAdapters).join() !== sortedUnique(editedWorkersAdapters).join();
423
+ const workersModelsChanged = sortedUnique(initialWorkersModels).join() !== sortedUnique(editedWorkersModels).join();
424
+ if (workersAdaptersChanged || workersModelsChanged) {
425
+ routing.deny = {
426
+ ...routing.deny,
427
+ workers: {
428
+ ...(routing.deny?.workers),
429
+ ...(workersAdaptersChanged
430
+ ? { adapters: editedWorkersAdapters.length ? editedWorkersAdapters : null }
431
+ : {}),
432
+ ...(workersModelsChanged
433
+ ? { models: editedWorkersModels.length ? editedWorkersModels : null }
434
+ : {}),
435
+ },
436
+ };
437
+ routingTouched = true;
438
+ }
382
439
  // pool widened to accept the null tombstone; MapEntry itself never carries null in memory.
383
440
  const mapDelta = {};
384
441
  for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
@@ -21,3 +21,10 @@ export type FleetWhyOptions = {
21
21
  export declare function projectFleetWhy<Id extends string>(values: readonly FleetWhyValue<Id>[], options: FleetWhyOptions): FleetWhyRow<Id>[];
22
22
  /** Plain line-mode twin of the Shapes rows; labels are projected, never reconstructed here. */
23
23
  export declare function renderFleetWhy(rows: readonly FleetWhyRow[]): string;
24
+ /** LEG2-T3: one exclusion-collector scope as the reason a fleet row shows — its config path, then
25
+ * the entry that matched (a deny) or the fact the allowlist does not admit the channel. */
26
+ export declare function exclusionReason(scope: {
27
+ by: "deny" | "allow";
28
+ configPath: string;
29
+ entry: string;
30
+ }): string;
@@ -40,3 +40,8 @@ export function projectFleetWhy(values, options) {
40
40
  export function renderFleetWhy(rows) {
41
41
  return ["tickmarkr fleet --why — effective shape routing", ...rows.map((row) => row.label)].join("\n");
42
42
  }
43
+ /** LEG2-T3: one exclusion-collector scope as the reason a fleet row shows — its config path, then
44
+ * the entry that matched (a deny) or the fact the allowlist does not admit the channel. */
45
+ export function exclusionReason(scope) {
46
+ return scope.by === "allow" ? `${scope.configPath} (not admitted)` : `${scope.configPath} (${scope.entry})`;
47
+ }
@@ -128,6 +128,11 @@ export declare class HerdrDriver implements ExecutorDriver {
128
128
  close(slot: Slot): Promise<void>;
129
129
  private closeGrouped;
130
130
  private watchPanes;
131
+ /** WB-1 (OBS-988): the daemon proved this run's own board lost. Forget the cached slot FIRST — even a
132
+ * retire that fails must never let the narrator answer with the ghost again — then take the pane
133
+ * back on ownership alone: the dead UI can never acknowledge, so the stop request is left for a
134
+ * merely stuck one to find. Serialized with the narrator so no split races the close. */
135
+ retireLostWatch(slot: Slot): Promise<void>;
131
136
  /** Name collisions never confer repository ownership. Unknown boards stay protected. */
132
137
  private retireWatch;
133
138
  focus(target: FocusTarget): Promise<FocusResult>;