tickmarkr 1.53.0 → 1.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,8 +173,56 @@ More levers, all optional and absent-safe (absent config keeps default behavior)
173
173
  the routed model's window
174
174
  - `routing.sla` — per-shape latency expectations, surfaced as advisory plan lints against the
175
175
  learned performance profile
176
- - `run --quality` — raise every capability floor one band for that run (it never lowers one),
177
- disable exploration, and record the original and raised floor in routing provenance
176
+ - `run --quality` — compatibility alias for `run --mode partner-led` for that run; it disables
177
+ exploration and records the selected mode in routing provenance
178
+
179
+ ## Steering: routing modes, reviewer preferences, and reruns
180
+
181
+ ### Routing modes
182
+
183
+ `routing.mode` is a preset that compiles into floor assignments at config load time. The router never sees the mode itself; it receives resolved floors only.
184
+
185
+ Three routing modes are available:
186
+
187
+ - **`risk-based`** (default): byte-identical to pre-v1.51 routing. Absent `mode` key resolves as risk-based.
188
+ - **`partner-led`**: resolves every non-overridden shape to a `frontier` floor and disables exploration — use when quality is paramount and cost is secondary.
189
+ - **`staff-led`**: lowers each mode default by one tier (e.g., `implement` and `refactor` become `cheap` instead of `mid`) while keeping the preset floor for the integrity set (`plan`, `spec`, `migration`, `ui`) at `frontier`.
190
+
191
+ Explicit `routing.floors` entries beat mode-preset deltas and are linted during plan if they shadow the mode's delta; an explicit integrity floor below `frontier` is also linted. The mode is compiled once at config load and never consulted during routing.
192
+
193
+ ### Review preferences
194
+
195
+ `review.prefer` is an ordered list of reviewer seats for the cross-vendor code-review gate. Entries are matched by diversity (never the same vendor or model as the original worker), and routing reorders the available channels only — it does not admit unauthed or denied channels.
196
+
197
+ ```yaml
198
+ review:
199
+ prefer: [codex, kimi] # bare adapter: inherits model from the routed channel
200
+ prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model explicit
201
+ prefer: [codex, kimi:kimi-code/k3] # mixed: bare and explicit
202
+ ```
203
+
204
+ **Grammar**: review prefer entries may name a bare adapter (inheriting the model from the current channel) or an explicit `adapter:model` pair. Bare adapters rank every diversity-eligible channel for that adapter; explicit pairs rank one diversity-eligible channel.
205
+
206
+ ### Consult preferences
207
+
208
+ `consult.prefer` is a ranked failover list of seats for escalations on deadlock or gate stalls. Unlike review, a consult seat has no channel to inherit a model from, so entries **must be explicit `adapter:model` pairs**.
209
+
210
+ ```yaml
211
+ consult:
212
+ adapter: claude-code
213
+ model: fable
214
+ prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] # adapter:model ONLY
215
+ ```
216
+
217
+ The daemon walks the preference list to the first live adapter, then the pinned `consult.adapter:model` pair as the final fallback. Failed or unparseable verdicts fall to the next entry.
218
+
219
+ **Grammar**: consult prefer entries require `adapter:model` form — a bare adapter name is invalid and fails config load. Every entry must declare both the adapter and the model because a consult seat runs independently with no channel context.
220
+
221
+ ### Rerun control: --supersedes
222
+
223
+ `tickmarkr run --supersedes <prior-runId>` marks the current run as a rerun of a prior engagement. The current task graph is used for the rerun; compile it fresh first if the spec changed. The prior runId is recorded in the new journal, and the prior journal records the successor, for audit trails and change attribution.
224
+
225
+ Use this when you modify the spec or worker logic and want to mark an intentional rerun while preserving the relationship in both run journals.
178
226
 
179
227
  ## Model scoping and auth detection
180
228
 
