tickmarkr 2.6.0 → 2.6.2

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 (103) hide show
  1. package/README.md +14 -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 +3 -1
  6. package/dist/adapters/prompt.js +21 -3
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +13 -4
  9. package/dist/adapters/types.js +16 -0
  10. package/dist/cli/commands/approve.d.ts +5 -1
  11. package/dist/cli/commands/approve.js +66 -23
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +2 -0
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +71 -8
  16. package/dist/cli/commands/plan.js +7 -3
  17. package/dist/cli/commands/report.js +18 -2
  18. package/dist/cli/commands/resume.js +4 -2
  19. package/dist/cli/commands/status.js +37 -22
  20. package/dist/cli/help.d.ts +4 -0
  21. package/dist/cli/help.js +11 -2
  22. package/dist/config/config.d.ts +20 -0
  23. package/dist/config/config.js +47 -8
  24. package/dist/config/fleet-overlay.d.ts +1 -0
  25. package/dist/config/fleet-overlay.js +56 -0
  26. package/dist/drivers/orca.d.ts +26 -1
  27. package/dist/drivers/orca.js +199 -61
  28. package/dist/eval/canary.d.ts +2 -1
  29. package/dist/eval/canary.js +2 -2
  30. package/dist/eval/dispatch.js +1 -0
  31. package/dist/gates/acceptance.d.ts +2 -1
  32. package/dist/gates/acceptance.js +7 -2
  33. package/dist/gates/baseline.d.ts +12 -1
  34. package/dist/gates/baseline.js +11 -4
  35. package/dist/gates/cache.d.ts +3 -1
  36. package/dist/gates/cache.js +10 -3
  37. package/dist/gates/llm.d.ts +5 -4
  38. package/dist/gates/llm.js +17 -14
  39. package/dist/gates/review.d.ts +8 -0
  40. package/dist/gates/review.js +47 -15
  41. package/dist/gates/run-gates.d.ts +6 -1
  42. package/dist/gates/run-gates.js +34 -17
  43. package/dist/gates/test-manifest.d.ts +14 -0
  44. package/dist/gates/test-manifest.js +33 -6
  45. package/dist/graph/graph.d.ts +6 -2
  46. package/dist/graph/graph.js +15 -4
  47. package/dist/graph/schema.d.ts +2 -0
  48. package/dist/graph/schema.js +2 -0
  49. package/dist/plan/scope.js +2 -2
  50. package/dist/route/preference.d.ts +20 -2
  51. package/dist/route/preference.js +48 -13
  52. package/dist/route/role-pick.d.ts +16 -0
  53. package/dist/route/role-pick.js +15 -0
  54. package/dist/route/router.d.ts +14 -0
  55. package/dist/route/router.js +39 -16
  56. package/dist/run/consult.d.ts +13 -1
  57. package/dist/run/consult.js +19 -14
  58. package/dist/run/daemon.d.ts +46 -2
  59. package/dist/run/daemon.js +964 -190
  60. package/dist/run/git.d.ts +44 -1
  61. package/dist/run/git.js +103 -5
  62. package/dist/run/host-health.d.ts +20 -0
  63. package/dist/run/host-health.js +64 -0
  64. package/dist/run/journal.d.ts +126 -3
  65. package/dist/run/journal.js +418 -36
  66. package/dist/run/merge.d.ts +3 -1
  67. package/dist/run/merge.js +3 -2
  68. package/dist/run/operator-state.d.ts +24 -2
  69. package/dist/run/operator-state.js +41 -5
  70. package/dist/run/operator-summary.d.ts +3 -0
  71. package/dist/run/operator-summary.js +3 -1
  72. package/dist/run/protocol.d.ts +31 -1
  73. package/dist/run/protocol.js +3 -1
  74. package/dist/run/stall.d.ts +38 -2
  75. package/dist/run/stall.js +276 -6
  76. package/dist/run/supervision.d.ts +7 -1
  77. package/dist/run/supervision.js +5 -2
  78. package/dist/tui/cockpit/board.d.ts +1 -1
  79. package/dist/tui/cockpit/board.js +30 -22
  80. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  81. package/dist/tui/cockpit/decision-actions.js +55 -32
  82. package/dist/tui/cockpit/derive.d.ts +2 -0
  83. package/dist/tui/cockpit/derive.js +17 -2
  84. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  85. package/dist/tui/cockpit/live-runtime.js +50 -3
  86. package/dist/tui/cockpit/live-store.d.ts +1 -0
  87. package/dist/tui/cockpit/live-store.js +31 -8
  88. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  89. package/dist/tui/cockpit/run-cockpit.js +28 -2
  90. package/dist/tui/cockpit/run-view.d.ts +11 -7
  91. package/dist/tui/cockpit/run-view.js +69 -15
  92. package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
  93. package/dist/tui/cockpit/setup-cockpit.js +6 -3
  94. package/dist/tui/ink/fleet-app.d.ts +15 -3
  95. package/dist/tui/ink/fleet-app.js +91 -22
  96. package/package.json +2 -1
  97. package/schema/config.schema.json +818 -0
  98. package/skills/tickmarkr-loop/SKILL.md +8 -2
  99. package/skills/tickmarkr-overseer/SKILL.md +85 -6
  100. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -0
  101. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  102. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  103. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -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}`;
@@ -0,0 +1,16 @@
1
+ import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "../adapters/types.js";
2
+ import { type TickmarkrConfig } from "../config/config.js";
3
+ import type { PreferenceRole } from "./preference.js";
4
+ export { preferIndex, rankPreferredChannels, reviewPreferenceTieBreak } from "./router.js";
5
+ export type RolePick = {
6
+ ok: true;
7
+ channel: BillingChannel;
8
+ } | {
9
+ ok: false;
10
+ reason: "missing-prefer" | "no-eligible-preferred-channel";
11
+ };
12
+ /** Strict role selection: no task, author, pin, or default seat is invented. */
13
+ export declare function pickRole(role: PreferenceRole, cfg: TickmarkrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>, opts?: {
14
+ excludeVendors?: ReadonlySet<string>;
15
+ eligible?: (channel: BillingChannel) => boolean;
16
+ }): RolePick;
@@ -0,0 +1,15 @@
1
+ import { discoverChannels } from "../adapters/registry.js";
2
+ import { rankPreferredChannels, reviewPreferenceTieBreak } from "./router.js";
3
+ export { preferIndex, rankPreferredChannels, reviewPreferenceTieBreak } from "./router.js";
4
+ /** Strict role selection: no task, author, pin, or default seat is invented. */
5
+ export function pickRole(role, cfg, adapters, health, opts = {}) {
6
+ // Worker preferences belong to shapes; judge currently has only an explicit pin.
7
+ const prefer = role === "review" ? cfg.review.prefer : role === "consult" ? cfg.consult.prefer : undefined;
8
+ if (!prefer?.length)
9
+ return { ok: false, reason: "missing-prefer" };
10
+ const pool = discoverChannels(cfg, adapters, health, role).filter((c) => !opts.excludeVendors?.has(c.vendor) && (opts.eligible?.(c) ?? true));
11
+ const channel = rankPreferredChannels(pool, prefer, {
12
+ tieBreak: role === "review" ? reviewPreferenceTieBreak : undefined,
13
+ })[0];
14
+ return channel ? { ok: true, channel } : { ok: false, reason: "no-eligible-preferred-channel" };
15
+ }
@@ -31,6 +31,19 @@ export declare class RoutingError extends Error {
31
31
  constructor(msg: string);
32
32
  }
33
33
  export declare function marginalCostRank(c: BillingChannel): number;
34
+ type ChannelSeat = {
35
+ adapter: string;
36
+ model: string;
37
+ };
38
+ /** Shared adapter | adapter:model grammar; unmatched channels follow every declared entry. */
39
+ export declare function preferIndex(c: ChannelSeat, prefer?: readonly string[]): number;
40
+ /** Rank an already eligible pool. Review alone retains its legacy unpreferred tail. */
41
+ export declare function rankPreferredChannels<C extends ChannelSeat>(channels: readonly C[], prefer?: readonly string[], opts?: {
42
+ includeUnpreferred?: boolean;
43
+ tieBreak?: (a: C, b: C) => number;
44
+ }): C[];
45
+ /** Review preserves its established tier-then-cost order inside a preference band. */
46
+ export declare const reviewPreferenceTieBreak: (a: BillingChannel, b: BillingChannel) => number;
34
47
  export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>, exploreCtx?: ExploreContext): Route;
35
48
  export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;
36
49
  export type ClimbPick = Assignment & {
@@ -46,3 +59,4 @@ export type ClimbResult = ClimbPick;
46
59
  * returns nextChannel's same-or-higher pick flagged climbed: false naming the reason.
47
60
  */
48
61
  export declare function climbChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): ClimbPick | null;
62
+ export {};
@@ -44,6 +44,7 @@ export function marginalCostRank(c) {
44
44
  }
45
45
  const toAssignment = (c) => ({
46
46
  adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier,
47
+ ...(c.effort ? { effort: c.effort } : {}),
47
48
  });
48
49
  function resolvePin(pin, channels) {
49
50
  const c = channels.find((c) => c.adapter === pin.via && c.model === pin.model);
@@ -52,10 +53,18 @@ function resolvePin(pin, channels) {
52
53
  }
53
54
  return c;
54
55
  }
55
- function preferIndex(c, prefer = []) {
56
+ /** Shared adapter | adapter:model grammar; unmatched channels follow every declared entry. */
57
+ export function preferIndex(c, prefer = []) {
56
58
  const i = prefer.findIndex((p) => p === c.adapter || p === channelKey(c));
57
59
  return i === -1 ? prefer.length : i;
58
60
  }
61
+ /** Rank an already eligible pool. Review alone retains its legacy unpreferred tail. */
62
+ export function rankPreferredChannels(channels, prefer = [], opts = {}) {
63
+ return channels.filter((c) => opts.includeUnpreferred || preferIndex(c, prefer) < prefer.length)
64
+ .sort((a, b) => preferIndex(a, prefer) - preferIndex(b, prefer) || opts.tieBreak?.(a, b) || 0);
65
+ }
66
+ /** Review preserves its established tier-then-cost order inside a preference band. */
67
+ export const reviewPreferenceTieBreak = (a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || Number(a.channel === "api") - Number(b.channel === "api");
59
68
  function ladderFor(task, entry) {
60
69
  const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
61
70
  return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
@@ -142,6 +151,13 @@ const maybeSlaLint = (lints, task, profile, slaMinutes, c) => {
142
151
  lints.push(`${task.id} (${task.shape}): median ${medianMin}m exceeds sla ${slaMinutes}m — learned perf term references sla ${slaMinutes}m ref`);
143
152
  };
144
153
  export function route(task, cfg, channels, profile, preferCtx, exclude, exploreCtx) {
154
+ // OBS-1143: deny checks below read the probed identity the offered channels carry — the identity
155
+ // discovery admitted them under. A seat no channel vouches for stays unknown (conservative).
156
+ const observed = new Map(channels.flatMap((c) => (c.identity ? [[channelKey(c), c.identity]] : [])));
157
+ const seat = (adapter, model) => {
158
+ const identity = observed.get(channelKey({ adapter, model }));
159
+ return identity ? { adapter, model, identity } : { adapter, model };
160
+ };
145
161
  channels = withoutExcluded(channels, exclude);
146
162
  const lints = [];
147
163
  const advisoryFloor = cfg.routing.floors[task.shape];
@@ -153,7 +169,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
153
169
  const prefer = effectivePrefer(entry);
154
170
  const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
155
171
  const disallowedPin = (via, model, kind) => {
156
- const d = disallowedBy({ adapter: via, model }, cfg.routing);
172
+ const d = disallowedBy(seat(via, model), cfg.routing);
157
173
  if (d) {
158
174
  throw new RoutingError(`${task.id}: ${kind} ${via}:${model} is disallowed by routing.${d.by} (${d.entry}) — remove the ${d.by} entry or re-pin to an allowed channel`);
159
175
  }
@@ -166,8 +182,8 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
166
182
  }
167
183
  if (cfg.tiers[p]) {
168
184
  const expanded = channelsFromConfig(p, cfg);
169
- if (expanded.length && expanded.every((c) => disallowedBy(c, cfg.routing) !== null)) {
170
- const d = disallowedBy(expanded[0], cfg.routing);
185
+ if (expanded.length && expanded.every((c) => disallowedBy(seat(c.adapter, c.model), cfg.routing) !== null)) {
186
+ const d = disallowedBy(seat(expanded[0].adapter, expanded[0].model), cfg.routing);
171
187
  throw new RoutingError(`${task.id}: prefer entry ${p} is disallowed by routing.${d.by} (${d.entry}) — remove the ${d.by} entry or re-pin to an allowed channel`);
172
188
  }
173
189
  return;
@@ -220,21 +236,28 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
220
236
  // single-channel pool) and the open floor/auto path. Exhaustion stays fail-loud (pin precedent).
221
237
  if (entry?.pool) {
222
238
  const pool = entry.pool;
223
- if (prefActive) {
224
- for (const p of pool.channels) {
225
- const i = p.indexOf(":");
226
- const d = disallowedBy({ adapter: p.slice(0, i), model: p.slice(i + 1) }, cfg.routing);
227
- if (d) {
228
- throw new RoutingError(`${task.id}: pool entry ${p} is disallowed by routing.${d.by} (${d.entry}) — remove the ${d.by} entry or drop it from the pool`);
229
- }
230
- }
239
+ // OBS-1144: a disallowed entry is skipped, named in provenance, never fatal for the pool; only an
240
+ // empty admitted remainder refuses. Filtering keeps declaration order, so ordered stays ordered.
241
+ const skipped = [];
242
+ const admitted = pool.channels.filter((p) => {
243
+ if (!prefActive)
244
+ return true;
245
+ const i = p.indexOf(":");
246
+ const d = disallowedBy(seat(p.slice(0, i), p.slice(i + 1)), cfg.routing);
247
+ if (d)
248
+ skipped.push(`${p} (disallowed by routing.${d.by} (${d.entry}))`);
249
+ return d === null;
250
+ });
251
+ const skippedNote = skipped.length ? `; skipped ${skipped.join(", ")}` : "";
252
+ if (!admitted.length) {
253
+ throw new RoutingError(`${task.id}: routing.map.${task.shape}.pool is exhausted — no admitted entry remains: ${skipped.join(", ")} — remove the deny/allow entry or add an admitted channel to the pool`);
231
254
  }
232
255
  // live preserves pool declaration order — ordered mode and any-mode stable-sort ties depend on it
233
- const live = pool.channels
256
+ const live = admitted
234
257
  .map((id) => channels.find((c) => channelKey(c) === id))
235
258
  .filter((c) => c !== undefined && (!taskFloor || TIER_RANK[c.tier] >= TIER_RANK[taskFloor]));
236
259
  if (!live.length) {
237
- throw new RoutingError(`${task.id}: routing.map.${task.shape}.pool has no live channel${taskFloor ? ` at task floor ${taskFloor}` : ""} — declared: ${pool.channels.join(", ")}; doctor found: ${channels.map(channelKey).join(", ") || "(nothing)"}`);
260
+ throw new RoutingError(`${task.id}: routing.map.${task.shape}.pool has no live channel${taskFloor ? ` at task floor ${taskFloor}` : ""} — declared: ${pool.channels.join(", ")}${skippedNote}; doctor found: ${channels.map(channelKey).join(", ") || "(nothing)"}`);
238
261
  }
239
262
  // No learned/explore inside pools: any spreads deterministically; ordered keeps declaration order.
240
263
  const chosen = pool.mode === "ordered"
@@ -242,7 +265,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
242
265
  : rankAnyPool(live, task)[0];
243
266
  lintMapPinFloor(chosen.tier, "pool");
244
267
  maybeSlaLint(lints, task, profile, slaMinutes, chosen);
245
- return { assignment: toAssignment(chosen), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pool ${pool.mode} ${channelKey(chosen)} (config routing.map)` };
268
+ return { assignment: toAssignment(chosen), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pool ${pool.mode} ${channelKey(chosen)} (config routing.map${skippedNote})` };
246
269
  }
247
270
  const baseTier = floor ?? "cheap";
248
271
  // A task floor is hard on pool and floor/auto paths; the map-pin branch above stays supreme.
@@ -260,7 +283,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
260
283
  let msg = `${task.id}: no channel at tier>=${minTier}; available: ${channels.map(channelKey).join(", ") || "(none)"}`;
261
284
  if (prefActive) {
262
285
  const excluded = Object.keys(cfg.tiers).flatMap((id) => channelsFromConfig(id, cfg))
263
- .filter((c) => disallowedBy(c, cfg.routing) !== null && TIER_RANK[c.tier] >= TIER_RANK[minTier])
286
+ .filter((c) => disallowedBy(seat(c.adapter, c.model), cfg.routing) !== null && TIER_RANK[c.tier] >= TIER_RANK[minTier])
264
287
  .map(channelKey);
265
288
  if (excluded.length)
266
289
  msg += `; ${excluded.length} channel(s) excluded by routing.allow/deny: ${excluded.join(", ")}`;
@@ -1,5 +1,6 @@
1
- import type { WorkerAdapter } from "../adapters/types.js";
1
+ import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import type { TickmarkrConfig } from "../config/config.js";
3
+ import type { Effort } from "../graph/schema.js";
3
4
  import type { ExecutorDriver, Slot } from "../drivers/types.js";
4
5
  import type { GateResult } from "../gates/types.js";
5
6
  import { type VerdictUnparseableCause } from "../gates/verdict-cause.js";
@@ -13,6 +14,16 @@ export interface ConsultVerdict {
13
14
  adapter?: string;
14
15
  model?: string;
15
16
  vendor?: string;
17
+ effort?: Effort;
18
+ invocations?: ConsultInvocation[];
19
+ }
20
+ export interface ConsultInvocation {
21
+ adapter: string;
22
+ model: string;
23
+ vendor: string;
24
+ effort?: Effort;
25
+ outcome: "completed" | "failed";
26
+ reason: string;
16
27
  }
17
28
  export declare function renderRetryGuidance(v: ConsultVerdict): string;
18
29
  export declare function augmentRetryBrief(feedback: string, opts: {
@@ -37,6 +48,7 @@ export declare function buildDossierPrompt(d: Dossier, nonce: string): string;
37
48
  export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: WorkerAdapter[], driver: ExecutorDriver, cwd: string, runDir: string, opts?: {
38
49
  keep?: boolean;
39
50
  onSlot?: (slot: Slot) => void;
51
+ onInvocation?: (inv: ConsultInvocation) => void;
40
52
  runId?: string;
41
53
  channels?: Array<{
42
54
  adapter: string;
@@ -1,9 +1,11 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAdapter } from "../adapters/registry.js";
4
+ import { configuredEffort } from "../adapters/types.js";
4
5
  import { bannerShell, paneDispatchCommand } from "../brand.js";
5
6
  import { dewrapPaneVerdict, extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
6
7
  import { classifyVerdictCause } from "../gates/verdict-cause.js";
8
+ import { rankPreferredChannels } from "../route/role-pick.js";
7
9
  import { disallowedBy, routingModelProvider } from "../route/preference.js";
8
10
  import { sh } from "./git.js";
9
11
  import { redactSecrets } from "./redact.js";
@@ -144,6 +146,8 @@ export async function consult(d, cfg, adapters, driver, cwd, runDir,
144
146
  // via SlotOpts.owned; without it the legacy gatePaneName shape survives (non-daemon callers/tests).
145
147
  // v1.54 T1: channels — the daemon's doctor-filtered live channel list; prefer-seat liveness is
146
148
  // judged against it only (never rebuilt from config, which would select installed-but-unauthed seats).
149
+ // OBS-1182: onInvocation hears every launched seat — its identity, effort and outcome — failed seats
150
+ // included, so a seat that never produced the verdict still reaches the journal.
147
151
  opts = {}) {
148
152
  const n = ++consultSeq;
149
153
  const nonce = generateVerdictNonce();
@@ -156,11 +160,11 @@ opts = {}) {
156
160
  // One seat = the WHOLE invoke-and-parse unit, both visibility branches (OBS-69 class: a headless-only
157
161
  // failover would leave the production pane path hard-failing on seat one). A null verdict retains
158
162
  // the classifier's cause when extraction failed; parsed content rejections deliberately have none.
159
- const invokeSeat = async (seatAdapter, seatModel, seatIdx) => {
163
+ const invokeSeat = async (seatAdapter, seatModel, seatIdx, effort) => {
160
164
  const adapter = getAdapter(seatAdapter, adapters);
161
165
  let out;
162
166
  if (cfg.visibility.llm === "headless") {
163
- const r = await sh(adapter.headlessCommand(promptFile, seatModel), cwd, cfg.consult.stallMinutes * 60_000);
167
+ const r = await sh(adapter.headlessCommand(promptFile, seatModel, effort), cwd, cfg.consult.stallMinutes * 60_000);
164
168
  out = r.stdout + r.stderr;
165
169
  }
166
170
  else {
@@ -168,7 +172,7 @@ opts = {}) {
168
172
  // but never exits, so the exit trailer after it never runs, the wait runs to the stall cap and
169
173
  // the 300-line read is a TUI frame. The headless command exits, the trailer prints, and the
170
174
  // pane is harvested through dewrap exactly like a judge or review seat.
171
- const command = adapter.headlessCommand(promptFile, seatModel);
175
+ const command = adapter.headlessCommand(promptFile, seatModel, effort);
172
176
  // T8: role-first pane name for fleet visibility (consult · T2); consultSeq stays on the dossier artifact only
173
177
  const slot = await driver.slot(cwd, gatePaneName("consult", d.taskId), {
174
178
  label: `CONSULT ${d.taskId}`,
@@ -212,15 +216,10 @@ opts = {}) {
212
216
  }
213
217
  return parsed;
214
218
  };
215
- // v1.54 T1: ranked seat failover. Walk consult.prefer (adapter:model entries) to the first entry
216
- // whose adapter is in the live channel set; a failed seat or unparseable verdict falls to the next;
217
- // the pinned consult.adapter/model is always the final seat. No channels provided (non-daemon
218
- // callers) ⇒ empty live set ⇒ pin only, byte-identical to pre-v1.54 behavior. Failover changes
219
- // only WHICH seat answers — per-seat parsing and the fail-safe human action below are untouched.
220
- const live = new Set((opts.channels ?? []).map((c) => c.adapter));
221
- const seats = (cfg.consult.prefer ?? [])
222
- .map((entry) => ({ adapter: entry.slice(0, entry.indexOf(":")), model: entry.slice(entry.indexOf(":") + 1) }))
223
- .filter((s) => live.has(s.adapter));
219
+ // Rank only exact live models from the daemon's role-scoped discovery pool.
220
+ // The explicit pin remains a caller-owned fallback, outside strict preference selection.
221
+ const live = (opts.channels ?? []).filter((c) => typeof c.model === "string");
222
+ const seats = rankPreferredChannels(live.filter((c) => disallowedBy(c, cfg.routing, "consult") === null), cfg.consult.prefer);
224
223
  seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
225
224
  // v1.87 T2: a consult seat reads the code, so it passes through the operator's policy exactly like
226
225
  // a worker does. The filter runs AFTER the pin is pushed, so the final pinned seat is checked by
@@ -246,8 +245,12 @@ opts = {}) {
246
245
  };
247
246
  }
248
247
  for (const [i, seat] of allowedSeats.entries()) {
248
+ // OBS-1182: each seat launches at its own configured effort, never the worker's.
249
+ const effort = configuredEffort(cfg, seat);
250
+ const record = (outcome, reason) => opts.onInvocation?.({ ...seatIdentity(seat), ...(effort ? { effort } : {}), outcome, reason });
249
251
  try {
250
- const parsed = await invokeSeat(seat.adapter, seat.model, i);
252
+ const parsed = await invokeSeat(seat.adapter, seat.model, i, effort);
253
+ record(parsed.verdict ? "completed" : "failed", parsed.verdict ? "verdict" : `verdict unparseable${parsed.cause ? ` (${parsed.cause})` : ""}`);
251
254
  if (parsed.verdict) {
252
255
  const excludeProvider = parsed.verdict.excludeAdapter
253
256
  ? excludedProviderFromDossier(d, parsed.verdict.excludeAdapter)
@@ -259,11 +262,13 @@ opts = {}) {
259
262
  notes: `${parsed.verdict.notes} — excluded provider ${excludeProvider}`,
260
263
  } : {}),
261
264
  ...seatIdentity(seat),
265
+ ...(effort ? { effort } : {}),
262
266
  };
263
267
  }
264
268
  }
265
- catch {
269
+ catch (e) {
266
270
  // failed seat (unknown adapter, dead driver/pane, shell error) — fall to the next entry
271
+ record("failed", `seat error: ${e instanceof Error ? e.message : String(e)}`.slice(0, 200));
267
272
  }
268
273
  }
269
274
  return {
@@ -2,11 +2,14 @@ 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 } from "../gates/baseline.js";
5
+ import { type Baseline, type BaselineProvenance } from "../gates/baseline.js";
6
6
  import type { GateResult } from "../gates/types.js";
7
7
  import { type RunGraph } from "../graph/schema.js";
8
+ import { type PreserveProducer } from "./git.js";
8
9
  import { Journal, type JournalEvent } from "./journal.js";
9
10
  export declare function closeLiveSlot(liveSlots: Set<Slot>, driver: Pick<ExecutorDriver, "close">, slot: Slot): Promise<void>;
11
+ export declare function setContactUnreadableDeadlineMsForTests(ms: number): void;
12
+ export declare function resetContactUnreadableDeadlineMsForTests(): void;
10
13
  /** Optional authoritative transport receipt. Screen text can prove execution, never nonacceptance. */
11
14
  export interface DispatchObservation {
12
15
  slotId: string;
@@ -66,7 +69,35 @@ export interface RunSummary {
66
69
  approvalDisposition?: "complete" | "outstanding";
67
70
  /** the accepted approvals that never reached a dispatch — named, never left to the park buckets */
68
71
  outstandingApprovals?: string[];
72
+ /** OBS-1123: every baseline-recorded red a standing green carried, with the capture that recorded it */
73
+ forgiven?: ForgivenFingerprints[];
69
74
  }
75
+ /** OBS-1123: one forgiveness — the fingerprints a green carried and the capture that recorded them. */
76
+ export interface ForgivenFingerprints {
77
+ /** the task whose gate forgave them; absent for the integration tip */
78
+ taskId?: string;
79
+ gate: string;
80
+ /** absent: a legacy row that named no fingerprints */
81
+ fingerprints?: string[];
82
+ /** absent: a legacy capture or row that recorded no provenance — rendered unknown, never dated */
83
+ baseline?: BaselineProvenance;
84
+ }
85
+ /** Journal rows are untrusted input: a malformed field reads as not recorded, never as a value. */
86
+ export declare const fingerprintsOf: (v: unknown) => string[] | undefined;
87
+ export declare const baselineProvenanceOf: (v: unknown) => BaselineProvenance | undefined;
88
+ /** A forgiven battery green: the structured field, or the battery's own "(forgiven)" wording on a legacy row. */
89
+ export declare const forgivenGateRow: (data: Record<string, unknown>) => boolean;
90
+ /**
91
+ * OBS-1123: the forgiveness standing at this point in the journal — each task gate's LATEST verdict
92
+ * where it is a forgiven green, then the latest verification cycle's forgiven tip rows. A legacy row
93
+ * keeps what it never recorded absent, so the reader says "unknown" instead of borrowing a date.
94
+ */
95
+ export declare function forgivenFingerprints(events: readonly JournalEvent[]): ForgivenFingerprints[];
96
+ /** The capture a forgiveness rests on, or an explicit unknown — never a time read off another clock. */
97
+ export declare function formatBaselineProvenance(p: BaselineProvenance | undefined): string;
98
+ export declare function formatFingerprints(fingerprints: string[] | undefined): string;
99
+ export declare function formatForgiven(f: ForgivenFingerprints): string;
100
+ export declare function pendingDaemonApprovalActions(events: JournalEvent[]): Map<string, import("./journal.js").PendingApprovalAction>;
70
101
  /**
71
102
  * T14, amended by v2.2 T3: approvals the run accepted and never acted on. `approved` above is still
72
103
  * built ONCE at startup — replay determinism depends on it — but a live approval is no longer inert:
@@ -126,6 +157,12 @@ export declare const APPROVAL_POLL_MS = 250;
126
157
  export declare const APPROVAL_WINDOW_MS = 120000;
127
158
  export declare const setApprovalWindowForTests: (ms: number) => void;
128
159
  export declare const resetApprovalWindowForTests: () => void;
160
+ /** Test seam — shrink the capacity backoff without minute-long sleeps. */
161
+ export declare function setCapacityBackoffMsForTests(ms: number): void;
162
+ export declare function resetCapacityBackoffMsForTests(): void;
163
+ /** Test seam — shrink the bootstrap backoff. */
164
+ export declare function setBootstrapBackoffMsForTests(ms: number): void;
165
+ export declare function resetBootstrapBackoffMsForTests(): void;
129
166
  export declare const EARLY_LAUNCH_LIVENESS_MS = 60000;
130
167
  /** Test seam — lowers the empty-pane liveness window without sleeping 60s per case. */
131
168
  export declare function setEarlyLaunchLivenessMsForTests(ms: number): void;
@@ -149,8 +186,11 @@ export interface StartupFailureEvidence {
149
186
  /** One seat, one startup prefix. Once execution or its input box is seen, later panes are ineligible. */
150
187
  export declare class StartupFailureDetector {
151
188
  private readonly adapter;
189
+ private readonly pattern;
190
+ private readonly wholePrefix;
152
191
  private closed;
153
- constructor(adapter: Pick<WorkerAdapter, "inputBox" | "harnessBannerRows">);
192
+ constructor(adapter: Pick<WorkerAdapter, "inputBox" | "harnessBannerRows">, pattern?: RegExp, // OBS-1169: or known bootstrap text
193
+ wholePrefix?: boolean);
154
194
  sample(output: string, launchedAt: number | undefined): StartupFailureEvidence | undefined;
155
195
  }
156
196
  /** Test seam — shrink the quota-banner silence gate without minute-long sleeps. */
@@ -218,5 +258,9 @@ export declare function liveSuiteCount(repoRoot: string): Promise<number>;
218
258
  /** Test seam — exercise the production observer's total read bound with a small real tree. */
219
259
  export declare function setObserveBudgetBytesForTests(bytes: number): void;
220
260
  export declare function resetObserveBudgetBytesForTests(): void;
261
+ /** The attempt whose worker last launched into the task's current checkout; "unknown" once a
262
+ * recreation replaced that tree without a new launch (recheck, gate-only restore) or when no
263
+ * dispatch assignment is on record. Never the newest dispatch by itself. */
264
+ export declare function knownProducer(events: readonly JournalEvent[], taskId: string): PreserveProducer;
221
265
  export declare function recordFatalRunEnd(journal: Journal, runId: string, branch: string, err: unknown, graph?: RunGraph, phase?: string): TipProof | undefined;
222
266
  export declare function runDaemon(repoRoot: string, opts?: RunOptions): Promise<RunSummary>;