tickmarkr 2.6.1 → 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 (105) hide show
  1. package/README.md +16 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +1 -0
  6. package/dist/adapters/prompt.js +14 -6
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +12 -4
  9. package/dist/adapters/types.js +6 -0
  10. package/dist/cli/commands/approve.d.ts +11 -4
  11. package/dist/cli/commands/approve.js +82 -27
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +8 -2
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +87 -11
  16. package/dist/cli/commands/plan.js +13 -8
  17. package/dist/cli/commands/report.d.ts +2 -1
  18. package/dist/cli/commands/report.js +74 -8
  19. package/dist/cli/commands/resume.js +4 -2
  20. package/dist/cli/commands/status.js +43 -20
  21. package/dist/cli/help.d.ts +2 -0
  22. package/dist/cli/help.js +9 -2
  23. package/dist/compile/native.js +7 -0
  24. package/dist/config/config.d.ts +35 -2
  25. package/dist/config/config.js +86 -10
  26. package/dist/config/fleet-overlay.d.ts +13 -2
  27. package/dist/config/fleet-overlay.js +60 -0
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +35 -2
  31. package/dist/drivers/orca.js +222 -67
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/eval/canary.d.ts +2 -1
  35. package/dist/eval/canary.js +2 -2
  36. package/dist/eval/dispatch.js +1 -0
  37. package/dist/gates/acceptance.d.ts +9 -1
  38. package/dist/gates/acceptance.js +31 -4
  39. package/dist/gates/baseline.d.ts +32 -2
  40. package/dist/gates/baseline.js +111 -24
  41. package/dist/gates/cache.d.ts +8 -0
  42. package/dist/gates/cache.js +12 -2
  43. package/dist/gates/llm.d.ts +11 -4
  44. package/dist/gates/llm.js +40 -21
  45. package/dist/gates/review.d.ts +14 -1
  46. package/dist/gates/review.js +160 -34
  47. package/dist/gates/run-gates.d.ts +56 -4
  48. package/dist/gates/run-gates.js +358 -58
  49. package/dist/gates/test-manifest.d.ts +45 -1
  50. package/dist/gates/test-manifest.js +78 -12
  51. package/dist/graph/schema.d.ts +2 -0
  52. package/dist/graph/schema.js +2 -0
  53. package/dist/plan/scope.js +2 -2
  54. package/dist/route/preference.d.ts +20 -2
  55. package/dist/route/preference.js +48 -13
  56. package/dist/route/router.d.ts +12 -1
  57. package/dist/route/router.js +56 -24
  58. package/dist/run/consult.d.ts +15 -1
  59. package/dist/run/consult.js +18 -7
  60. package/dist/run/daemon.d.ts +38 -2
  61. package/dist/run/daemon.js +895 -192
  62. package/dist/run/git.d.ts +8 -0
  63. package/dist/run/git.js +14 -0
  64. package/dist/run/interactive-seed.d.ts +4 -0
  65. package/dist/run/interactive-seed.js +35 -9
  66. package/dist/run/journal.d.ts +152 -3
  67. package/dist/run/journal.js +551 -50
  68. package/dist/run/lease.d.ts +13 -0
  69. package/dist/run/lease.js +45 -0
  70. package/dist/run/merge.d.ts +3 -1
  71. package/dist/run/merge.js +3 -2
  72. package/dist/run/operator-summary.d.ts +3 -0
  73. package/dist/run/operator-summary.js +3 -1
  74. package/dist/run/protocol.d.ts +46 -1
  75. package/dist/run/protocol.js +14 -2
  76. package/dist/run/receipt-resolver.d.ts +22 -0
  77. package/dist/run/receipt-resolver.js +40 -1
  78. package/dist/run/repair-selection.d.ts +11 -1
  79. package/dist/run/repair-selection.js +17 -9
  80. package/dist/run/supervision.d.ts +7 -1
  81. package/dist/run/supervision.js +5 -2
  82. package/dist/run/wall-budget.d.ts +48 -0
  83. package/dist/run/wall-budget.js +280 -0
  84. package/dist/tui/cockpit/board.js +3 -3
  85. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  86. package/dist/tui/cockpit/decision-actions.js +55 -32
  87. package/dist/tui/cockpit/derive.js +13 -2
  88. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  89. package/dist/tui/cockpit/live-runtime.js +50 -3
  90. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  91. package/dist/tui/cockpit/run-cockpit.js +27 -2
  92. package/dist/tui/cockpit/run-view.d.ts +9 -2
  93. package/dist/tui/cockpit/run-view.js +66 -9
  94. package/dist/tui/cockpit/setup-cockpit.d.ts +6 -0
  95. package/dist/tui/cockpit/setup-cockpit.js +10 -3
  96. package/dist/tui/ink/fleet-app.d.ts +15 -3
  97. package/dist/tui/ink/fleet-app.js +91 -22
  98. package/package.json +3 -1
  99. package/schema/config.schema.json +825 -0
  100. package/skills/tickmarkr-loop/SKILL.md +15 -3
  101. package/skills/tickmarkr-overseer/SKILL.md +42 -0
  102. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +91 -0
  103. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  104. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  105. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -80,11 +80,49 @@ export declare function verifyManifestReport(opts: {
80
80
  report: TestReport | undefined;
81
81
  killedFile?: string;
82
82
  hangBudgetMs?: number;
83
+ /** Active elapsed time (detected suspend subtracted) versus raw wall service at the kill. */
84
+ hangActiveMs?: number;
85
+ hangWallMs?: number;
83
86
  }): ManifestVerdict;