@@ -17,6 +17,7 @@ export type RoutedAssignment = {
17
17
  /** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
18
18
  export declare function contextWindowLints(tasks: ReadonlyArray<Task>, assignments: ReadonlyArray<RoutedAssignment>, cfg: TickmarkrConfig, repoRoot: string): string[];
19
19
  export declare function seedPreferLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], overlayPreferShapes?: ReadonlySet<string>): string[];
20
+ export declare function preferEntryLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, mapShapes?: ReadonlySet<string>): string[];
20
21
  export declare function modelLints(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], opts?: {
21
22
  tty?: boolean;
22
23
  stateDir?: string;
@@ -95,6 +95,33 @@ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new
95
95
  }
96
96
  return lints;
97
97
  }
98
+ // v1.54 T3: dead-steering sweep — operator prefer entries (routing.map overlay shapes, review.prefer,
99
+ // consult.prefer) that can never match an installed channel are named at plan time; v1.53 T2 pins the
100
+ // no-match case as a silent no-op, which makes a typo invisible. Advisory only: reads config + doctor
101
+ // health (no live probes), never touches routing. Seed map prefers stay seedPreferLints' turf (auto-prefer
102
+ // routes around dead seeds) — mapShapes limits this sweep to operator-authored entries so a bare default
103
+ // fleet isn't double-linted. Entry grammar mirrors preferIndex: adapter | adapter:model (first colon).
104
+ export function preferEntryLints(cfg, health, mapShapes = new Set()) {
105
+ const lints = [];
106
+ const sweep = (surface, entries) => {
107
+ for (const entry of entries ?? []) {
108
+ const i = entry.indexOf(":");
109
+ const adapter = i < 0 ? entry : entry.slice(0, i);
110
+ const model = i < 0 ? undefined : entry.slice(i + 1);
111
+ if (!health[adapter]?.installed) {
112
+ lints.push(`${surface} '${entry}' names uninstalled adapter '${adapter}' — dead steering (entry can never match)`);
113
+ }
114
+ else if (model !== undefined && !(model in (cfg.tiers[adapter]?.models ?? {}))) {
115
+ lints.push(`${surface} '${entry}' names model '${model}' absent from ${adapter}'s configured channels — dead steering (entry can never match)`);
116
+ }
117
+ }
118
+ };
119
+ for (const shape of mapShapes)
120
+ sweep(`routing.map.${shape}.prefer`, cfg.routing.map[shape]?.prefer);
121
+ sweep("review.prefer", cfg.review.prefer);
122
+ sweep("consult.prefer", cfg.consult.prefer);
123
+ return lints;
124
+ }
98
125
  // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
99
126
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
100
127
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
@@ -77,6 +77,19 @@ const MODEL_PROBE_PROMPT = "Reply with exactly OK and nothing else.";
77
77
  const MODEL_PROBE_TIMEOUT_MS = 60000;
78
78
  // Auth-words only match when tied to a failure word; bare "auth"/"OAuth"/"authored" never fail (v1.27 T2).
79
79
  const AUTH_FAILURE_RE = /\b4\d\d\b|\bauth(?:entication|orization)?\s+(?:error|failed|failure|denied)|unauthori[sz]ed|forbidden|access denied|credit(?:s)?\s+(?:exhausted|error|denied)/i;
80
+ const PROBE_REASON_CAP = 240;
81
+ // v1.55 T3: the tail must open at a word boundary — a mid-word slice ("odel 'grok-…'") reads as
82
+ // corruption in operator-facing diagnostics. Input is already space-normalized, so " " is the only
83
+ // boundary. A tail that is one unbroken token keeps its mid-word cut rather than storing nothing.
84
+ function reasonTail(output) {
85
+ if (output.length <= PROBE_REASON_CAP)
86
+ return output;
87
+ const tail = output.slice(-PROBE_REASON_CAP);
88
+ if (output[output.length - PROBE_REASON_CAP - 1] === " ")
89
+ return tail;
90
+ const sp = tail.indexOf(" ");
91
+ return sp === -1 ? tail : tail.slice(sp + 1);
92
+ }
80
93
  function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TIMEOUT_MS) {
81
94
  // SIGKILL-timeout is not exit-1: report the budget, never the masked kill code (v1.27 T1).
82
95
  if (timedOut)
@@ -85,7 +98,7 @@ function probeFailure(code, stdout, stderr, timedOut, timeoutMs = MODEL_PROBE_TI
85
98
  // OBS-72: TAIL, not head — the error lands at the END of CLI output; a head slice stores only the
86
99
  // startup banner and hid the real "Not inside a trusted directory" failure for a day.
87
100
  return code !== 0 || QUOTA_RE.test(output) || AUTH_FAILURE_RE.test(output)
88
- ? output.slice(-240) || `probe exited ${code}`
101
+ ? reasonTail(output) || `probe exited ${code}`
89
102
  : undefined;
90
103
  }
91
104
  export const pendingAutoPreferKey = Symbol.for("tickmarkr.pendingAutoPrefer");
@@ -194,9 +194,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
194
194
  const health = cached;
195
195
  const term = openTerm(input, output);
196
196
  try {
197
- // step 1/5 — probe data
197
+ // step 1/6 — probe data
198
198
  const ageLine = formatAge(doctorAgeMs(cwd));
199
- term.frame(listFrame("step 1/5 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
199
+ term.frame(listFrame("step 1/6 · probe data", "enter continue · r refresh via doctor · esc/q quit", [`probe data: ${ageLine} (.tickmarkr/doctor.json)`], 0));
200
200
  for (;;) {
201
201
  const k = await term.key();
202
202
  if (isAbort(k))
@@ -207,7 +207,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
207
207
  if (isNext(k))
208
208
  break;
209
209
  }
210
- // step 2/5 — agent CLIs (space toggles adapter deny)
210
+ // step 2/6 — agent CLIs (space toggles adapter deny)
211
211
  const installed = adapters.filter((a) => health[a.id]?.installed);
212
212
  const adapterRows = () => installed.map((a) => {
213
213
  const h = health[a.id];
@@ -216,7 +216,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
216
216
  });
217
217
  const listLegend = "↑↓/jk move · space toggle · enter next · esc/q quit";
218
218
  let aCursor = 0;
219
- term.frame(listFrame("step 2/5 · agent CLIs", listLegend, adapterRows(), aCursor));
219
+ term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
220
220
  for (;;) {
221
221
  const k = await term.key();
222
222
  if (isAbort(k))
@@ -240,9 +240,9 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
240
240
  changed = true;
241
241
  }
242
242
  if (changed)
243
- term.frame(listFrame("step 2/5 · agent CLIs", listLegend, adapterRows(), aCursor));
243
+ term.frame(listFrame("step 2/6 · agent CLIs", listLegend, adapterRows(), aCursor));
244
244
  }
245
- // step 3/5 — models per enabled adapter (space toggles deny, t assigns tier from the row)
245
+ // step 3/6 — models per enabled adapter (space toggles deny, t assigns tier from the row)
246
246
  const enabledAdapters = installed.filter((a) => !isDeniedAdapter(a.id, editable));
247
247
  for (const a of enabledAdapters) {
248
248
  const unclassifiedAll = fleetUnclassifiedModels(cfg, health, adapters).filter((u) => u.adapter === a.id);
@@ -260,7 +260,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
260
260
  const denied = isDeniedModel(a.id, r.model, editable);
261
261
  return `${denied ? toggleInactive() : toggleActive()} ${r.model} ${tier?.tier ?? "???"} ${denied ? "denied" : "allowed"}`;
262
262
  });
263
- const title = `step 3/5 · models · ${a.id}`;
263
+ const title = `step 3/6 · models · ${a.id}`;
264
264
  const modelsLegend = "↑↓/jk move · space toggle · t tier · enter next · esc/q quit";
265
265
  let mCursor = 0;
266
266
  term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
@@ -305,7 +305,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
305
305
  term.frame(listFrame(title, modelsLegend, renderRows(rowsData()), mCursor));
306
306
  }
307
307
  }
308
- // step 4/5 — routing mode (selection is in-memory; the write happens only through the diff confirm).
308
+ // step 4/6 — routing mode (selection is in-memory; the write happens only through the diff confirm).
309
309
  // Candidate floor tables come from the ONE preset compiler via resolveRunMode — no mode math here.
310
310
  const modeCfgs = Object.fromEntries(ROUTING_MODES.map((m) => [m, m === rm.mode.mode ? rm : resolveRunMode(cwd, { flag: m, globalDir })]));
311
311
  const modeRows = () => ROUTING_MODES.map((m) => `${m === rm.mode.mode ? toggleActive() : " "} ${m.padEnd(11)} ${MODE_GLOSS[m]}`);
@@ -321,7 +321,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
321
321
  ...(changed.length ? changed.map((s) => ` ${s}: ${cur[s]} → ${next[s]}`) : [" (no floor changes)"]),
322
322
  ];
323
323
  };
324
- const modeTitle = "step 4/5 · routing mode";
324
+ const modeTitle = "step 4/6 · routing mode";
325
325
  const modeLegend = "↑↓/jk move · enter select · esc/q quit";
326
326
  let modeCursor = ROUTING_MODES.indexOf(rm.mode.mode);
327
327
  let selectedMode = rm.mode.mode;
@@ -341,7 +341,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
341
341
  term.frame(modeFrame());
342
342
  }
343
343
  }
344
- // step 5/5 — shape routing (typed entry for pin/tier/prefer from the highlighted row);
344
+ // step 5/6 — shape routing (typed entry for pin/tier/prefer from the highlighted row);
345
345
  // previews route under the SELECTED mode's resolved floors, never a floor edit of its own
346
346
  const channels = discoverChannels(cfg, adapters, health);
347
347
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
@@ -359,7 +359,7 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
359
359
  const auto = autoPrefer?.[shape] && !overlayShapes.has(shape) ? " (auto-prefer active)" : "";
360
360
  return `${shape} → ${now}${auto}`;
361
361
  });
362
- const shapesTitle = "step 5/5 · shape routing";
362
+ const shapesTitle = "step 5/6 · shape routing";
363
363
  // v1.52 T5: no map-tier editing action — routing.floors is the only band authority now.
364
364
  const shapesLegend = "↑↓/jk move · a auto · p pin · f prefer · enter next · esc/q quit";
365
365
  let sCursor = 0;
@@ -400,6 +400,43 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
400
400
  if (changed)
401
401
  term.frame(listFrame(shapesTitle, shapesLegend, shapeRows(), sCursor));
402
402
  }
403
+ // step 6/6 — steering (v1.54 T4): review.prefer + consult.prefer via the same typed-entry
404
+ // pattern as the shape prefer action; edits are in-memory only and land on the merged overlay
405
+ // below, so they ride the ONE diff-confirm + reload-guard write path (never a second writer)
406
+ const STEER_KEYS = ["review", "consult"];
407
+ const initialSteering = {
408
+ review: cfg.review.prefer?.slice(),
409
+ consult: cfg.consult.prefer?.slice(),
410
+ };
411
+ const steering = structuredClone(initialSteering);
412
+ const steerRows = () => STEER_KEYS.map((k) => `${k}.prefer → ${steering[k]?.join(", ") ?? "(none)"}`);
413
+ const steerTitle = "step 6/6 · steering";
414
+ const steerLegend = "↑↓/jk move · f prefer · enter next · esc/q quit";
415
+ let stCursor = 0;
416
+ term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
417
+ for (;;) {
418
+ const k = await term.key();
419
+ if (isAbort(k))
420
+ return QUIT;
421
+ if (isNext(k))
422
+ break;
423
+ let changed = false;
424
+ const moved = moveCursor(k, stCursor, STEER_KEYS.length);
425
+ if (moved !== stCursor) {
426
+ stCursor = moved;
427
+ changed = true;
428
+ }
429
+ else if (k.name === "f") {
430
+ const which = STEER_KEYS[stCursor];
431
+ const grammar = which === "consult" ? "adapter:model seats" : "adapters or adapter:model";
432
+ const raw = await term.askTyped(`${which}.prefer (comma-separated ${grammar}, empty clears)> `);
433
+ const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
434
+ steering[which] = list.length ? list : undefined;
435
+ changed = true;
436
+ }
437
+ if (changed)
438
+ term.frame(listFrame(steerTitle, steerLegend, steerRows(), stCursor));
439
+ }
403
440
  // review — unified diff + typed confirm (the ONLY write path; the mode screen funnels through here)
404
441
  const before = currentRepoOverlayText(cwd);
405
442
  const existing = readOverlayFile(repoOverlayPath(cwd));
@@ -409,8 +446,25 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
409
446
  const merged = fleetEditableEquals(initial, editable)
410
447
  ? structuredClone(existing)
411
448
  : fleetRepoOverlayFromDelta(initial, editable, existing);
449
+ // steering lands on the merged object and serializes via repoOverlayYaml's non-fleet
450
+ // passthrough; clearing removes the prefer key (and an emptied parent block) from the overlay
451
+ let steeringChanged = false;
452
+ for (const key of STEER_KEYS) {
453
+ if (JSON.stringify(steering[key]) === JSON.stringify(initialSteering[key]))
454
+ continue;
455
+ steeringChanged = true;
456
+ const block = { ...merged[key] };
457
+ if (steering[key])
458
+ block.prefer = steering[key];
459
+ else
460
+ delete block.prefer;
461
+ if (Object.keys(block).length)
462
+ merged[key] = block;
463
+ else
464
+ delete merged[key];
465
+ }
412
466
  const after = withModeLine(repoOverlayYaml(merged, provenanceMap(editable)), writeMode);
413
- if ((!modeChanged && fleetEditableEquals(initial, editable)) || before === after) {
467
+ if ((!modeChanged && !steeringChanged && fleetEditableEquals(initial, editable)) || before === after) {
414
468
  return "fleet: no overlay changes (empty diff)";
415
469
  }
416
470
  const diff = unifiedYamlDiff(before, after, repoOverlayPath(cwd));
@@ -1,9 +1,9 @@
1
1
  import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, servableExclusions, servabilityLine } from "../../adapters/registry.js";
2
- import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
2
+ import { formatModelAuthLine, contextWindowLints, modelLints, preferEntryLints, ttyVisual } from "../../adapters/model-lints.js";
3
3
  import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
4
4
  import { parseArgs } from "node:util";
5
5
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
6
- import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
6
+ import { DEFAULT_CONFIG, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
7
7
  import { loadGraph } from "../../graph/graph.js";
8
8
  import { resolveRunMode } from "../../run/daemon.js";
9
9
  import { excludedChannels, exclusionLine } from "../../route/preference.js";
@@ -121,6 +121,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
121
121
  lints.push(`${role}: ${sel.adapter}:${sel.model} not installed — that gate/consult will fail closed`);
122
122
  }
123
123
  lints.push(...modelLints(cfg, health, adapters, { tty: ttyVisual() })); // health may be pre-v1.5/probeAll-fallback — no-detection branch covers both
124
+ // v1.54 T3: dead-steering sweep — advisory only, renders with the routing lints, never alters routing.
125
+ lints.push(...preferEntryLints(cfg, health, overlayPreferShapes(cwd)));
124
126
  if (cfg.review.required && channels.length && !fleetCanCrossVendorReview(channels)) {
125
127
  lints.push("review: no cross-vendor reviewer pair in fleet — set review.required: false to waive");
126
128
  }
@@ -174,6 +174,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
174
174
  adapter: z.ZodString;
175
175
  model: z.ZodString;
176
176
  stallMinutes: z.ZodNumber;
177
+ prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
177
178
  }, z.core.$strip>;
178
179
  visibility: z.ZodObject<{
179
180
  llm: z.ZodEnum<{
@@ -155,7 +155,18 @@ export const TickmarkrConfigSchema = z.object({
155
155
  // v1.53 T2: prefer — ordered reviewer preference (entry grammar: adapter | adapter:model, same as
156
156
  // routing.map.prefer). Reorders diversity-eligible channels only; never widens or narrows eligibility.
157
157
  review: z.object({ complexityThreshold: z.number(), required: z.boolean(), prefer: z.array(z.string()).optional() }),
158
- consult: z.object({ adapter: z.string(), model: z.string(), stallMinutes: z.number().positive() }),
158
+ // v1.54 T1: prefer ranked consult seat failover. Entries MUST be adapter:model (unlike
159
+ // review.prefer's adapter|adapter:model grammar): a consult seat has no channel to inherit a
160
+ // model from, so a bare adapter is meaningless and fails config load fail-closed. The pinned
161
+ // adapter/model pair below stays the final fallback seat; an absent list is byte-identical.
162
+ consult: z.object({
163
+ adapter: z.string(),
164
+ model: z.string(),
165
+ stallMinutes: z.number().positive(),
166
+ prefer: z
167
+ .array(z.string().regex(/^[^:]+:.+$/, "consult.prefer entries must be adapter:model — a bare adapter has no model to run the seat with"))
168
+ .optional(),
169
+ }),
159
170
  visibility: z.object({
160
171
  llm: z.enum(["pane", "headless"]),
161
172
  keepPanes: z.enum(["run", "attempt", "forever"]),
@@ -479,7 +490,11 @@ export function configTemplate(overlay) {
479
490
  # review: { complexityThreshold: 7, required: true, prefer: [codex:gpt-5.6-sol, kimi] }
480
491
  # # prefer: ordered reviewer seat preference (adapter | adapter:model); ranks
481
492
  # # diversity-eligible channels only — never admits a same-vendor/same-model reviewer
482
- # consult: { adapter: claude-code, model: fable, stallMinutes: 15 }
493
+ # consult: { adapter: claude-code, model: fable, stallMinutes: 15, prefer: [codex:gpt-5.6-sol, kimi:kimi-code/k3] }
494
+ # # prefer: ranked consult seat failover — entries are adapter:model ONLY (a
495
+ # # consult seat has no channel to inherit a model from). The seat walks the
496
+ # # list to the first live adapter; a failed seat or unparseable verdict falls
497
+ # # to the next entry; the pinned adapter/model above is always the final seat
483
498
  # cost: # v1.20 REC-02 — OPTIONAL price table for the usage/cost report. Absent ⇒ every
484
499
  # # channel reports "not measurable" (never a crash or a fake $0). Two economics,
485
500
  # # never conflated: API = tokens × per-Mtok rate; sub = flat plan amortized over a
@@ -28,4 +28,7 @@ export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: Work
28
28
  keep?: boolean;
29
29
  onSlot?: (slot: Slot) => void;
30
30
  runId?: string;
31
+ channels?: Array<{
32
+ adapter: string;
33
+ }>;
31
34
  }): Promise<ConsultVerdict>;
@@ -92,6 +92,8 @@ let consultSeq = 0;
92
92
  export async function consult(d, cfg, adapters, driver, cwd, runDir,
93
93
  // T2 ownership contract: runId names the consult pane canonically (tickmarkr:consult:<task>:0:<runId>)
94
94
  // via SlotOpts.owned; without it the legacy gatePaneName shape survives (non-daemon callers/tests).
95
+ // v1.54 T1: channels — the daemon's doctor-filtered live channel list; prefer-seat liveness is
96
+ // judged against it only (never rebuilt from config, which would select installed-but-unauthed seats).
95
97
  opts = {}) {
96
98
  const n = ++consultSeq;
97
99
  const nonce = generateVerdictNonce();
@@ -99,54 +101,82 @@ opts = {}) {
99
101
  mkdirSync(dir, { recursive: true });
100
102
  const promptFile = join(dir, `${d.taskId}-${n}.md`);
101
103
  writeFileSync(promptFile, buildDossierPrompt(d, nonce));
102
- const adapter = getAdapter(cfg.consult.adapter, adapters);
103
- let out;
104
- if (cfg.visibility.llm === "headless") {
105
- const r = await sh(adapter.headlessCommand(promptFile, cfg.consult.model), cwd, cfg.consult.stallMinutes * 60_000);
106
- out = r.stdout + r.stderr;
107
- out = augmentFakeVerdictOutput(adapter, out, nonce);
108
- }
109
- else {
110
- // T8: role-first pane name for fleet visibility (consult · T2); consultSeq stays on the dossier artifact only
111
- const slot = await driver.slot(cwd, gatePaneName("consult", d.taskId), {
112
- label: `CONSULT ${d.taskId}`,
113
- ...(opts.runId ? { owned: { role: "consult", taskId: d.taskId, attempt: 0, runId: opts.runId } } : {}),
114
- });
115
- opts.onSlot?.(slot);
116
- const scriptPath = join(dir, `${d.taskId}-${n}.sh`);
117
- // OBS-50: visible consult panes get the brand banner; headless path above stays banner-free (machine-parsed stdout)
118
- writeFileSync(scriptPath, [
119
- "export BASH_SILENCE_DEPRECATION_WARNING=1",
120
- bannerShell(),
121
- adapter.headlessCommand(promptFile, cfg.consult.model),
122
- gateExitTrailer(nonce),
123
- ].join("\n"));
124
- await driver.run(slot, paneDispatchCommand(scriptPath));
125
- // nonce-suffixed exit only: a dossier quoting tickmarkr's own exit literal must not false-complete.
126
- await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, cfg.consult.stallMinutes * 60_000, { regex: true });
127
- out = await driver.read(slot, 300);
128
- if (!opts.keep)
129
- await driver.close(slot);
104
+ // One seat = the WHOLE invoke-and-parse unit, both visibility branches (OBS-69 class: a headless-only
105
+ // failover would leave the production pane path hard-failing on seat one). null = no parseable verdict.
106
+ const invokeSeat = async (seatAdapter, seatModel, seatIdx) => {
107
+ const adapter = getAdapter(seatAdapter, adapters);
108
+ let out;
109
+ if (cfg.visibility.llm === "headless") {
110
+ const r = await sh(adapter.headlessCommand(promptFile, seatModel), cwd, cfg.consult.stallMinutes * 60_000);
111
+ out = r.stdout + r.stderr;
112
+ }
113
+ else {
114
+ // T8: role-first pane name for fleet visibility (consult · T2); consultSeq stays on the dossier artifact only
115
+ const slot = await driver.slot(cwd, gatePaneName("consult", d.taskId), {
116
+ label: `CONSULT ${d.taskId}`,
117
+ ...(opts.runId ? { owned: { role: "consult", taskId: d.taskId, attempt: 0, runId: opts.runId } } : {}),
118
+ });
119
+ opts.onSlot?.(slot);
120
+ const scriptPath = join(dir, `${d.taskId}-${n}${seatIdx > 0 ? `-s${seatIdx}` : ""}.sh`);
121
+ // OBS-50: visible consult panes get the brand banner; headless path above stays banner-free (machine-parsed stdout)
122
+ writeFileSync(scriptPath, [
123
+ "export BASH_SILENCE_DEPRECATION_WARNING=1",
124
+ bannerShell(),
125
+ adapter.headlessCommand(promptFile, seatModel),
126
+ gateExitTrailer(nonce),
127
+ ].join("\n"));
128
+ try {
129
+ await driver.run(slot, paneDispatchCommand(scriptPath));
130
+ // nonce-suffixed exit only: a dossier quoting tickmarkr's own exit literal must not false-complete.
131
+ await driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, cfg.consult.stallMinutes * 60_000, { regex: true });
132
+ out = await driver.read(slot, 300);
133
+ }
134
+ finally {
135
+ // a failed seat must not leak its pane before the next seat opens under the same name
136
+ if (!opts.keep)
137
+ await driver.close(slot);
138
+ }
139
+ }
130
140
  out = augmentFakeVerdictOutput(adapter, out, nonce);
131
- }
132
- const v = extractVerdictJson(out, nonce);
133
- if (!v || !ACTIONS.includes(v.action)) {
134
- return { action: "human", notes: "consult verdict unparseable failing safe to human" };
135
- }
136
- // fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
137
- // empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
138
- // and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
139
- // zero-match expansion as channel-level reroute.
140
- const raw = v.excludeAdapter;
141
- const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
142
- const reason = typeof v.reason === "string" ? v.reason : undefined;
143
- const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
144
- const notes = String(v.notes ?? guidance ?? reason ?? "");
145
- return {
146
- action: v.action,
147
- notes,
148
- ...(reason ? { reason } : {}),
149
- ...(guidance ? { guidance } : {}),
150
- ...(excludeAdapter ? { excludeAdapter } : {}),
141
+ const v = extractVerdictJson(out, nonce);
142
+ if (!v || !ACTIONS.includes(v.action))
143
+ return null;
144
+ // fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
145
+ // empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
146
+ // and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
147
+ // zero-match expansion as channel-level reroute.
148
+ const raw = v.excludeAdapter;
149
+ const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
150
+ const reason = typeof v.reason === "string" ? v.reason : undefined;
151
+ const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
152
+ const notes = String(v.notes ?? guidance ?? reason ?? "");
153
+ return {
154
+ action: v.action,
155
+ notes,
156
+ ...(reason ? { reason } : {}),
157
+ ...(guidance ? { guidance } : {}),
158
+ ...(excludeAdapter ? { excludeAdapter } : {}),
159
+ };
151
160
  };
161
+ // v1.54 T1: ranked seat failover. Walk consult.prefer (adapter:model entries) to the first entry
162
+ // whose adapter is in the live channel set; a failed seat or unparseable verdict falls to the next;
163
+ // the pinned consult.adapter/model is always the final seat. No channels provided (non-daemon
164
+ // callers) ⇒ empty live set ⇒ pin only, byte-identical to pre-v1.54 behavior. Failover changes
165
+ // only WHICH seat answers — per-seat parsing and the fail-safe human action below are untouched.
166
+ const live = new Set((opts.channels ?? []).map((c) => c.adapter));
167
+ const seats = (cfg.consult.prefer ?? [])
168
+ .map((entry) => ({ adapter: entry.slice(0, entry.indexOf(":")), model: entry.slice(entry.indexOf(":") + 1) }))
169
+ .filter((s) => live.has(s.adapter));
170
+ seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
171
+ for (const [i, seat] of seats.entries()) {
172
+ try {
173
+ const v = await invokeSeat(seat.adapter, seat.model, i);
174
+ if (v)
175
+ return v;
176
+ }
177
+ catch {
178
+ // failed seat (unknown adapter, dead driver/pane, shell error) — fall to the next entry
179
+ }
180
+ }
181
+ return { action: "human", notes: "consult verdict unparseable — failing safe to human" };
152
182
  }
@@ -13,6 +13,7 @@ export interface RunOptions {
13
13
  globalDir?: string;
14
14
  mode?: RoutingMode;
15
15
  narrate?: (event: JournalEvent) => void;
16
+ exit?: (code: number) => void;
16
17
  }
17
18
  export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
18
19
  export interface ResolvedRunMode {
@@ -106,6 +106,9 @@ export async function runDaemon(repoRoot, opts = {}) {
106
106
  // subprocess, so the optional-chain open below is a no-op there). Cosmetic-only: any failure is
107
107
  // swallowed (never affects the run); the operator closes a surviving watch pane.
108
108
  const lock = acquireRunLock(repoRoot, runId);
109
+ // v1.54 T2: declared before the try so the finally can always deregister (a throw before
110
+ // registration leaves it undefined — the guard below covers that path).
111
+ let onTermination;
109
112
  try {
110
113
  let graph = loadGraph(repoRoot);
111
114
  // v1.51 T2: the routing mode resolves BEFORE any routing input is built — run flag > spec front-matter
@@ -120,6 +123,67 @@ export async function runDaemon(repoRoot, opts = {}) {
120
123
  // No preview — the daemon honors routing.learned:off and gets undefined; this snapshot is immutable
121
124
  // for the run, so this run's own telemetry never feeds back into its own routing.
122
125
  const profile = loadRoutingProfile(repoRoot, cfg);
126
+ // v1.54 T2 (OBS-71): signal reaper — a killed daemon closes its own panes and releases the lock.
127
+ // Every slot this run opens stays in liveSlots until closed: the worker path opens through
128
+ // trackedDriver below, and gates/consults receive trackedDriver as THEIR driver, so their pane
129
+ // opens/closes keep the ledger exact. Termination then closes exactly what is still live — and a
130
+ // slot closed once (task-done, quota reroute, gate self-clean) can never be closed twice.
131
+ const liveSlots = new Set();
132
+ const closeSlot = async (s) => {
133
+ if (!liveSlots.delete(s))
134
+ return; // already closed — never twice
135
+ await driver.close(s);
136
+ };
137
+ const trackedDriver = {
138
+ id: driver.id,
139
+ interactive: driver.interactive,
140
+ slot: async (cwd, name, o) => { const s = await driver.slot(cwd, name, o); liveSlots.add(s); return s; },
141
+ run: (s, cmd) => driver.run(s, cmd),
142
+ waitOutput: (s, p, ms, o) => driver.waitOutput(s, p, ms, o),
143
+ waitAgentStatus: (s, st, ms) => driver.waitAgentStatus(s, st, ms),
144
+ status: (s) => driver.status(s),
145
+ read: (s, n) => driver.read(s, n),
146
+ ...(driver.sendKey ? { sendKey: driver.sendKey.bind(driver) } : {}),
147
+ notify: (m, o) => driver.notify(m, o),
148
+ close: closeSlot,
149
+ worktree: (r, b, base) => driver.worktree(r, b, base),
150
+ };
151
+ // Termination (SIGINT/SIGTERM): close every live slot, reconcile owned panes against an EMPTY
152
+ // desired set (herdr panes not in memory; panesToClose spares foreign names, watch panes, and
153
+ // other runs' panes by construction), release the run lock, then exit. Journal-silent by design —
154
+ // no run-end/interrupted event, so stop-amend-resume keeps resuming. keepPanes:"forever" (the
155
+ // keep-everything debug override) preserves panes but still releases the lock and exits.
156
+ let termSignal;
157
+ let abortRun = () => { };
158
+ const aborted = new Promise((_, reject) => { abortRun = reject; });
159
+ aborted.catch(() => { });
160
+ let reaping = false;
161
+ const exit = opts.exit ?? ((code) => process.exit(code));
162
+ onTermination = (sig) => {
163
+ termSignal = sig;
164
+ void (async () => {
165
+ if (!reaping) {
166
+ reaping = true;
167
+ if (cfg.visibility.keepPanes !== "forever") {
168
+ for (const s of liveSlots) { // closeSlot only deletes the element being visited — safe during Set iteration
169
+ try {
170
+ await closeSlot(s);
171
+ }
172
+ catch { /* cosmetic — reconcile is the backstop */ }
173
+ }
174
+ try {
175
+ await driver.reconcile?.(new Set(), runId);
176
+ }
177
+ catch { /* cosmetic — visibility is never a gate */ }
178
+ }
179
+ releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
180
+ }
181
+ abortRun(new Error(`terminated by ${sig}`));
182
+ exit(sig === "SIGINT" ? 130 : 143);
183
+ })();
184
+ };
185
+ process.on("SIGINT", onTermination);
186
+ process.on("SIGTERM", onTermination);
123
187
  const journal = opts.resume ? Journal.open(repoRoot, runId, opts.narrate) : Journal.create(repoRoot, runId, opts.narrate);
