tickmarkr 2.6.2 → 2.6.3

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 (63) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/commands/approve.d.ts +6 -3
  3. package/dist/cli/commands/approve.js +16 -4
  4. package/dist/cli/commands/doctor.d.ts +6 -2
  5. package/dist/cli/commands/fleet.js +46 -8
  6. package/dist/cli/commands/plan.js +13 -8
  7. package/dist/cli/commands/report.d.ts +2 -1
  8. package/dist/cli/commands/report.js +56 -6
  9. package/dist/cli/commands/status.js +19 -1
  10. package/dist/compile/native.js +7 -0
  11. package/dist/config/config.d.ts +17 -4
  12. package/dist/config/config.js +43 -6
  13. package/dist/config/fleet-overlay.d.ts +13 -3
  14. package/dist/config/fleet-overlay.js +12 -8
  15. package/dist/drivers/herdr.d.ts +12 -0
  16. package/dist/drivers/herdr.js +51 -0
  17. package/dist/drivers/orca.d.ts +9 -1
  18. package/dist/drivers/orca.js +29 -7
  19. package/dist/drivers/types.d.ts +2 -0
  20. package/dist/drivers/types.js +2 -2
  21. package/dist/gates/acceptance.d.ts +7 -0
  22. package/dist/gates/acceptance.js +27 -5
  23. package/dist/gates/baseline.d.ts +20 -1
  24. package/dist/gates/baseline.js +100 -20
  25. package/dist/gates/cache.d.ts +8 -0
  26. package/dist/gates/cache.js +12 -2
  27. package/dist/gates/llm.d.ts +6 -0
  28. package/dist/gates/llm.js +27 -8
  29. package/dist/gates/review.d.ts +6 -1
  30. package/dist/gates/review.js +122 -32
  31. package/dist/gates/run-gates.d.ts +54 -3
  32. package/dist/gates/run-gates.js +331 -45
  33. package/dist/gates/test-manifest.d.ts +42 -0
  34. package/dist/gates/test-manifest.js +69 -10
  35. package/dist/route/router.d.ts +12 -1
  36. package/dist/route/router.js +26 -9
  37. package/dist/run/consult.d.ts +3 -1
  38. package/dist/run/consult.js +4 -2
  39. package/dist/run/daemon.d.ts +2 -1
  40. package/dist/run/daemon.js +342 -77
  41. package/dist/run/interactive-seed.d.ts +4 -0
  42. package/dist/run/interactive-seed.js +35 -9
  43. package/dist/run/journal.d.ts +26 -0
  44. package/dist/run/journal.js +143 -15
  45. package/dist/run/lease.d.ts +13 -0
  46. package/dist/run/lease.js +45 -0
  47. package/dist/run/protocol.d.ts +15 -0
  48. package/dist/run/protocol.js +11 -1
  49. package/dist/run/receipt-resolver.d.ts +22 -0
  50. package/dist/run/receipt-resolver.js +40 -1
  51. package/dist/run/repair-selection.d.ts +11 -1
  52. package/dist/run/repair-selection.js +17 -9
  53. package/dist/run/wall-budget.d.ts +48 -0
  54. package/dist/run/wall-budget.js +280 -0
  55. package/dist/tui/cockpit/run-cockpit.js +2 -2
  56. package/dist/tui/cockpit/run-view.d.ts +2 -1
  57. package/dist/tui/cockpit/run-view.js +13 -9
  58. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  59. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  60. package/package.json +2 -1
  61. package/schema/config.schema.json +8 -1
  62. package/skills/tickmarkr-loop/SKILL.md +7 -1
  63. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -1,7 +1,9 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { performance } from "node:perf_hooks";
5
7
  import { TEST_REPORTER_SOURCE } from "./test-reporter.js";
6
8
  import { shq } from "../adapters/types.js";
7
9
  import { beginGateEvidence, redactGateOutput } from "./baseline.js";