84
87
  /** How much longer than its baseline measurement one file may legitimately run before it is a hang. */
85
88
  export declare const FILE_HANG_SLACK = 3;
86
89
  export declare const DEFAULT_FILE_HANG_BUDGET_MS = 60000;
87
90
  export declare function fileHangBudgetMs(file: string, baselineDurations?: readonly BaselineFileDuration[] | null, ceilingMs?: number, longestFile?: BaselineFileDuration | null): number;
91
+ /**
92
+ * OBS-953 (+add): a per-file hang budget counted in raw wall time charged a lid-close to the file that
93
+ * was running. Each poll compares how far the wall clock and the monotonic clock advanced since the
94
+ * last one. Wall advancing more than monotonic by over CLOCK_JUMP_SLACK_MS is a DETECTED discontinuity
95
+ * (`host-suspend`): its offset is subtracted from the active elapsed time of every file started before
96
+ * it ended. A poll that arrives overdue while both clocks advanced together is recorded as `unknown`
97
+ * and subtracts nothing — an ambiguous gap never excuses an active hang. Whether the host really slept
98
+ * is not provable from these clocks (a manually set clock jumps the same way); only the offset is.
99
+ */
100
+ export declare const CLOCK_JUMP_SLACK_MS = 1000;
101
+ export interface HostInterruption {
102
+ kind: "host-suspend" | "unknown";
103
+ /** Wall time of the previous poll and of the poll that observed the gap. */
104
+ from: number;
105
+ to: number;
106
+ wallMs: number;
107
+ monoMs: number;
108
+ /** The wall-over-monotonic offset of a detected suspend; 0 for an unknown gap. */
109
+ subtractedMs: number;
110
+ }
111
+ export interface HangClocks {
112
+ wall: () => number;
113
+ mono: () => number;
114
+ }
115
+ export declare const setHangClocksForTests: (clocks: HangClocks) => void;
116
+ export declare const resetHangClocksForTests: () => void;
117
+ /** A poll gap worth recording, or undefined for an on-time poll. */
118
+ export declare function classifyPollGap(prev: {
119
+ wall: number;
120
+ mono: number;
121
+ }, now: {
122
+ wall: number;
123
+ mono: number;
124
+ }, pollMs: number): HostInterruption | undefined;
125
+ export declare const runWithInterruptionSink: <T>(sink: (interruption: HostInterruption) => void, run: () => Promise<T>) => Promise<T>;
88
126
  export interface ManifestRunResult {
89
127
  evidenceReceipt: GateEvidenceReceipt;
90
128
  evidenceReceipts: GateEvidenceReceipt[];
@@ -94,6 +132,10 @@ export interface ManifestRunResult {
94
132
  report: TestReport | undefined;
95
133
  killedFile?: string;
96
134
  hangBudgetMs?: number;
135
+ /** A hang's active elapsed time (suspend subtracted) and its raw wall service, kept apart. */
136
+ hangActiveMs?: number;
137
+ hangWallMs?: number;
138
+ interruptions: HostInterruption[];
97
139
  /** The child's own pid (its process GROUP id too, since it is spawned detached) — for a caller
98
140
  * that wants to prove the group is really gone after a hang kill (`process.kill(-pid, 0)` throws). */
99
141
  pid?: number;
@@ -151,7 +193,9 @@ export declare function manifestFileCount(cmd: string, cwd: string): Promise<num
151
193
  * selection (OBS-1166: a selected screen's retry rediscovered the whole selection and refused). So the
152
194
  * retry is built from the UN-narrowed base command, its own `--` rule, the stranded files as the only
153
195
  * positional filters, and an `--exclude` of every completed file; the caller then requires discovery
154
- * to prove the exact retry set before launch. */
196
+ * to prove the exact retry set before launch. OBS-1180: Vitest matches an absolute filter against the
197
+ * module path it resolved through every symlink, so the filters are rooted at the canonical realpath of
198
+ * `cwd` — a symlinked worktree root otherwise filters to an empty discovery. */
155
199
  export declare function singleForkRetryCommand(base: string, cwd: string, stranded: readonly string[], completed: readonly string[]): string;
156
200
  /** One configured runner execution, and its own collection under the same arguments and environment.
157
201
  * The installed runner is trusted (R28 add.1 option B); the nonce catches stale artifacts, not forgery. */
@@ -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) {
@@ -492,10 +543,17 @@ function strandedSingleForkFiles(files, nonce, run) {
492
543
  * selection (OBS-1166: a selected screen's retry rediscovered the whole selection and refused). So the
493
544
  * retry is built from the UN-narrowed base command, its own `--` rule, the stranded files as the only
494
545
  * positional filters, and an `--exclude` of every completed file; the caller then requires discovery
495
- * to prove the exact retry set before launch. */
546
+ * to prove the exact retry set before launch. OBS-1180: Vitest matches an absolute filter against the
547
+ * module path it resolved through every symlink, so the filters are rooted at the canonical realpath of
548
+ * `cwd` — a symlinked worktree root otherwise filters to an empty discovery. */
496
549
  export function singleForkRetryCommand(base, cwd, stranded, completed) {
550
+ let root = cwd;
551
+ try {
552
+ root = realpathSync(cwd);
553
+ }
554
+ catch { /* unreadable cwd — the exact rediscovery below fails closed */ }
497
555
  const excluded = completed.map(f => `--exclude=${shq(f.replace(/[\\*?[\]{}()!+@]/g, "\\$&"))}`).join(" ");
498
- return `${base}${runnerInvocation(base, cwd).separator} ${stranded.map(f => shq(join(cwd, f))).join(" ")} ${excluded}`;
556
+ return `${base}${runnerInvocation(base, cwd).separator} ${stranded.map(f => shq(join(root, f))).join(" ")} ${excluded}`;
499
557
  }
500
558
  /** One configured runner execution, and its own collection under the same arguments and environment.
501
559
  * The installed runner is trusted (R28 add.1 option B); the nonce catches stale artifacts, not forgery. */
@@ -532,8 +590,10 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
532
590
  pollMs: 20,
533
591
  });
534
592
  let verdict = verifyManifestReport({ manifest: files, nonce, exitCode: invoked.exitCode,
535
- 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 });
536
595
  evidenceReceipts.push(...invoked.evidenceReceipts);
596
+ const interruptions = [...invoked.interruptions];
537
597
  const stranded = strandedSingleForkFiles(files, nonce, invoked);
538
598
  if (stranded) {
539
599
  const first = invoked.report;
@@ -556,8 +616,10 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
556
616
  longestFile: opts.longestFile, overallCeilingMs: opts.overallCeilingMs ?? DEFAULT_FILE_HANG_BUDGET_MS, pollMs: 20,
557
617
  });
558
618
  evidenceReceipts.push(...invoked.evidenceReceipts);
619
+ interruptions.push(...invoked.interruptions);
559
620
  verdict = verifyManifestReport({ manifest: stranded, nonce, exitCode: invoked.exitCode,
560
- 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 });
561
623
  // An all-skipped retry may contribute lifecycle accounting, but only the combined manifest
562
624
  // can establish executed success. Never rewrite either invocation's persisted certificate.
563
625
  if (verdict.pass || verdict.meta.noExecutedModules) {
@@ -589,11 +651,15 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
589
651
  + [...runnerErrors,
590
652
  stdoutTail.toString(), stderrTail.toString()].filter(Boolean).map(text => `\n${text}`).join("")
591
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
+ : "";
592
658
  return { evidenceReceipt, evidenceReceipts, pass: verdict.pass, kind: verdict.kind,
593
- details: verdict.details + diagnostics,
659
+ details: verdict.details + interrupted + diagnostics,
594
660
  classification: verdict.meta.classification,
595
661
  meta: { ...verdict.meta, nonce, manifest: files, manifestPath, listingCommand: invocation.listing, verification,
596
- ...(recovery ? { recovery, retryable: false } : {}),
662
+ ...(recovery ? { recovery, retryable: false } : {}), ...(interruptions.length ? { interruptions } : {}),
597
663
  spawnedCommand, processExit: invoked.exitCode, pid: invoked.pid, stdoutPath, stderrPath },
598
664
  exitCode: invoked.exitCode ?? -1, reportPath };
599
665
  }
@@ -4,12 +4,14 @@ export declare const GRAPH_ROUTING_MODES: readonly ["partner-led", "risk-based",
4
4
  export declare const STATUSES: readonly ["pending", "running", "gated", "failed", "done", "human"];
5
5
  export declare const GATE_NAMES: readonly ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
6
6
  export declare const TIERS: readonly ["cheap", "mid", "frontier"];
7
+ export declare const EFFORTS: readonly ["low", "medium", "high"];
7
8
  export declare const SPEC_SOURCES: readonly ["speckit", "gsd", "prd", "native"];
8
9
  export declare const ORACLES: readonly ["command", "test", "judge"];
9
10
  export type Shape = (typeof SHAPES)[number];
10
11
  export type TaskStatus = (typeof STATUSES)[number];
11
12
  export type GateName = (typeof GATE_NAMES)[number];
12
13
  export type Oracle = (typeof ORACLES)[number];
14
+ export type Effort = (typeof EFFORTS)[number];
13
15
  export type SpecSource = (typeof SPEC_SOURCES)[number];
14
16
  export declare const AcceptanceItemSchema: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
15
17
  oracle: z.ZodLiteral<"command">;
@@ -7,6 +7,8 @@ export const STATUSES = ["pending", "running", "gated", "failed", "done", "human
7
7
  export const GATE_NAMES = ["build", "test", "lint", "evidence", "scope", "acceptance", "review"];
8
8
  const MANDATORY_GATES = ["build", "test", "lint", "evidence", "scope"];
9
9
  export const TIERS = ["cheap", "mid", "frontier"];
10
+ // OBS-1182: launch effort — channel metadata beside tier, never part of model identity or channelKey.
11
+ export const EFFORTS = ["low", "medium", "high"];
10
12
  export const SPEC_SOURCES = ["speckit", "gsd", "prd", "native"];
11
13
  // v1.19 acceptance oracles: command (exit code), test (named test), judge (LLM, free-text rubric).
12
14
  // A plain string is the read-old/write-new compat form — semantically a judge oracle (spec §2).
@@ -169,7 +169,7 @@ function bindCandidate(candidate, channels) {
169
169
  `(doctor found: ${channels.map((ch) => `${ch.adapter}:${ch.model}`).join(", ") || "(nothing)"}) — ` +
170
170
  "re-run scope --preview and confirm again");
171
171
  }
172
- return { adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier };
172
+ return { adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier, ...(c.effort ? { effort: c.effort } : {}) };
173
173
  }
174
174
  // Adapter-level probes establish the current installation/auth state, but shipped adapters do not
175
175
  // return per-model verdicts from probe(). Preserve cached verdicts only where that fresh snapshot is
@@ -202,7 +202,7 @@ export async function scopeIntent(intentFile, repoRoot, options) {
202
202
  driver, name: `scope-${name}-${attempts}-${adapter.id}`, label: "SCOPE",
203
203
  keep: options.cfg.visibility.keepPanes === "forever",
204
204
  } : undefined;
205
- const draft = extractDraft(await runLlm(adapter, assignment.model, prompt, repoRoot, via));
205
+ const draft = extractDraft(await runLlm(adapter, assignment.model, prompt, repoRoot, via, undefined, assignment.effort));
206
206
  let tasks;
207
207
  try {
208
208
  tasks = validateDraft(draft);
@@ -11,6 +11,16 @@ export declare const routingModelProvider: typeof modelProvider;
11
11
  export declare const modelRouteIdentity: (model: string, fallback?: string) => string;
12
12
  export declare const channelRouteIdentity: (key: string, fallback?: string) => string;
13
13
  export declare function routingEntrySeatLines(cfg: TickmarkrConfig): string[];
14
+ export declare const observedIdentity: (health: Record<string, AuthHealth> | null | undefined, adapter: string, model: string) => string | undefined;
15
+ export declare const observedSeat: (health: Record<string, AuthHealth> | null | undefined, adapter: string, model: string) => {
16
+ adapter: string;
17
+ model: string;
18
+ identity: string;
19
+ } | {
20
+ adapter: string;
21
+ model: string;
22
+ identity?: undefined;
23
+ };
14
24
  export declare function excludedChannels(cfg: TickmarkrConfig, adapters: {
15
25
  id: string;
16
26
  }[] | string[], health: Record<string, AuthHealth>): {
@@ -59,6 +69,14 @@ export interface DenyPreferCollision {
59
69
  detail: string;
60
70
  disallowed: Disallowed;
61
71
  }
62
- export declare function preferEntryDenied(p: string, cfg: TickmarkrConfig): Disallowed | null;
63
- export declare function denyPreferCollisions(cfg: TickmarkrConfig, shapes?: Iterable<string>): DenyPreferCollision[];
72
+ export declare function preferEntryDenied(p: string, cfg: TickmarkrConfig, health?: Record<string, AuthHealth> | null): Disallowed | null;
73
+ export declare function denyPreferCollisions(cfg: TickmarkrConfig, shapes?: Iterable<string>, health?: Record<string, AuthHealth> | null): DenyPreferCollision[];
74
+ export interface DeadPoolEntry {
75
+ shape: string;
76
+ entry: string;
77
+ disallowed: Disallowed;
78
+ admitted: string[];
79
+ }
80
+ export declare function deadPoolEntries(cfg: TickmarkrConfig, shapes?: Iterable<string>, health?: Record<string, AuthHealth> | null): DeadPoolEntry[];
81
+ export declare function deadPoolEntryLine({ shape, entry, disallowed, admitted }: DeadPoolEntry): string;
64
82
  export declare function denyPreferCollisionLine({ kind, shape, detail, disallowed }: DenyPreferCollision): string;
@@ -47,6 +47,13 @@ export function routingEntrySeatLines(cfg) {
47
47
  add("routing.deny.workers.models", cfg.routing.deny?.workers?.models, ["worker"]);
48
48
  return lines;
49
49
  }
50
+ // OBS-1143: the probed identity doctor cached for adapter:model — the one discoverChannels routes
51
+ // with. Absent ⇒ unknown, and the deny matcher stays conservative (alias-family match).
52
+ export const observedIdentity = (health, adapter, model) => health?.[adapter]?.modelAuth?.[model]?.identity ?? health?.[adapter]?.modelIdentities?.[model];
53
+ export const observedSeat = (health, adapter, model) => {
54
+ const identity = observedIdentity(health, adapter, model);
55
+ return identity ? { adapter, model, identity } : { adapter, model };
56
+ };
50
57
  const adapterIds = (adapters) => typeof adapters[0] === "string" ? adapters : adapters.map((a) => a.id);
51
58
  export function excludedChannels(cfg, adapters, health) {
52
59
  const { allow, deny } = cfg.routing;
@@ -57,9 +64,7 @@ export function excludedChannels(cfg, adapters, health) {
57
64
  if (!health[id]?.installed || !health[id]?.authed)
58
65
  continue;
59
66
  for (const c of channelsFromConfig(id, cfg)) {
60
- const identity = health[id]?.modelAuth?.[c.model]?.identity ?? health[id]?.modelIdentities?.[c.model];
61
- const chan = identity ? { ...c, identity } : c;
62
- const d = disallowedBy(chan, cfg.routing);
67
+ const d = disallowedBy(observedSeat(health, c.adapter, c.model), cfg.routing);
63
68
  if (d)
64
69
  out.push({ key: channelKey(c), d });
65
70
  }
@@ -175,13 +180,20 @@ const disallowedFromPreferError = (msg) => {
175
180
  const m = msg.match(/prefer entry .+ is disallowed by routing\.(deny|allow) \(([^)]+)\)/);
176
181
  return m ? { by: m[1], entry: m[2] } : null;
177
182
  };
178
- export function preferEntryDenied(p, cfg) {
183
+ // OBS-1143: `health` is doctor's CACHED verdict (never a probe); its identities ride the channels
184
+ // route() reads them from, so the preflight answers exactly as the router would.
185
+ export function preferEntryDenied(p, cfg, health) {
179
186
  if (!cfg.routing.allow && !cfg.routing.deny)
180
187
  return null;
181
188
  const probe = structuredClone(cfg);
182
189
  probe.routing.map = { ...probe.routing.map, [PREFLIGHT_SHAPE]: { prefer: [p] } };
190
+ const observed = Object.keys(cfg.tiers).flatMap((id) => channelsFromConfig(id, cfg))
191
+ .flatMap((c) => {
192
+ const identity = observedIdentity(health, c.adapter, c.model);
193
+ return identity ? [{ ...c, identity }] : [];
194
+ });
183
195
  try {
184
- route(preflightTask, probe, []);
196
+ route(preflightTask, probe, observed);
185
197
  return null;
186
198
  }
187
199
  catch (e) {
@@ -194,7 +206,9 @@ export function preferEntryDenied(p, cfg) {
194
206
  // route — resume hands it the loaded graph's shape set, so a collision on a shape no resumed task
195
207
  // uses can no longer refuse the only crash-recovery path. Omitted ⇒ the whole routing map: doctor
196
208
  // audits the config itself, which has no graph to be scoped by.
197
- export function denyPreferCollisions(cfg, shapes) {
209
+ // OBS-1143: `health` (doctor's cached verdict) supplies each alias's observed identity; omitted or
210
+ // unrecorded ⇒ unknown, which stays conservative.
211
+ export function denyPreferCollisions(cfg, shapes, health) {
198
212
  if (!cfg.routing.allow && !cfg.routing.deny)
199
213
  return [];
200
214
  const inGraph = shapes === undefined ? undefined : new Set(shapes);
@@ -203,7 +217,7 @@ export function denyPreferCollisions(cfg, shapes) {
203
217
  if (inGraph && !inGraph.has(shape))
204
218
  continue;
205
219
  if (entry.pin) {
206
- const d = disallowedBy({ adapter: entry.pin.via, model: entry.pin.model }, cfg.routing);
220
+ const d = disallowedBy(observedSeat(health, entry.pin.via, entry.pin.model), cfg.routing);
207
221
  if (d) {
208
222
  out.push({
209
223
  kind: "pin",
@@ -214,20 +228,17 @@ export function denyPreferCollisions(cfg, shapes) {
214
228
  }
215
229
  }
216
230
  const prefer = entry.prefer ?? [];
217
- if (prefer.length && prefer.every((p) => preferEntryDenied(p, cfg) !== null)) {
231
+ if (prefer.length && prefer.every((p) => preferEntryDenied(p, cfg, health) !== null)) {
218
232
  out.push({
219
233
  kind: "prefer",
220
234
  shape,
221
235
  detail: prefer.join(" > "),
222
- disallowed: preferEntryDenied(prefer[0], cfg),
236
+ disallowed: preferEntryDenied(prefer[0], cfg, health),
223
237
  });
224
238
  }
225
239
  const pool = entry.pool;
226
240
  if (pool) {
227
- const denied = pool.channels.map((p) => {
228
- const i = p.indexOf(":");
229
- return disallowedBy({ adapter: p.slice(0, i), model: p.slice(i + 1) }, cfg.routing);
230
- });
241
+ const denied = pool.channels.map((p) => poolEntryDisallowed(p, cfg, health));
231
242
  // only a FULLY dead pool collides — a partial deny still leaves live members to route
232
243
  if (denied.every((d) => d !== null)) {
233
244
  out.push({ kind: "pool", shape, detail: pool.channels.join(" > "), disallowed: denied[0] });
@@ -236,6 +247,30 @@ export function denyPreferCollisions(cfg, shapes) {
236
247
  }
237
248
  return out;
238
249
  }
250
+ const poolEntryDisallowed = (p, cfg, health) => {
251
+ const i = p.indexOf(":");
252
+ return disallowedBy(observedSeat(health, p.slice(0, i), p.slice(i + 1)), cfg.routing);
253
+ };
254
+ // OBS-1144: every disallowed entry a pool carries, beside the admitted remainder the router routes
255
+ // instead — the router skips the entry; an empty remainder is the exhausted pool it refuses.
256
+ export function deadPoolEntries(cfg, shapes, health) {
257
+ if (!cfg.routing.allow && !cfg.routing.deny)
258
+ return [];
259
+ const inGraph = shapes === undefined ? undefined : new Set(shapes);
260
+ return Object.entries(cfg.routing.map).flatMap(([shape, entry]) => {
261
+ if (!entry.pool || (inGraph && !inGraph.has(shape)))
262
+ return [];
263
+ const judged = entry.pool.channels.map((p) => ({ p, d: poolEntryDisallowed(p, cfg, health) }));
264
+ const admitted = judged.filter(({ d }) => d === null).map(({ p }) => p);
265
+ return judged.flatMap(({ p, d }) => (d ? [{ shape, entry: p, disallowed: d, admitted }] : []));
266
+ });
267
+ }
268
+ export function deadPoolEntryLine({ shape, entry, disallowed, admitted }) {
269
+ const verdict = admitted.length
270
+ ? `skipped — routes the admitted remainder ${admitted.join(" > ")}`
271
+ : "the pool is exhausted — no admitted entry remains, so the shape is unroutable";
272
+ return `routing.map.${shape}.pool entry ${entry} is disallowed by routing.${disallowed.by} (${disallowed.entry}) — ${verdict}`;
273
+ }
239
274
  export function denyPreferCollisionLine({ kind, shape, detail, disallowed }) {
240
275
  const verb = kind === "pin" ? "is disallowed" : "fully disallowed";
241
276
  return `deny∩prefer: routing.map.${shape}.${kind} ${detail} ${verb} by routing.${disallowed.by} (${disallowed.entry}) — remove the ${disallowed.by} entry or adjust ${kind}`;
@@ -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 & {