124
188
  const branchEvent = opts.resume
125
189
  ? [...journal.read()].reverse().find((e) => (e.event === "run-start" || e.event === "run-end" || e.event === "merge") && typeof e.data.branch === "string")
@@ -346,9 +410,11 @@ export async function runDaemon(repoRoot, opts = {}) {
346
410
  journalTail: JSON.stringify(journal.read().slice(-20)),
347
411
  transcript: transcript.slice(-8000),
348
412
  diff: diffOrFeedback, gates,
349
- }, cfg, adapters, driver, repoRoot, journal.dir,
413
+ }, cfg, adapters, trackedDriver, repoRoot, journal.dir,
350
414
  // D-07: consult panes self-clean when the verdict is read (keepLlm) — only "forever" keeps them.
351
- { keep: keepLlm, onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined, runId });
415
+ // v1.54 T1: channels = this run's doctor-filtered live list consult.prefer seat liveness
416
+ // is judged against it, never rebuilt from config (installed-but-unauthed seats would stall).
417
+ { keep: keepLlm, onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined, runId, channels });
352
418
  };
353
419
  // returns true → continue attempting, false → task is terminal (parked)
354
420
  // trigger (why the consult ran) is threaded in so the decompose/human park keeps its cause —
@@ -497,7 +563,7 @@ export async function runDaemon(repoRoot, opts = {}) {
497
563
  const adapter = getAdapter(assignment.adapter, adapters);
498
564
  // VIS-04: workers share one role tab. T2: `owned` names the pane canonically (ownership contract);
499
565
  // the legacy name stays the fallback for drivers without owned handling (subprocess spies).
500
- const slot = await driver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, { group: "workers", owned: { role: "worker", taskId: t.id, attempt, runId } });
566
+ const slot = await trackedDriver.slot(wt, `${t.id}-worker-${assignment.adapter}-a${attempt}-${runTag}`, { group: "workers", owned: { role: "worker", taskId: t.id, attempt, runId } });
501
567
  const sessionId = retryMode === "resume" ? priorSession.id : slot.name;