@@ -175,13 +177,16 @@ export function readTestReport(path) {
175
177
  * module killed for a per-file hang never reaches its report.
176
178
  */
177
179
  export function verifyManifestReport(opts) {
178
- const { manifest, nonce, exitCode, report, killedFile, hangBudgetMs } = opts;
180
+ const { manifest, nonce, exitCode, report, killedFile, hangBudgetMs, hangActiveMs, hangWallMs } = opts;
179
181
  if (killedFile !== undefined) {
182
+ const service = hangActiveMs !== undefined && hangWallMs !== undefined
183
+ ? ` (${Math.round(hangActiveMs)}ms active of ${Math.round(hangWallMs)}ms wall)` : "";
180
184
  return {
181
185
  kind: "infra",
182
186
  pass: false,
183
- details: `infra hang: "${killedFile}" started and did not complete within its ${hangBudgetMs}ms budget — killed, its process group is gone`,
184
- meta: { classification: "infra", infra: true, kind: "hang", file: killedFile, hangBudgetMs },
187
+ details: `infra hang: "${killedFile}" started and did not complete within its ${hangBudgetMs}ms budget${service} — killed, its process group is gone`,
188
+ meta: { classification: "infra", infra: true, kind: "hang", file: killedFile, hangBudgetMs,
189
+ ...(hangActiveMs !== undefined ? { activeMs: hangActiveMs, wallMs: hangWallMs } : {}) },
185
190
  };
186
191
  }
187
192
  if (!report) {
@@ -334,6 +339,36 @@ export function fileHangBudgetMs(file, baselineDurations, ceilingMs = DEFAULT_FI
334
339
  const known = timings.find((d) => d.file === file)?.durationMs;
335
340
  return Math.min(ceiling, known === undefined ? FILE_HANG_SLACK * longest : Math.max(FILE_HANG_SLACK * known, longest));
336
341
  }
342
+ /**
343
+ * OBS-953 (+add): a per-file hang budget counted in raw wall time charged a lid-close to the file that
344
+ * was running. Each poll compares how far the wall clock and the monotonic clock advanced since the
345
+ * last one. Wall advancing more than monotonic by over CLOCK_JUMP_SLACK_MS is a DETECTED discontinuity
346
+ * (`host-suspend`): its offset is subtracted from the active elapsed time of every file started before
347
+ * it ended. A poll that arrives overdue while both clocks advanced together is recorded as `unknown`
348
+ * and subtracts nothing — an ambiguous gap never excuses an active hang. Whether the host really slept
349
+ * is not provable from these clocks (a manually set clock jumps the same way); only the offset is.
350
+ */
351
+ export const CLOCK_JUMP_SLACK_MS = 1_000;
352
+ const systemClocks = { wall: () => Date.now(), mono: () => performance.now() };
353
+ let hangClocks = systemClocks;
354
+ export const setHangClocksForTests = (clocks) => { hangClocks = clocks; };
355
+ export const resetHangClocksForTests = () => { hangClocks = systemClocks; };
356
+ /** A poll gap worth recording, or undefined for an on-time poll. */
357
+ export function classifyPollGap(prev, now, pollMs) {
358
+ const wallMs = now.wall - prev.wall;
359
+ const monoMs = now.mono - prev.mono;
360
+ const gap = { from: prev.wall, to: now.wall, wallMs, monoMs };
361
+ if (wallMs - monoMs > CLOCK_JUMP_SLACK_MS)
362
+ return { kind: "host-suspend", ...gap, subtractedMs: wallMs - monoMs };
363
+ if (monoMs > pollMs + CLOCK_JUMP_SLACK_MS)
364
+ return { kind: "unknown", ...gap, subtractedMs: 0 };
365
+ return undefined;
366
+ }
367
+ /** The detected suspend a file started at `startedAt` sat through. */
368
+ const suspendedSince = (interruptions, startedAt) => interruptions.reduce((sum, i) => sum + Math.min(i.subtractedMs, Math.max(0, i.to - Math.max(i.from, startedAt))), 0);
369
+ // The daemon journals each interruption where it ran (task gate, tip verify); no sink, no journal row.
370
+ const interruptionSink = new AsyncLocalStorage();
371
+ export const runWithInterruptionSink = (sink, run) => interruptionSink.run(sink, run);
337
372
  /** Supervise the configured command and poll the runner's atomic lifecycle snapshots. Every
338
373
  * timeout kills the detached process group, including descendants holding the output pipes. */
339
374
  export function runManifestedTest(cmd, cwd, opts) {
@@ -345,20 +380,35 @@ export function runManifestedTest(cmd, cwd, opts) {
345
380
  let pid;
346
381
  let killedFile;
347
382
  let hangBudgetMs;
383
+ let hangActiveMs;
384
+ let hangWallMs;
348
385
  let poll;
386
+ const interruptions = [];
387
+ const sink = interruptionSink.getStore();
388
+ let last = { wall: hangClocks.wall(), mono: hangClocks.mono() };
349
389
  const checkHang = (atCeiling = false) => {
390
+ const at = { wall: hangClocks.wall(), mono: hangClocks.mono() };
391
+ const gap = classifyPollGap(last, at, pollMs);
392
+ last = at;
393
+ if (gap) {
394
+ interruptions.push(gap);
395
+ sink?.(gap);
396
+ }
350
397
  const report = readTestReport(opts.reportPath);
351
398
  if (!report || report.nonce !== opts.nonce)
352
399
  return;
353
- const now = Date.now();
354
400
  for (const file of opts.manifest) {
355
401
  const startedAt = report.started[file];
356
402
  if (startedAt === undefined || file in report.completed)
357
403
  continue;
358
404
  const budget = fileHangBudgetMs(file, opts.baselineDurations, overallCeilingMs, opts.longestFile);
359
- if (now - startedAt >= budget || (atCeiling && budget === overallCeilingMs)) {
405
+ const wallMs = at.wall - startedAt;
406
+ const activeMs = wallMs - suspendedSince(interruptions, startedAt);
407
+ if (activeMs >= budget || (atCeiling && budget === overallCeilingMs)) {
360
408
  killedFile = file;
361
409
  hangBudgetMs = budget;
410
+ hangActiveMs = activeMs;
411
+ hangWallMs = wallMs;
362
412
  if (!atCeiling)
363
413
  controller.abort();
364
414
  return;
@@ -372,6 +422,7 @@ export function runManifestedTest(cmd, cwd, opts) {
372
422
  pid = childPid;
373
423
  // Neither the per-file clocks nor the command ceiling includes lease queue time.
374
424
  clearInterval(poll);
425
+ last = { wall: hangClocks.wall(), mono: hangClocks.mono() };
375
426
  poll = setInterval(checkHang, pollMs);
376
427
  },
377
428
  }).then((result) => {
@@ -380,7 +431,7 @@ export function runManifestedTest(cmd, cwd, opts) {
380
431
  evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt],
381
432
  exitCode: result.signalExit ? undefined : result.code,
382
433
  stdout: result.stdout, stderr: result.stderr,
383
- report: readTestReport(opts.reportPath), killedFile, hangBudgetMs, pid,
434
+ report: readTestReport(opts.reportPath), killedFile, hangBudgetMs, hangActiveMs, hangWallMs, interruptions, pid,
384
435
  };
385
436
  }).catch((error) => {
386
437
  if (error instanceof Error) {
@@ -539,8 +590,10 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
539
590
  pollMs: 20,
540
591
  });
541
592
  let verdict = verifyManifestReport({ manifest: files, nonce, exitCode: invoked.exitCode,
542
- report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs });
593
+ report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs,
594
+ hangActiveMs: invoked.hangActiveMs, hangWallMs: invoked.hangWallMs });
543
595
  evidenceReceipts.push(...invoked.evidenceReceipts);
596
+ const interruptions = [...invoked.interruptions];
544
597
  const stranded = strandedSingleForkFiles(files, nonce, invoked);
545
598
  if (stranded) {
546
599
  const first = invoked.report;
@@ -563,8 +616,10 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
563
616
  longestFile: opts.longestFile, overallCeilingMs: opts.overallCeilingMs ?? DEFAULT_FILE_HANG_BUDGET_MS, pollMs: 20,
564
617
  });
565
618
  evidenceReceipts.push(...invoked.evidenceReceipts);
619
+ interruptions.push(...invoked.interruptions);
566
620
  verdict = verifyManifestReport({ manifest: stranded, nonce, exitCode: invoked.exitCode,
567
- report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs });
621
+ report: invoked.report, killedFile: invoked.killedFile, hangBudgetMs: invoked.hangBudgetMs,
622
+ hangActiveMs: invoked.hangActiveMs, hangWallMs: invoked.hangWallMs });
568
623
  // An all-skipped retry may contribute lifecycle accounting, but only the combined manifest
569
624
  // can establish executed success. Never rewrite either invocation's persisted certificate.
570
625
  if (verdict.pass || verdict.meta.noExecutedModules) {
@@ -596,11 +651,15 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
596
651
  + [...runnerErrors,
597
652
  stdoutTail.toString(), stderrTail.toString()].filter(Boolean).map(text => `\n${text}`).join("")
598
653
  : "";
654
+ // The interruption is reported beside the verdict, never folded into it or into raw wall service.
655
+ const interrupted = interruptions.length
656
+ ? `\nhost interruptions: ${interruptions.map(i => `${i.kind} ${Math.round(i.wallMs)}ms wall / ${Math.round(i.monoMs)}ms monotonic, ${Math.round(i.subtractedMs)}ms subtracted`).join("; ")}`
657
+ : "";
599
658
  return { evidenceReceipt, evidenceReceipts, pass: verdict.pass, kind: verdict.kind,
600
- details: verdict.details + diagnostics,
659
+ details: verdict.details + interrupted + diagnostics,
601
660
  classification: verdict.meta.classification,
602
661
  meta: { ...verdict.meta, nonce, manifest: files, manifestPath, listingCommand: invocation.listing, verification,
603
- ...(recovery ? { recovery, retryable: false } : {}),
662
+ ...(recovery ? { recovery, retryable: false } : {}), ...(interruptions.length ? { interruptions } : {}),
604
663
  spawnedCommand, processExit: invoked.exitCode, pid: invoked.pid, stdoutPath, stderrPath },
605
664
  exitCode: invoked.exitCode ?? -1, reportPath };
606
665
  }
@@ -1,5 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../adapters/types.js";
2
- import { type TickmarkrConfig } from "../config/config.js";
2
+ import { type TickmarkrConfig, type Tier } from "../config/config.js";
3
3
  import type { Task } from "../graph/schema.js";
4
4
  import { type RoutingProfile } from "./profile.js";
5
5
  export type LadderStep = "retry" | "escalate" | "consult" | "human";
@@ -44,6 +44,17 @@ export declare function rankPreferredChannels<C extends ChannelSeat>(channels: r
44
44
  }): C[];
45
45
  /** Review preserves its established tier-then-cost order inside a preference band. */
46
46
  export declare const reviewPreferenceTieBreak: (a: BillingChannel, b: BillingChannel) => number;
47
+ export declare function resolvedFloor(task: Task, cfg: TickmarkrConfig): {
48
+ tier: Tier;
49
+ source: "task" | "config";
50
+ } | undefined;
51
+ export declare const taskPool: (task: Task, cfg: TickmarkrConfig) => {
52
+ mode: "any" | "ordered";
53
+ channels: string[];
54
+ } | undefined;
55
+ /** Park-reason suffix naming the pool an exhausted re-dispatch stayed inside; empty when unpooled. */
56
+ export declare const poolExhaustion: (task: Task, cfg: TickmarkrConfig) => string;
57
+ export declare const inTaskPool: (task: Task, cfg: TickmarkrConfig, key: string) => boolean;
47
58
  export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>, exploreCtx?: ExploreContext): Route;
