tickmarkr 2.5.4 → 2.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/registry.js +6 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/approve.d.ts +1 -0
  5. package/dist/cli/commands/approve.js +118 -9
  6. package/dist/cli/commands/doctor.d.ts +10 -0
  7. package/dist/cli/commands/doctor.js +44 -29
  8. package/dist/cli/commands/fleet.js +199 -33
  9. package/dist/cli/commands/init.js +196 -6
  10. package/dist/cli/commands/plan.js +20 -4
  11. package/dist/cli/commands/resume.js +4 -2
  12. package/dist/cli/commands/run.js +11 -2
  13. package/dist/cli/commands/verify.js +102 -81
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +3 -1
  16. package/dist/config/config.d.ts +25 -8
  17. package/dist/config/config.js +41 -29
  18. package/dist/config/fleet-overlay.d.ts +3 -9
  19. package/dist/config/fleet-overlay.js +114 -19
  20. package/dist/config/fleet-why.d.ts +7 -0
  21. package/dist/config/fleet-why.js +5 -0
  22. package/dist/drivers/index.d.ts +15 -1
  23. package/dist/drivers/index.js +39 -10
  24. package/dist/drivers/orca.d.ts +119 -10
  25. package/dist/drivers/orca.js +781 -110
  26. package/dist/gates/baseline.d.ts +17 -3
  27. package/dist/gates/baseline.js +63 -15
  28. package/dist/gates/cache.d.ts +101 -0
  29. package/dist/gates/cache.js +401 -0
  30. package/dist/gates/llm.d.ts +3 -0
  31. package/dist/gates/llm.js +11 -0
  32. package/dist/gates/review.d.ts +28 -3
  33. package/dist/gates/review.js +106 -14
  34. package/dist/gates/run-gates.d.ts +9 -1
  35. package/dist/gates/run-gates.js +407 -102
  36. package/dist/gates/test-manifest.d.ts +128 -0
  37. package/dist/gates/test-manifest.js +463 -0
  38. package/dist/gates/test-reporter.d.ts +4 -0
  39. package/dist/gates/test-reporter.js +57 -0
  40. package/dist/graph/graph.d.ts +2 -0
  41. package/dist/graph/graph.js +45 -1
  42. package/dist/route/preference.d.ts +22 -1
  43. package/dist/route/preference.js +123 -25
  44. package/dist/route/router.js +31 -6
  45. package/dist/run/consult.js +5 -4
  46. package/dist/run/daemon.d.ts +15 -0
  47. package/dist/run/daemon.js +638 -167
  48. package/dist/run/execution-budget.d.ts +25 -0
  49. package/dist/run/execution-budget.js +142 -0
  50. package/dist/run/git.d.ts +50 -1
  51. package/dist/run/git.js +131 -12
  52. package/dist/run/journal.d.ts +16 -2
  53. package/dist/run/journal.js +115 -25
  54. package/dist/run/lease.d.ts +58 -0
  55. package/dist/run/lease.js +310 -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/run/recovery.d.ts +8 -0
  61. package/dist/run/recovery.js +25 -0
  62. package/dist/run/repair-selection.d.ts +12 -0
  63. package/dist/run/repair-selection.js +56 -0
  64. package/dist/run/stall.d.ts +6 -1
  65. package/dist/run/stall.js +60 -3
  66. package/dist/tui/cockpit/board.d.ts +96 -0
  67. package/dist/tui/cockpit/board.js +346 -0
  68. package/dist/tui/cockpit/decision-actions.js +2 -0
  69. package/dist/tui/cockpit/layout.d.ts +5 -1
  70. package/dist/tui/cockpit/layout.js +8 -3
  71. package/dist/tui/cockpit/live-runtime.js +71 -21
  72. package/dist/tui/cockpit/run-view.d.ts +7 -5
  73. package/dist/tui/cockpit/run-view.js +12 -11
  74. package/dist/tui/ink/fleet-app.d.ts +49 -27
  75. package/dist/tui/ink/fleet-app.js +229 -38
  76. package/package.json +2 -2
  77. package/skills/tickmarkr-overseer/SKILL.md +173 -113
  78. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -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,44 @@ 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
+ // OBS-1046: the allow complement is staged beside the deny lists, never folded into them
328
+ allowOut: deny.allowOut ?? initial.allowOut,
329
+ denyWorkersAdapters: deny.workersAdapters,
330
+ denyWorkersModels: deny.workersModels,
331
+ map,
332
+ });
333
+ const candidateMemo = new Map();
309
334
  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 };