502
568
  const icmd = retryMode === "resume"
503
569
  ? adapter.resumeCommand(sessionId, promptFile, assignment.model)
@@ -677,7 +743,7 @@ export async function runDaemon(repoRoot, opts = {}) {
677
743
  if (keepOpen && (finished || driver.id !== "subprocess"))
678
744
  keptSlots.push(slot);
679
745
  else
680
- await driver.close(slot);
746
+ await closeSlot(slot);
681
747
  // SPEND-01: usage from the harness's own cwd-keyed structured store, read POST-HOC from disk —
682
748
  // `wt` is this task's private worktree, so the path is unique; the read is sliced to records
683
749
  // stamped at/after this attempt's dispatch instant. Never the harvested pane text, never the
@@ -731,7 +797,7 @@ export async function runDaemon(repoRoot, opts = {}) {
731
797
  if (idx >= 0) {
732
798
  keptSlots.splice(idx, 1);
733
799
  try {
734
- await driver.close(slot);
800
+ await closeSlot(slot);
735
801
  }
736
802
  catch { /* cosmetic — reconcile is the backstop */ }
737
803
  }
@@ -810,7 +876,7 @@ export async function runDaemon(repoRoot, opts = {}) {
810
876
  commands, baseline, channels, adapters, cfg,
811
877
  via: cfg.visibility.llm === "pane"
812
878
  ? {
813
- driver,
879
+ driver: trackedDriver,
814
880
  // D-07: judge/review panes self-clean when their verdict is read (keepLlm) — only "forever" keeps them.
815
881
  keep: keepLlm,
816
882
  onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined,
@@ -862,7 +928,7 @@ export async function runDaemon(repoRoot, opts = {}) {
862
928
  const idx = keptSlots.indexOf(slot);
863
929
  if (idx >= 0) {
864
930
  keptSlots.splice(idx, 1);
865
- await driver.close(slot);
931
+ await closeSlot(slot);
866
932
  }
867
933
  }
868
934
  await reconcile({ spareLiveLlm: true }); // task-done is a terminal event — sweep this task's leftovers
@@ -901,6 +967,10 @@ export async function runDaemon(repoRoot, opts = {}) {
901
967
  };
902
968
  const inflight = new Map();
903
969
  while (true) {
970
+ // v1.54 T2: a signal that landed while nothing was racing `aborted` (empty inflight window)
971
+ // must still stop the run before it can dispatch more work or write run-end.
972
+ if (termSignal)
973
+ throw new Error(`terminated by ${termSignal}`);
904
974
  const ready = readyTasks(graph)
905
975
  .filter((t) => !inflight.has(t.id))
906
976
  .slice(0, Math.max(0, concurrency - inflight.size));
@@ -918,14 +988,14 @@ export async function runDaemon(repoRoot, opts = {}) {
918
988
  }
919
989
  if (inflight.size === 0)
920
990
  break;
921
- await Promise.race(inflight.values());
991
+ await Promise.race([...inflight.values(), aborted]); // aborted rejects on termination — unwinds the run
922
992
  }
923
993
  // D-07: the sweep now closes only what's LEFT in keptSlots — done-closed worker slots were removed
924
994
  // (no double-close) and self-cleaned LLM/consult panes were never added under keepLlm:false. This
925
995
  // leaves failed/parked attempts' worker slots, which keep their failure context until run end.
926
996
  if (cfg.visibility.keepPanes === "run") {
927
997
  for (const s of keptSlots)
928
- await driver.close(s); // panes persist for the run's duration, then clean up
998
+ await closeSlot(s); // panes persist for the run's duration, then clean up
929
999
  }
930
1000
  saveGraph(repoRoot, graph);
931
1001
  const byStatus = (s) => graph.tasks.filter((t) => t.status === s).map((t) => t.id);
@@ -986,6 +1056,13 @@ export async function runDaemon(repoRoot, opts = {}) {
986
1056
  return summary;
987
1057
  }
988
1058
  finally {
1059
+ // v1.54 T2: deregister on EVERY exit (normal run end, throw, termination unwind) — the daemon
1060
+ // test suite runs runDaemon dozens of times in one process; a leaked handler would close a
1061
+ // later run's slots.
1062
+ if (onTermination) {
1063
+ process.removeListener("SIGINT", onTermination);
1064
+ process.removeListener("SIGTERM", onTermination);
1065
+ }
989
1066
  releaseRunLock(repoRoot);
990
1067
  }
991
1068
  }
package/dist/run/git.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
4
  import { shq } from "../adapters/types.js";
5
5
  import { tickmarkrDir } from "../graph/graph.js";
6
6
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
@@ -110,11 +110,40 @@ export async function createWorktree(repo, branch, baseRef) {
110
110
  // (real directory, wrong/broken symlink) is restored to the provisioned link. Idempotent: an
111
111
  // already-correct link is a no-op. Returns whether dest is the provisioned link: provisioning ignores
112
112
  // the result; the pre-gate caller treats false as a named environmental park, never a masked test red.
113
+ // OBS-78: target repos typically ignore `node_modules/` — a directories-only pattern that does NOT
114
+ // match the provisioned SYMLINK, so a worker staging with `git add -A` commits the link and burns an
115
+ // attempt on the scope gate. Write `node_modules` (no slash: matches the link too) into the exclude
116
+ // file git actually consults for this worktree. Per-worktree `info/` is ignored in linked worktrees
117
+ // (gitrepository-layout redirects it), so resolve through the `.git` gitfile + `commondir` indirection
118
+ // to the common git dir. Local git metadata only — the target repository's .gitignore is never edited.
119
+ // Idempotent (appends only when no entry exists) and best-effort, like the link itself.
120
+ function excludeNodeModules(dir) {
121
+ try {
122
+ let gitDir = join(dir, ".git");
123
+ if (lstatSync(gitDir).isFile()) { // linked worktree: .git is a gitfile naming the real git dir
124
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(gitDir, "utf8"));
125
+ if (!m)
126
+ return;
127
+ gitDir = resolve(dir, m[1]);
128
+ }
129
+ const commondir = join(gitDir, "commondir");
130
+ if (existsSync(commondir))
131
+ gitDir = resolve(gitDir, readFileSync(commondir, "utf8").trim());
132
+ const exclude = join(gitDir, "info", "exclude");
133
+ const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
134
+ if (/^node_modules$/m.test(current))
135
+ return; // already excluded — repeated re-asserts add nothing
136
+ mkdirSync(join(gitDir, "info"), { recursive: true });
137
+ writeFileSync(exclude, current + (current && !current.endsWith("\n") ? "\n" : "") + "node_modules\n");
138
+ }
139
+ catch { /* not a git checkout (bare tmpdir in tests) — never fail provisioning over the exclude */ }
140
+ }
113
141
  export function linkNodeModules(repo, dir, { force = false } = {}) {
114
142
  const src = join(repo, "node_modules");
115
143
  const dest = join(dir, "node_modules");
116
144
  if (!existsSync(src))
117
145
  return true; // nothing provisioned to link — correct state is no link (OBS-27 best-effort)
146
+ excludeNodeModules(dir); // OBS-78: the link must never be stageable in this worktree
118
147
  try {
119
148
  if (lstatSync(dest).isSymbolicLink() && readlinkSync(dest) === src)
120
149
  return true; // already the provisioned link
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.53.0",
3
+ "version": "1.55.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,10 @@ Use one of:
50
50
 
51
51
  After sending, **confirm delivery** by reading the target pane and verifying the message landed (input empty, agent status `working`, or notification acknowledged). Never report "briefed" or "relayed" without read-back confirmation.
52
52
 
53
+ ## Dedicated consultant tab rule
54
+
55
+ When spawning consultants (agents gathering synthesis input for decisions like SCOPER analysis or architectural reviews), create them in a DEDICATED tab separate from the ORCHESTRATOR tab. This ensures that when the orchestrator stands down, the consultant panes persist and their assessments remain available for review and reference.
56
+
53
57
  ## Stand-down (mission end and retirement)
54
58
 
55
59
  - **Orchestrator, on terminal state** (green, failed, or parked), after the record commit and operator notification: stop every monitor and background task you started, print one final stand-down line, and leave NOTHING queued in your input box. A finished session with an armed watcher or pre-filled input is a loaded gun — a retired v1.40 orchestrator sat idle with "merge … tag, publish" unsent in its input; one stray Enter would have shipped a duplicate release.