48
59
  export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;
49
60
  export type ClimbPick = Assignment & {
@@ -65,6 +65,24 @@ export function rankPreferredChannels(channels, prefer = [], opts = {}) {
65
65
  }
66
66
  /** Review preserves its established tier-then-cost order inside a preference band. */
67
67
  export const reviewPreferenceTieBreak = (a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || Number(a.channel === "api") - Number(b.channel === "api");
68
+ // OBS-1185: the floor route() resolves, shared with plan's display — a task hint at or above the
69
+ // configured (or mode-compiled) floor wins; otherwise the configured floor stands.
70
+ export function resolvedFloor(task, cfg) {
71
+ const taskFloor = task.routingHints?.floor;
72
+ const configFloor = cfg.routing.floors[task.shape];
73
+ if (taskFloor && (!configFloor || TIER_RANK[taskFloor] >= TIER_RANK[configFloor]))
74
+ return { tier: taskFloor, source: "task" };
75
+ return configFloor ? { tier: configFloor, source: "config" } : undefined;
76
+ }
77
+ // OBS-1187: a declared map pool is the shape's closed candidate set for EVERY dispatch — demotion,
78
+ // retry-ban failover, tier climb, recycling and resume draw inside it, never from the open fleet.
79
+ export const taskPool = (task, cfg) => cfg.routing.map[task.shape]?.pool;
80
+ /** Park-reason suffix naming the pool an exhausted re-dispatch stayed inside; empty when unpooled. */
81
+ export const poolExhaustion = (task, cfg) => {
82
+ const pool = taskPool(task, cfg);
83
+ return pool ? ` — routing.map.${task.shape}.pool (${pool.mode}: ${pool.channels.join(", ")}) is exhausted` : "";
84
+ };
85
+ export const inTaskPool = (task, cfg, key) => taskPool(task, cfg)?.channels.includes(key) ?? true;
68
86
  function ladderFor(task, entry) {
69
87
  const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
70
88
  return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
@@ -199,9 +217,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
199
217
  }
200
218
  };
201
219
  const taskFloor = task.routingHints?.floor;
202
- const mapPinFloor = taskFloor && (!advisoryFloor || TIER_RANK[taskFloor] >= TIER_RANK[advisoryFloor])
203
- ? { tier: taskFloor, source: "task" }
204
- : advisoryFloor ? { tier: advisoryFloor, source: "config" } : undefined;
220
+ const mapPinFloor = resolvedFloor(task, cfg);
205
221
  const lintMapPinFloor = (tier, what = "pin") => {
206
222
  if (mapPinFloor && TIER_RANK[tier] < TIER_RANK[mapPinFloor.tier]) {
207
223
  lints.push(`${task.id} (${task.shape}): map ${what} routes ${tier}, below ${mapPinFloor.source} floor ${mapPinFloor.tier} — map ${what}s are supreme`);
@@ -364,8 +380,9 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
364
380
  eligible = spreadStatic(sortedStatic);
365
381
  spreadDecided = channelKey(eligible[0]) !== channelKey(sortedStatic[0]);
366
382
  }
367
- const bound = taskFloor && TIER_RANK[taskFloor] >= TIER_RANK[baseTier] ? `floor ${taskFloor} (task hint${src})` :
368
- floor ? `floor ${floor} (config floors)` :
383
+ const resolved = resolvedFloor(task, cfg);
384
+ const bound = resolved?.source === "task" ? `floor ${resolved.tier} (task hint${src})` :
385
+ resolved ? `floor ${resolved.tier} (config floors)` :
369
386
  "tier cheap (default)";
370
387
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
371
388
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
@@ -376,8 +393,8 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
376
393
  maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
377
394
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
378
395
  }
379
- function candidatePool(current, channels, tried, tierPredicate, exclude) {
380
- channels = withoutExcluded(channels, exclude);
396
+ function candidatePool(current, task, cfg, channels, tried, tierPredicate, exclude) {
397
+ channels = withoutExcluded(channels, exclude).filter((c) => inTaskPool(task, cfg, channelKey(c)));
381
398
  const triedKeys = new Set(tried);
382
399
  const triedIdentities = new Set(tried.map((key) => {
383
400
  const channel = channels.find((c) => channelKey(c) === key);
@@ -406,7 +423,7 @@ export function nextChannel(current, task, cfg, channels, tried, profile, exclud
406
423
  // profile-dependent filter. NO exploration bonus here (route():110 has one; a probe on the
407
424
  // failure path would spend a real retry). Absent profile ⇒ every score is 0 ⇒ third key
408
425
  // all-ties ⇒ the stable sort preserves the exact v1.7 candidate ORDER.
409
- const pool = candidatePool(current, channels, tried, (tier) => TIER_RANK[tier] >= TIER_RANK[current.tier], exclude);
426
+ const pool = candidatePool(current, task, cfg, channels, tried, (tier) => TIER_RANK[tier] >= TIER_RANK[current.tier], exclude);
410
427
  const candidates = rankFailoverCandidates(pool, task, cfg, profile);
411
428
  return candidates.length ? toAssignment(candidates[0]) : null;
412
429
  }
@@ -449,7 +466,7 @@ export function climbChannel(current, task, cfg, channels, tried, profile, exclu
449
466
  const pick = nextChannel(current, task, cfg, channels, tried, profile, exclude);
450
467
  return pick ? makeClimbPick(pick, false, "no higher tier") : null;
451
468
  }
452
- const higherPool = candidatePool(current, channels, tried, (tier) => TIER_RANK[tier] > TIER_RANK[current.tier], exclude);
469
+ const higherPool = candidatePool(current, task, cfg, channels, tried, (tier) => TIER_RANK[tier] > TIER_RANK[current.tier], exclude);
453
470
  if (higherPool.length > 0) {
454
471
  const candidates = rankFailoverCandidates(higherPool, task, cfg, profile);
455
472
  return makeClimbPick(toAssignment(candidates[0]), true);
@@ -1,4 +1,4 @@
1
- import { type WorkerAdapter } from "../adapters/types.js";
1
+ import { type AuthHealth, type WorkerAdapter } from "../adapters/types.js";
2
2
  import type { TickmarkrConfig } from "../config/config.js";
3
3
  import type { Effort } from "../graph/schema.js";
4
4
  import type { ExecutorDriver, Slot } from "../drivers/types.js";
@@ -56,5 +56,7 @@ export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: Work
56
56
  vendor?: string;
57
57
  channel?: "sub" | "api";
58
58
  tier?: string;
59
+ identity?: string;
59
60
  }>;
61
+ health?: Record<string, AuthHealth> | null;
60
62
  }): Promise<ConsultVerdict>;
@@ -6,7 +6,7 @@ import { bannerShell, paneDispatchCommand } from "../brand.js";
6
6
  import { dewrapPaneVerdict, extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
7
7
  import { classifyVerdictCause } from "../gates/verdict-cause.js";
8
8
  import { rankPreferredChannels } from "../route/role-pick.js";
9
- import { disallowedBy, routingModelProvider } from "../route/preference.js";
9
+ import { disallowedBy, observedSeat, routingModelProvider } from "../route/preference.js";
10
10
  import { sh } from "./git.js";
11
11
  import { redactSecrets } from "./redact.js";
12
12
  import { filterLlmTranscript } from "./stall.js";
@@ -148,6 +148,7 @@ export async function consult(d, cfg, adapters, driver, cwd, runDir,
148
148
  // judged against it only (never rebuilt from config, which would select installed-but-unauthed seats).
149
149
  // OBS-1182: onInvocation hears every launched seat — its identity, effort and outcome — failed seats
150
150
  // included, so a seat that never produced the verdict still reaches the journal.
151
+ // OBS-1186: health — doctor's cached verdict, the observed identity of the pinned seat (absent ⇒ unknown, conservative).
151
152
  opts = {}) {
152
153
  const n = ++consultSeq;
153
154
  const nonce = generateVerdictNonce();
@@ -219,8 +220,9 @@ opts = {}) {
219
220
  // Rank only exact live models from the daemon's role-scoped discovery pool.
220
221
  // The explicit pin remains a caller-owned fallback, outside strict preference selection.
221
222
  const live = (opts.channels ?? []).filter((c) => typeof c.model === "string");
223
+ // Ranking keeps each live channel's observed identity; the pin carries its cached identity too.
222
224
  const seats = rankPreferredChannels(live.filter((c) => disallowedBy(c, cfg.routing, "consult") === null), cfg.consult.prefer);
223
- seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
225
+ seats.push(observedSeat(opts.health, cfg.consult.adapter, cfg.consult.model));
224
226
  // v1.87 T2: a consult seat reads the code, so it passes through the operator's policy exactly like
225
227
  // a worker does. The filter runs AFTER the pin is pushed, so the final pinned seat is checked by
226
228
  // the same rule as every prefer entry — and disallowedBy carries the full deny grammar (adapter,
@@ -2,7 +2,7 @@ import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import { type ModeResolution, type RoutingMode, type TickmarkrConfig } from "../config/config.js";
3
3
  import { type DriverChoice } from "../drivers/index.js";
4
4
  import { type ExecutorDriver, type Slot } from "../drivers/types.js";
5
- import { type Baseline, type BaselineProvenance } from "../gates/baseline.js";
5
+ import { type Baseline, type BaselineProvenance, type GateEvidenceOptions } from "../gates/baseline.js";
6
6
  import type { GateResult } from "../gates/types.js";
7
7
  import { type RunGraph } from "../graph/schema.js";
8
8
  import { type PreserveProducer } from "./git.js";
@@ -244,6 +244,7 @@ export declare function verifyIntegrationTipCached(intWt: string, commands: Reco
244
244
  lastMergedTask?: string;
245
245
  baseline?: Baseline;
246
246
  signal?: AbortSignal;
247
+ evidence?: GateEvidenceOptions;
247
248
  }): Promise<boolean>;
248
249
  type SuitePidProbe = (pid: number) => number | undefined;
249
250
  /** Count full-suite roots in one process-table snapshot. The probes are arguments so the ownership