335
+ const key = JSON.stringify([mode, map, deny]);
336
+ const hit = candidateMemo.get(key);
337
+ if (hit)
338
+ return hit;
339
+ let preview;
340
+ try {
341
+ const bytes = renderFleetOverlayWrite(currentRepoOverlayText(cwd), {
342
+ initial,
343
+ edited: stagedEditable(map, deny),
344
+ universe,
345
+ ...(mode !== rm.mode.mode ? { mode } : {}),
346
+ });
347
+ preview = { ok: true, cfg: loadConfigWithMode(cwd, { globalDir, repoOverlayText: bytes }).cfg };
348
+ }
349
+ catch (error) {
350
+ preview = { ok: false, error: error.message.replace(/\s+/g, " ").trim() };
351
+ }
352
+ if (candidateMemo.size > 32)
353
+ candidateMemo.clear();
354
+ candidateMemo.set(key, preview);
355
+ return preview;
356
+ };
357
+ const previewUnavailable = (error) => `preview unavailable (${error}) — the staged overlay does not load, so w would be refused`;
358
+ const stagedRouting = (deny) => {
359
+ const preview = previewCfg(rm.mode.mode, editable.map, deny);
360
+ return preview.ok ? { ok: true, routing: preview.cfg.routing } : preview;
327
361
  };
328
362
  // route() never deny-filters its pool — that contract lives in discoverChannels — so every
329
363
  // preview call rebuilds the pool the staged deny would discover
@@ -333,7 +367,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
333
367
  let subs = 0;
334
368
  let apiN = 0;
335
369
  let apiUsd = 0;
336
- const cfgPreview = previewCfg(mode, map, deny);
370
+ const preview = previewCfg(mode, map, deny);
371
+ if (!preview.ok)
372
+ return ` mix: ${previewUnavailable(preview.error)}`;
373
+ const cfgPreview = preview.cfg;
337
374
  const pool = previewPool(cfgPreview);
338
375
  for (const shape of SHAPES) {
339
376
  try {
@@ -393,7 +430,15 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
393
430
  : { declaredAt: "routing.mode" };
394
431
  };
395
432
  const projectedShapeRows = (mode, map, deny) => {
396
- const cfgPreview = previewCfg(mode, map, deny);
433
+ const preview = previewCfg(mode, map, deny);
434
+ if (!preview.ok) {
435
+ return projectFleetWhy(SHAPES.map((shape) => ({
436
+ id: shape,
437
+ effective: previewUnavailable(preview.error),
438
+ ...whyDeclaration(shape, mode, map, false),
439
+ })), { repoRoot: cwd, globalDir });
440
+ }
441
+ const cfgPreview = preview.cfg;
397
442
  const pool = previewPool(cfgPreview);
398
443
  const values = SHAPES.map((shape) => {
399
444
  try {
@@ -433,13 +478,13 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
433
478
  const unauthedClis = adapters
434
479
  .filter((adapter) => health[adapter.id]?.installed && health[adapter.id]?.authed === false)
435
480
  .map((adapter) => adapter.id);
436
- const unauthedModelCount = modelAuthExclusions(cfg, adapters, health).length;
437
- const excludedNoteFor = (deny) => {
481
+ const unauthedModels = modelAuthExclusions(cfg, adapters, health);
482
+ const unauthedModelCount = unauthedModels.length;
483
+ const excludedNoteFor = (cfgPreview) => {
438
484
  let stagedOut = 0;
439
485
  let denied = 0;
440
- const stagedRouting = previewCfg(rm.mode.mode, {}, deny).routing;
441
486
  for (const channel of previewChannels) {
442
- if (disallowedBy(channel, stagedRouting) === null)
487
+ if (disallowedBy(channel, cfgPreview.routing) === null)
443
488
  continue;
444
489
  if (disallowedBy(channel, cfg.routing) === null)
445
490
  stagedOut += 1;
@@ -459,14 +504,122 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
459
504
  parts.push(`${unclassifiedRows.length} unclassified (never routed)`);
460
505
  return parts.length ? `not offered: ${parts.join(" · ")} — the models view explains each row` : undefined;
461
506
  };
507
+ // OBS-994/FL-1: the nine mechanisms outside the deny scopes that can keep a channel off a
508
+ // shape's candidate list each get a reason or an explicit "not manageable here" caption — a
509
+ // silent omission is indistinguishable from a bug (OBS-530's lesson, extended past deny/allow).
510
+ // allow and the unauthed probe already carry a reason through excludedNoteFor above; the rest
511
+ // are shape-scoped (a pin/pool/floor/prefer excludes only for THIS shape) and belong here.
512
+ const shapeExclusionCaptions = (shape, map, cfgPreview) => {
513
+ const pool = previewPool(cfgPreview);
514
+ const entry = map[shape];
515
+ const captions = [];
516
+ // 2. pin or pool — a closed candidate set declared for this shape; a channel outside it is
517
+ // not manageable from the models view, it is a Shapes-view declaration.
518
+ if (entry?.pin) {
519
+ captions.push(`pin: routing.map.${shape}.pin fixes this shape to ${entry.pin.via}:${entry.pin.model} — not manageable here, edit it in Shapes`);
520
+ }
521
+ else if (entry?.pool) {
522
+ const declared = new Set(entry.pool.channels);
523
+ const outside = pool.filter((c) => !declared.has(`${c.adapter}:${c.model}`));
524
+ if (outside.length) {
525
+ captions.push(`pool: ${outside.length} channel(s) outside routing.map.${shape}.pool's declared set — not manageable here, edit it in Shapes`);
526
+ }
527
+ }
528
+ // 3. floor — channels below this shape's minimum tier.
529
+ const floor = cfgPreview.routing.floors[shape];
530
+ if (floor) {
531
+ const belowFloor = pool.filter((c) => TIER_RANK[c.tier] < TIER_RANK[floor]);
532
+ if (belowFloor.length) {
533
+ captions.push(`floor: ${belowFloor.length} channel(s) below routing.floors.${shape} (${floor}) — not manageable here, edit the floor in Shapes`);
534
+ }
535
+ }
536
+ // 5. tombstones — the repo overlay's OWN null over this shape's map entry, masking a lower
537
+ // (global/default) layer's declaration rather than expressing a value of its own.
538
+ const repoRaw = readOverlayFile(repoOverlayPath(cwd));
539
+ const rawEntry = repoRaw.routing?.map?.[shape];
540
+ if (rawEntry === null) {
541
+ captions.push(`tombstone: routing.map.${shape}: null masks a lower layer's declaration — not manageable here, edit the repo overlay directly`);
542
+ }
543
+ else if (rawEntry && "pool" in rawEntry && rawEntry.pool === null) {
544
+ captions.push(`tombstone: routing.map.${shape}.pool: null masks a lower layer's pool — not manageable here, edit the repo overlay directly`);
545
+ }
546
+ else if (rawEntry && "pin" in rawEntry && rawEntry.pin === null) {
547
+ captions.push(`tombstone: routing.map.${shape}.pin: null masks a lower layer's pin — not manageable here, edit the repo overlay directly`);
548
+ }
549
+ // 6. explore/learned knobs — global routing settings, never a per-channel fleet toggle.
550
+ if (cfgPreview.routing.explore?.mode === "off") {
551
+ captions.push("explore: routing.explore.mode is off — a global knob, not manageable here");
552
+ }
553
+ if (cfgPreview.routing.learned === "off") {
554
+ captions.push("learned: routing.learned is off — a global knob, not manageable here");
555
+ }
556
+ // 7. task hint — a shape's prefer bias is authored in Shapes/spec, not per-channel in fleet.
557
+ if (entry?.prefer?.length) {
558
+ captions.push(`task hint: routing.map.${shape}.prefer ranks ${entry.prefer.join(", ")} — not manageable here, edit it in Shapes`);
559
+ }
560
+ // 8. deny∩prefer collision — a standing lint on this shape's declaration, never a fleet toggle.
561
+ for (const collision of denyPreferCollisions(cfgPreview, [shape])) {
562
+ captions.push(denyPreferCollisionLine(collision));
563
+ }
564
+ // 9. first-match provenance — the per-channel ledger below lists EVERY collector scope.
565
+ return captions;
566
+ };
567
+ // LEG2-T3 finding 1: each channel a mechanism touches on this shape gets its own ledger row
568
+ // naming every reason — collector scopes with their config paths (allow, flat and workers deny,
569
+ // all of them, never a first match), the unauthed probe, pin/pool/floor, the task hint and a
570
+ // deny∩prefer collision it is the target of — or that leaves the shape with no candidate at all.
571
+ const channelLedger = (shape, map, cfgPreview, offered) => {
572
+ const entry = map[shape];
573
+ const floor = cfgPreview.routing.floors[shape];
574
+ const collisions = denyPreferCollisions(cfgPreview, [shape]);
575
+ const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
576
+ // discovery already dropped a channel whose model probe failed — it still gets its own row
577
+ const unauthedRows = [
578
+ ...unauthedClis.map((adapter) => `${adapter} — CLI unauthed — re-probe with tickmarkr doctor`),
579
+ ...unauthedModels.filter(({ key }) => !discovered.has(key))
580
+ .map(({ key, reason }) => `${key.replace(":", "/")} — unauthed (${reason}) — re-probe with tickmarkr doctor`),
581
+ ];
582
+ return [...unauthedRows, ...previewChannels.flatMap((c) => {
583
+ const key = `${c.adapter}:${c.model}`;
584
+ const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
585
+ if (health[c.adapter]?.authed === false)
586
+ reasons.push(`${c.adapter} CLI unauthed — re-probe with tickmarkr doctor`);
587
+ else if (health[c.adapter]?.modelAuth?.[c.model]?.authed === false)
588
+ reasons.push("unauthed — re-probe with tickmarkr doctor");
589
+ if (entry?.pin && key !== `${entry.pin.via}:${entry.pin.model}`) {
590
+ reasons.push(`not routing.map.${shape}.pin (${entry.pin.via}:${entry.pin.model}) — not manageable here`);
591
+ }
592
+ if (entry?.pool && !entry.pool.channels.includes(key)) {
593
+ reasons.push(`outside routing.map.${shape}.pool — not manageable here`);
594
+ }
595
+ if (floor && TIER_RANK[c.tier] < TIER_RANK[floor]) {
596
+ reasons.push(`below routing.floors.${shape} (${floor}) — not manageable here`);
597
+ }
598
+ if (entry?.prefer?.some((p) => entryMatchesChannel(p, c))) {
599
+ reasons.push(`task hint: routing.map.${shape}.prefer names it — not manageable here`);
600
+ }
601
+ for (const collision of collisions) {
602
+ const named = collision.detail.split(" > ").some((p) => entryMatchesChannel(p, c));
603
+ if (named || offered.size === 0)
604
+ reasons.push(denyPreferCollisionLine(collision));
605
+ }
606
+ // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
607
+ return reasons.length ? [`${c.adapter}/${c.model} — ${reasons.join("; ")}`] : [];
608
+ })];
609
+ };
462
610
  const candidatesForShape = (shape, mode, map, deny) => {
463
- const cfgPreview = previewCfg(mode, map, deny);
611
+ const preview = previewCfg(mode, map, deny);
612
+ if (!preview.ok)
613
+ return { rows: [], excludedNote: previewUnavailable(preview.error) };
614
+ const cfgPreview = preview.cfg;
464
615
  const rows = shapeCandidates(previewTask(shape), cfgPreview, previewPool(cfgPreview), profile).map((candidate) => ({
465
616
  id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
466
617
  label: candidateRow(candidate, cfg.pricing),
467
618
  pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
468
619
  }));
469
- return { rows, excludedNote: excludedNoteFor(deny) };
620
+ const offered = new Set(rows.map((row) => row.id));
621
+ const notes = [excludedNoteFor(cfgPreview), ...shapeExclusionCaptions(shape, map, cfgPreview)].filter((note) => note !== undefined);
622
+ return { rows, excludedNote: notes.length ? notes.join("\n") : undefined, ledger: channelLedger(shape, map, cfgPreview, offered) };
470
623
  };
471
624
  const preferUniverse = [
472
625
  ...new Set(channels.flatMap((channel) => [
@@ -493,6 +646,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
493
646
  const staged = structuredClone(initial);
494
647
  staged.denyAdapters = state.denyAdapters;
495
648
  staged.denyModels = state.denyModels;
649
+ staged.allowOut = state.allowOut ?? initial.allowOut;
650
+ // OBS-994/FL-1: the workers-only deny scope rides the same review/write funnel as the flat one.
651
+ staged.denyWorkersAdapters = state.denyWorkersAdapters;
652
+ staged.denyWorkersModels = state.denyWorkersModels;
496
653
  staged.map = state.map;
497
654
  const today = new Date().toISOString().slice(0, 10);
498
655
  for (const classification of state.classifications) {
@@ -545,6 +702,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
545
702
  health,
546
703
  initialDenyAdapters: editable.denyAdapters,
547
704
  initialDenyModels: editable.denyModels,
705
+ initialAllowOut: editable.allowOut,
706
+ initialDenyWorkersAdapters: editable.denyWorkersAdapters,
707
+ initialDenyWorkersModels: editable.denyWorkersModels,
548
708
  modelGroups,
549
709
  initialMode: rm.mode.mode,
550
710
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
@@ -560,6 +720,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
560
720
  steeringOptionsFor,
561
721
  reviewOverlay,
562
722
  reloadGuard,
723
+ stagedRouting,
563
724
  entry: opts.entry,
564
725
  initialJudge,
565
726
  judgeSeats: seats,
@@ -607,6 +768,11 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
607
768
  return {
608
769
  props,
609
770
  commit,
610
- renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, { adapters: editable.denyAdapters, models: editable.denyModels })),
771
+ renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, {
772
+ adapters: editable.denyAdapters,
773
+ models: editable.denyModels,
774
+ workersAdapters: editable.denyWorkersAdapters ?? [],
775
+ workersModels: editable.denyWorkersModels ?? [],
776
+ })),
611
777
  };
612
778
  }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, relative } from "node:path";
2
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { parseArgs } from "node:util";
@@ -9,10 +9,11 @@ import { allAdapters, formatDoctorAgeForInit, formatDoctorReport, initDoctorReus
9
9
  import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../../config/config.js";
10
10
  import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
11
11
  import { BANNER, kvRow, legend, rule, statusRow, title } from "../../brand.js";
12
- import { tickmarkrDir } from "../../graph/graph.js";
12
+ import { stateDirName, tickmarkrDir } from "../../graph/graph.js";
13
13
  import { orcaHostDetected } from "../../drivers/index.js";
14
+ import { WORKTREES_DIR } from "../../run/git.js";
14
15
  import { Journal } from "../../run/journal.js";
15
- import { runLockRunId, runStatusLine } from "../../run/lock.js";
16
+ import { runLockOwner, runLockRunId, runStatusLine } from "../../run/lock.js";
16
17
  import { doctor } from "./doctor.js";
17
18
  import { assembleFleetEditor } from "./fleet.js";
18
19
  const SCAFFOLD_SPEC = "tickmarkr.spec.md";
@@ -340,6 +341,191 @@ async function runInitWizard(cwd, input, output) {
340
341
  return null;
341
342
  return { overlay: result.overlay, installSkills: result.installSkills, installDocs: result.installDocs };
342
343
  }
344
+ function realpathSafe(p) {
345
+ try {
346
+ return realpathSync(p);
347
+ }
348
+ catch {
349
+ const parent = dirname(p);
350
+ if (parent === p)
351
+ return p;
352
+ return join(realpathSafe(parent), basename(p));
353
+ }
354
+ }
355
+ function isPathInside(parentDir, candidatePath) {
356
+ const resolvedParent = realpathSafe(resolve(parentDir));
357
+ const resolvedCandidate = realpathSafe(resolve(candidatePath));
358
+ const rel = relative(resolvedParent, resolvedCandidate);
359
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
360
+ }
361
+ /**
362
+ * The ONLY way the --remove path runs git: GIT_DIR, GIT_WORK_TREE and GIT_COMMON_DIR are stripped so the repository acted on
363
+ * is always cwd's own, and LC_ALL=C keeps stderr parseable. Throws execFileSync's error (status, stderr) unchanged.
364
+ */
365
+ function removeGit(cwd, args) {
366
+ const { GIT_DIR: _dir, GIT_WORK_TREE: _workTree, GIT_COMMON_DIR: _commonDir, ...env } = process.env;
367
+ return execFileSync("git", args, { cwd, env: { ...env, LC_ALL: "C" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
368
+ }
369
+ function isLinkedWorktree(cwd) {
370
+ try {
371
+ const gitDir = removeGit(cwd, ["rev-parse", "--git-dir"]).trim();
372
+ const gitCommonDir = removeGit(cwd, ["rev-parse", "--git-common-dir"]).trim();
373
+ return resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir);
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ }
379
+ function checkLiveLock(cwd) {
380
+ const localOwner = runLockOwner(cwd);
381
+ if (localOwner?.live)
382
+ return localOwner;
383
+ try {
384
+ const gitDir = removeGit(cwd, ["rev-parse", "--git-dir"]).trim();
385
+ const gitCommonDir = removeGit(cwd, ["rev-parse", "--git-common-dir"]).trim();
386
+ if (resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir)) {
387
+ const mainRepo = dirname(resolve(cwd, gitCommonDir));
388
+ const mainOwner = runLockOwner(mainRepo);
389
+ if (mainOwner?.live)
390
+ return mainOwner;
391
+ }
392
+ }
393
+ catch {
394
+ // not a git repo
395
+ }
396
+ return undefined;
397
+ }
398
+ /**
399
+ * Only git's discovery answer — exit 128, stderr `fatal: not a git repository (or any of the parent directories)` — means false.
400
+ * Every other failure (explicit bad GIT_DIR, invalid gitfile, permission, missing binary) refuses: unregistration could not be verified.
401
+ */
402
+ function isGitRepo(cwd) {
403
+ try {
404
+ removeGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
405
+ return true;
406
+ }
407
+ catch (err) {
408
+ const e = err;
409
+ const stderr = String(e.stderr ?? "").trim();
410
+ if (e.status === 128 && stderr.startsWith("fatal: not a git repository (or any of the parent directories)"))
411
+ return false;
412
+ throw new Error(`cannot remove: git repository probe failed: ${stderr || e.message || "unknown error"}`);
413
+ }
414
+ }
415
+ /** Runs git and throws `message` plus git's stderr on failure — a remove never proceeds past a failed step. */
416
+ function git(cwd, args, message) {
417
+ try {
418
+ return removeGit(cwd, args);
419
+ }
420
+ catch (err) {
421
+ const stderr = String(err.stderr ?? "").trim();
422
+ throw new Error(stderr ? `${message}: ${stderr}` : message);
423
+ }
424
+ }
425
+ function registeredWorktrees(cwd, worktreesDir) {
426
+ const out = git(cwd, ["worktree", "list", "--porcelain"], "cannot remove: git worktree list failed");
427
+ return out
428
+ .split("\n")
429
+ .filter((line) => line.startsWith("worktree "))
430
+ .map((line) => line.slice("worktree ".length).trim())
431
+ .filter((wt) => isPathInside(worktreesDir, wt));
432
+ }
433
+ function handleRemove(cwd) {
434
+ const owner = checkLiveLock(cwd);
435
+ if (owner?.live) {
436
+ const holder = typeof owner.pid === "number" ? `live pid ${owner.pid}` : (owner.pid ?? "unreadable payload");
437
+ throw new Error(`cannot remove: graph.lock held by ${holder}`);
438
+ }
439
+ if (isLinkedWorktree(cwd)) {
440
+ throw new Error("cannot remove from inside a linked worktree");
441
+ }
442
+ const notes = [];
443
+ const stateDir = join(cwd, stateDirName(cwd));
444
+ const worktreesDir = join(stateDir, WORKTREES_DIR);
445
+ // 1. Unregister every git worktree under stateDir/WORKTREES_DIR, and prove it, before deleting anything
446
+ if (isGitRepo(cwd)) {
447
+ for (const wt of registeredWorktrees(cwd, worktreesDir)) {
448
+ // double --force: git refuses a locked worktree (present or already deleted) with a single one
449
+ git(cwd, ["worktree", "remove", "--force", "--force", wt], `cannot remove: failed to unregister worktree ${wt}`);
450
+ }
451
+ git(cwd, ["worktree", "prune"], "cannot remove: git worktree prune failed");
452
+ const left = registeredWorktrees(cwd, worktreesDir);
453
+ if (left.length > 0) {
454
+ throw new Error(`cannot remove: worktrees still registered: ${left.join(", ")}`);
455
+ }
456
+ }
457
+ // 2. Delete .tickmarkr state directory
458
+ if (existsSync(stateDir)) {
459
+ rmSync(stateDir, { recursive: true, force: true });
460
+ notes.push(`deleted state directory ${stateDir}`);
461
+ }
462
+ // 3. Delete shipped skill directories at every host location
463
+ const targetSkillDirs = [
464
+ join(cwd, ".agents", "skills"),
465
+ join(cwd, ".claude", "skills"),
466
+ ];
467
+ for (const dir of targetSkillDirs) {
468
+ for (const skill of AGENT_SKILLS) {
469
+ const skillPath = join(dir, skill);
470
+ if (existsSync(skillPath)) {
471
+ rmSync(skillPath, { recursive: true, force: true });
472
+ notes.push(`deleted skill ${skillPath}`);
473
+ }
474
+ }
475
+ }
476
+ // 4. Delete marker-bounded guidance block in AGENTS.md and CLAUDE.md leaving every byte outside identical
477
+ const marked = new RegExp(`<!-- (?:tickmarkr|${LEGACY_PREFIX}):agent-docs begin -->[\\s\\S]*?`
478
+ + `<!-- (?:tickmarkr|${LEGACY_PREFIX}):agent-docs end -->`);
479
+ for (const doc of ["AGENTS.md", "CLAUDE.md"]) {
480
+ const docPath = join(cwd, doc);
481
+ if (existsSync(docPath)) {
482
+ const current = readFileSync(docPath, "utf8");
483
+ const m = current.match(marked);
484
+ if (m && m.index !== undefined) {
485
+ const before = current.slice(0, m.index);
486
+ const after = current.slice(m.index + m[0].length);
487
+ writeFileSync(docPath, before + after);
488
+ notes.push(`removed guidance block from ${docPath}`);
489
+ }
490
+ }
491
+ }
492
+ // 5. Delete scaffold spec only while it still equals the shipped template
493
+ const specPath = join(cwd, SCAFFOLD_SPEC);
494
+ if (existsSync(specPath)) {
495
+ const currentSpec = readFileSync(specPath, "utf8");
496
+ if (currentSpec === specTemplate()) {
497
+ rmSync(specPath, { force: true });
498
+ notes.push(`deleted scaffold spec ${specPath}`);
499
+ }
500
+ else {
501
+ notes.push(`kept edited spec ${specPath}`);
502
+ }
503
+ }
504
+ // 6. Query and print remaining branches and preserved refs
505
+ let branches = [];
506
+ try {
507
+ const out = removeGit(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/tickmarkr/"]);
508
+ branches = out.split("\n").map((b) => b.trim()).filter(Boolean);
509
+ }
510
+ catch {
511
+ // ignore
512
+ }
513
+ let preservedRefs = [];
514
+ try {
515
+ const out = removeGit(cwd, ["for-each-ref", "--format=%(refname)", "refs/tickmarkr/preserved/"]);
516
+ preservedRefs = out.split("\n").map((r) => r.trim()).filter(Boolean);
517
+ }
518
+ catch {
519
+ // ignore
520
+ }
521
+ for (const b of branches) {
522
+ notes.push(`left branch: ${b}`);
523
+ }
524
+ for (const r of preservedRefs) {
525
+ notes.push(`left ref: ${r}`);
526
+ }
527
+ return notes.join("\n");
528
+ }
343
529
  export async function init(argv, cwd = process.cwd(), io = {}) {
344
530
  let bannerEmitted = false;
345
531
  const emitBanner = () => {
@@ -348,6 +534,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
348
534
  process.stdout.write(BANNER);
349
535
  }
350
536
  };
537
+ // banner at START on the visual surface — every init path, not just the wizard, and never trailing the probe (operator report 2026-07-17)
538
+ emitBanner();
351
539
  const { values } = parseArgs({
352
540
  args: argv,
353
541
  options: {
@@ -357,13 +545,15 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
357
545
  docs: { type: "boolean" },
358
546
  fresh: { type: "boolean" },
359
547
  yes: { type: "boolean" },
548
+ remove: { type: "boolean" },
360
549
  },
361
550
  });
362
- // banner at START on the visual surface — every init path, not just the wizard, and never trailing the probe (operator report 2026-07-17)
363
- emitBanner();
364
551
  if (packageName(cwd) === "tickmarkr") {
365
552
  throw new Error("OBS-584: tickmarkr init refuses to run inside the tickmarkr source repository");
366
553
  }
554
+ if (values.remove) {
555
+ return handleRemove(cwd);
556
+ }
367
557
  const gdir = values["global-dir"] ?? globalConfigDir();
368
558
  mkdirSync(gdir, { recursive: true });
369
559
  const notes = [];
@@ -7,7 +7,7 @@ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
7
7
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
8
8
  import { classifyContextPath } from "../../compile/native.js";
9
9
  import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
10
- import { graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
10
+ import { chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
11
11
  import { renderAcceptanceItem } from "../../graph/schema.js";
12
12
  import { resolveRunMode } from "../../run/daemon.js";
13
13
  import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
@@ -364,11 +364,27 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
364
364
  : "";
365
365
  return { line: `${channelKey(reviewer)}${rotationNote} — reviewPolicy full`, cost: seatCost(reviewer) };
366
366
  };
367
+ // OBS-1018: depth and wave come from the same functions the daemon admits by, so the plan shows the
368
+ // critical path the run will actually follow at this concurrency.
369
+ const depths = chainDepth(g);
370
+ const waves = dispatchWaves(g, cfg.concurrency);
371
+ const byId = new Map(g.tasks.map((t) => [t.id, t]));
372
+ const chainLine = (t) => {
373
+ const wave = waves.get(t.id);
374
+ // Map absence never implies status: a pending task stays wave-less when a dependency is
375
+ // running/gated/failed/human, so render the real status and name the blocking deps.
376
+ const tail = wave !== undefined
377
+ ? `wave ${wave} at concurrency ${cfg.concurrency}`
378
+ : t.status !== "pending"
379
+ ? `no wave (${t.status})`
380
+ : `no wave (pending; blocked by ${t.deps.filter((d) => byId.get(d)?.status !== "done").map((d) => `${d}:${byId.get(d)?.status ?? "missing"}`).join(", ")})`;
381
+ return ` chain: depth ${depths.get(t.id)} · ${tail}`;
382
+ };
367
383
  const routed = [];
368
384
  for (const t of g.tasks) {
369
385
  const refusals = [...(oracleRefusals.get(t.id) ?? []), ...(inputRefusals.get(t.id) ?? [])];
370
386
  if (refusals.length) {
371
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! pre-dispatch refusal — ${refusals.join("; ")}`);
387
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! pre-dispatch refusal — ${refusals.join("; ")}`, chainLine(t));
372
388
  continue;
373
389
  }
374
390
  try {
@@ -382,7 +398,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
382
398
  const judge = judgeSeat(t);
383
399
  const review = reviewSeat(t, r.assignment);
384
400
  cost += est + judge.cost + review.cost;
385
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`);
401
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`, chainLine(t));
386
402
  const d = derivation(t.shape);
387
403
  if (d)
388
404
  lines.push(d);
@@ -406,7 +422,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
406
422
  if (!(e instanceof RoutingError))
407
423
  throw e;
408
424
  const msg = `${e.message}${exclusionReason(e.message)}`;
409
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}`);
425
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} !! ${msg}`, chainLine(t));
410
426
  const d = derivation(t.shape);
411
427
  if (d)
412
428
  lines.push(d);
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { loadConfig } from "../../config/config.js";
3
- import { parseDriverOverride, pickDriver } from "../../drivers/index.js";
3
+ import { classifyHost, parseDriverOverride, pickDriver, preflightHostDriver } from "../../drivers/index.js";
4
4
  import { loadGraph } from "../../graph/graph.js";
5
5
  import { formatSummary, runDaemon } from "../../run/daemon.js";
6
6
  import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
@@ -38,6 +38,8 @@ export async function resume(argv, cwd = process.cwd()) {
38
38
  throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
39
39
  }
40
40
  await assertRefsWritable(cwd, "resume");
41
+ const host = classifyHost();
42
+ preflightHostDriver(cfg, driverOverride, host);
41
43
  const narrate = narrationSink(runId);
42
44
  const s = await runDaemon(cwd, {
43
45
  runId,
@@ -45,7 +47,7 @@ export async function resume(argv, cwd = process.cwd()) {
45
47
  graphChanged,
46
48
  retryFailed,
47
49
  // bound to the same sink the daemon gets, so a driver-journaled recovery reaches this rail too
48
- driver: bindNarration(pickDriver(cfg, driverOverride), narrate),
50
+ driver: bindNarration(pickDriver(cfg, driverOverride, host), narrate),
49
51
  // v1.99 T2: the ONE narration sink — the quiet rail on a TTY, the raw journal formatter on a
50
52
  // pipe. A resumed run meets the same surface a fresh one does; printing the raw formatter here
51
53
  // would leave `resume` as the last place the old unfiltered dump survives. Bound to the run id