tickmarkr 2.6.0 → 2.6.1

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 (47) hide show
  1. package/dist/adapters/prompt.d.ts +2 -1
  2. package/dist/adapters/prompt.js +10 -0
  3. package/dist/adapters/types.d.ts +1 -0
  4. package/dist/adapters/types.js +10 -0
  5. package/dist/cli/commands/fleet.js +26 -1
  6. package/dist/cli/commands/plan.js +7 -3
  7. package/dist/cli/commands/status.js +13 -3
  8. package/dist/cli/help.d.ts +2 -0
  9. package/dist/cli/help.js +2 -0
  10. package/dist/drivers/orca.js +6 -1
  11. package/dist/gates/cache.d.ts +3 -1
  12. package/dist/gates/cache.js +10 -3
  13. package/dist/gates/llm.js +4 -1
  14. package/dist/gates/review.js +7 -11
  15. package/dist/gates/run-gates.d.ts +4 -0
  16. package/dist/gates/run-gates.js +7 -4
  17. package/dist/gates/test-manifest.d.ts +12 -0
  18. package/dist/gates/test-manifest.js +26 -6
  19. package/dist/graph/graph.d.ts +6 -2
  20. package/dist/graph/graph.js +15 -4
  21. package/dist/route/role-pick.d.ts +16 -0
  22. package/dist/route/role-pick.js +15 -0
  23. package/dist/route/router.d.ts +14 -0
  24. package/dist/route/router.js +9 -1
  25. package/dist/run/consult.js +5 -9
  26. package/dist/run/daemon.d.ts +9 -0
  27. package/dist/run/daemon.js +395 -59
  28. package/dist/run/git.d.ts +36 -1
  29. package/dist/run/git.js +89 -5
  30. package/dist/run/host-health.d.ts +20 -0
  31. package/dist/run/host-health.js +64 -0
  32. package/dist/run/journal.js +9 -0
  33. package/dist/run/operator-state.d.ts +24 -2
  34. package/dist/run/operator-state.js +41 -5
  35. package/dist/run/stall.d.ts +38 -2
  36. package/dist/run/stall.js +276 -6
  37. package/dist/tui/cockpit/board.d.ts +1 -1
  38. package/dist/tui/cockpit/board.js +27 -19
  39. package/dist/tui/cockpit/derive.d.ts +2 -0
  40. package/dist/tui/cockpit/derive.js +4 -0
  41. package/dist/tui/cockpit/live-store.d.ts +1 -0
  42. package/dist/tui/cockpit/live-store.js +31 -8
  43. package/dist/tui/cockpit/run-cockpit.js +2 -1
  44. package/dist/tui/cockpit/run-view.d.ts +2 -4
  45. package/dist/tui/cockpit/run-view.js +9 -8
  46. package/package.json +1 -1
  47. package/skills/tickmarkr-overseer/SKILL.md +43 -6
@@ -1,6 +1,6 @@
1
1
  import { type Task } from "../graph/schema.js";
2
2
  import { type VerdictUnparseableCause } from "../gates/verdict-cause.js";
3
- import type { WorkerResult } from "./types.js";
3
+ import { type WorkerResult } from "./types.js";
4
4
  export declare function buildTaskPrompt(task: Task, feedback?: string, nonce?: string): string;
5
5
  export declare function writePrompt(dir: string, task: Task, attempt: number, feedback?: string, nonce?: string): string;
6
6
  export declare function trailerPattern(nonce: string): string;
@@ -11,4 +11,5 @@ export type ClassifiedWorkerResult = WorkerResult & {
11
11
  };
12
12
  export declare function parseWorkerResult(raw: string, nonce: string): ClassifiedWorkerResult;
13
13
  export type DeadChannelReason = "auth-required" | "setup-required" | "provider-outage" | "timeout";
14
+ export declare function classifyTransientCapacity(result: WorkerResult): RegExpExecArray | null;
14
15
  export declare function classifyDeadChannel(result: WorkerResult): DeadChannelReason | undefined;
@@ -2,6 +2,7 @@ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { renderAcceptanceItem } from "../graph/schema.js";
4
4
  import { classifyVerdictCause } from "../gates/verdict-cause.js";
5
+ import { CAPACITY_RE } from "./types.js";
5
6
  // v1.87 T5: the scope rule states only what the runtime enforces, and no more. What it enforces:
6
7
  // `scope.allowDeviations` comes from the operator's config, is snapshotted and frozen when a gate
7
8
  // round starts (run-gates.ts `allowDeviations`), and is never written back — so a worker has no
@@ -133,6 +134,15 @@ const AUTH_RE = /not logged in|please (?:log ?in|sign in)|please run [^\n]{0,30}
133
134
  const SETUP_RE = /command not found|not recognized as an internal or external command|spawn \S+ ENOENT|missing required config|workspace trust (?:required|not granted)/i;
134
135
  const OUTAGE_RE = /unable to reach the model provider|cannot reach the model provider|model provider.{0,40}(?:unavailable|unreachable)|service (?:is )?temporarily unavailable|upstream connect error|overloaded_error/i;
135
136
  const TIMEOUT_RE = /\bETIMEDOUT\b|request timed out|connection timed out|deadline exceeded|timed out waiting for/i;
137
+ // OBS-1161: transient capacity is the fourth classification beside quota and the typed dead-channel
138
+ // reasons, and it obeys the same boundary rule: a parsed trailer — ok:true OR ok:false — is the worker
139
+ // speaking, so a verdict that QUOTES the capacity phrase stays ordinary work evidence. The caller
140
+ // hands in the chrome-filtered banner rows as `raw`, exactly as it does for classifyDeadChannel.
141
+ export function classifyTransientCapacity(result) {
142
+ if (result.ok || (result.summary !== NO_TRAILER_SUMMARY && result.summary !== UNPARSEABLE_TRAILER_SUMMARY))
143
+ return null;
144
+ return CAPACITY_RE.exec(result.raw);
145
+ }
136
146
  export function classifyDeadChannel(result) {
137
147
  // A parsed trailer — ok:true OR ok:false — is the worker speaking: genuine work outcomes walk
138
148
  // the normal gate/ladder path even when their transcript mentions auth/outage/timeout text.
@@ -210,5 +210,6 @@ export declare const PROMPT_ARGV_CEILING_LINUX = 120000;
210
210
  export declare const PROMPT_ARGV_CEILING_DEFAULT = 900000;
211
211
  export declare function promptArgvCeiling(platform?: string): number;
212
212
  export declare function promptFitsArgv(promptFile: string, platform?: string): boolean;
213
+ export declare const CAPACITY_RE: RegExp;
213
214
  export declare const QUOTA_RE: RegExp;
214
215
  export declare const MODEL_ID_RE: RegExp;
@@ -294,6 +294,16 @@ export function promptFitsArgv(promptFile, platform = process.platform) {
294
294
  // ZAI coding-plan exhaustion text: "Insufficient balance or no resource package. Please recharge."
295
295
  // Anchor the distinctive full phrase, not the two-word "insufficient balance" fragment — that fires
296
296
  // on ordinary billing/wallet task output the harness edits (research Pitfall 3, 2026-07-10).
297
+ // OBS-1161: transient provider capacity ("Selected model is at capacity") is NOT quota — the seat
298
+ // comes back within minutes, so it takes bounded same-seat backoff before a same-floor failover and
299
+ // never permanent demotion. Kept apart from QUOTA_RE so permanent quota policy stays untouched, and
300
+ // anchored on the full phrase (the Pitfall-3 lesson: a bare "capacity" fires on ordinary work text).
301
+ // A banner is the CLI speaking, so the phrase must OPEN its row — after at most an error glyph
302
+ // (codex paints "■ Selected model is at capacity. …") or an "error:" label. Anything else in front —
303
+ // a `>` quote, a quotation mark, a sentence — is the transcript QUOTING it: work evidence, never capacity.
304
+ // ponytail: row-start grammar, not per-adapter fixtures — the ceiling is a worker whose own output
305
+ // opens a row with the bare phrase inside the banner tail; widen the glyph set only from a captured frame.
306
+ export const CAPACITY_RE = /^[ \t■⚠✗✘×!]*(?:(?:stream )?error:[ \t]*)?(?:(?:selected |the )?model is (?:currently |temporarily )?at capacity|(?:provider|service|server) is (?:currently )?at capacity)/im;
297
307
  export const QUOTA_RE = /rate.?limit|quota|usage limit|out of credits|insufficient credit|insufficient balance or no resource|\b429\b/i;
298
308
  // v1.5 MODEL-01: charset gate for detected model ids (research Pitfall 4, verified 2026-07-10).
299
309
  // Ids come from CLI stdout / another program's JSON (models_cache.json) and are echoed into
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions } from "../../adapters/registry.js";
4
+ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions, PREFERENCE_ROLES } from "../../adapters/registry.js";
5
5
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
6
  import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
7
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
@@ -11,6 +11,7 @@ import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { doctor } from "./doctor.js";
12
12
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
13
13
  import { route } from "../../route/router.js";
14
+ import { pickRole } from "../../route/role-pick.js";
14
15
  import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, entryMatchesChannel, exclusionCollector } from "../../route/preference.js";
15
16
  import { resolveRunMode } from "../../run/daemon.js";
16
17
  import { loadRoutingProfile } from "../../run/journal.js";
@@ -85,6 +86,8 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
85
86
  const { values } = parseArgs({
86
87
  args: argv,
87
88
  options: {
89
+ pick: { type: "string" },
90
+ "exclude-vendor": { type: "string", multiple: true },
88
91
  print: { type: "boolean" },
89
92
  why: { type: "boolean" },
90
93
  "global-dir": { type: "string" },
@@ -92,6 +95,28 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
92
95
  },
93
96
  });
94
97
  const globalDir = values["global-dir"] ?? globalConfigDir();
98
+ if (values.pick !== undefined) {
99
+ const role = PREFERENCE_ROLES.find((role) => role === values.pick);
100
+ const refuse = (reason) => ({ out: `tickmarkr fleet --pick ${values.pick}: ${reason}`, code: 1 });
101
+ if (!role)
102
+ return refuse("unsupported-role — expected worker, judge, review or consult; no role prefer resolved");
103
+ if (values.print || values.why || values.fresh)
104
+ return refuse("incompatible options — --pick cannot use --print, --why or --fresh");
105
+ const { cfg } = resolveRunMode(cwd, { globalDir });
106
+ const { reuse, health } = initDoctorReuse(cwd, false);
107
+ const selection = pickRole(role, cfg, adapters, reuse && health ? health : {}, {
108
+ excludeVendors: new Set(values["exclude-vendor"] ?? []),
109
+ });
110
+ if (!selection.ok) {
111
+ return refuse(selection.reason === "missing-prefer" ? `${role}.prefer: missing-prefer`
112
+ : !reuse || !health ? `${role}.prefer: probe data missing or stale — run tickmarkr doctor first`
113
+ : `${role}.prefer: ${selection.reason}`);
114
+ }
115
+ const { adapter, model, vendor, channel } = selection.channel;
116
+ return JSON.stringify({ role, adapter, model, vendor, channel });
117
+ }
118
+ if (values["exclude-vendor"]?.length)
119
+ return { out: "tickmarkr fleet: --exclude-vendor requires --pick <role>", code: 1 };
95
120
  const print = values.print ?? false;
96
121
  const why = values.why ?? false;
97
122
  const input = io.input ?? process.stdin;
@@ -7,10 +7,10 @@ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
7
7
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
8
8
  import { classifyContextPath } from "../../compile/native.js";
9
9
  import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
10
- import { chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
10
+ import { batteryPriority, chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
11
11
  import { filesGlob } from "../../graph/files-glob.js";
12
12
  import { renderAcceptanceItem } from "../../graph/schema.js";
13
- import { resolveRunMode } from "../../run/daemon.js";
13
+ import { pendingDaemonApprovalActions, resolveRunMode } from "../../run/daemon.js";
14
14
  import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
15
15
  import { decayWeight, HALF_LIFE_RUNS, staffLedEvidence } from "../../route/profile.js";
16
16
  import { route, RoutingError } from "../../route/router.js";
@@ -372,7 +372,11 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
372
372
  // OBS-1018: depth and wave come from the same functions the daemon admits by, so the plan shows the
373
373
  // critical path the run will actually follow at this concurrency.
374
374
  const depths = chainDepth(g);
375
- const waves = dispatchWaves(g, cfg.concurrency);
375
+ // OBS-1158: waves simulate the graph the daemon would admit on resume — the journal's replayed
376
+ // statuses (an approval releases its park to pending) and its pending recheck approvals ranked
377
+ // through the same seam the daemon admits by.
378
+ const projected = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: taskStatuses.get(t.id) ?? t.status })) };
379
+ const waves = dispatchWaves(projected, cfg.concurrency, batteryPriority(pendingDaemonApprovalActions(matchingEvents ?? []).values()));
376
380
  const byId = new Map(g.tasks.map((t) => [t.id, t]));
377
381
  const chainLine = (t) => {
378
382
  const wave = waves.get(t.id);
@@ -8,6 +8,7 @@ import { formatOwnedName, parseOwnedName, } from "../../drivers/types.js";
8
8
  import { blockedTasks, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
9
9
  import { GATE_NAMES } from "../../graph/schema.js";
10
10
  import { projectActivity } from "../../run/activity.js";
11
+ import { authorsNote, doneAuthors } from "../../run/operator-state.js";
11
12
  import { projectOperatorSummary } from "../../run/operator-summary.js";
12
13
  import { trackJournalRows } from "../../run/protocol.js";
13
14
  import { newestPark, permittedDecisionVerbs } from "./approve.js";
@@ -988,6 +989,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
988
989
  const comparable = record?.comparable ?? false;
989
990
  const rehashAt = record?.rehashAt;
990
991
  const assignments = new Map();
992
+ const authorsByTask = new Map();
991
993
  const contexts = new Map();
992
994
  // v1.53 T5: this run is dead — a newer run replaced it
993
995
  const supersededBy = [...events].reverse()
@@ -1000,6 +1002,11 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1000
1002
  assignments.set(e.taskId, `${a.adapter}:${a.model}`);
1001
1003
  }
1002
1004
  }
1005
+ if (e.event === "task-done" && e.taskId) {
1006
+ const authors = doneAuthors(e.data);
1007
+ if (authors)
1008
+ authorsByTask.set(e.taskId, authors);
1009
+ }
1003
1010
  if (e.event === "context-sample" && e.taskId && typeof e.data.tokens === "number" && Number.isFinite(e.data.tokens)) {
1004
1011
  contexts.set(e.taskId, e.data.tokens); // last write wins
1005
1012
  }
@@ -1106,12 +1113,14 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1106
1113
  const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
1107
1114
  const channel = journalRowsOnly ? folded?.actor ?? "-" : assignments.get(t.id) ?? "-";
1108
1115
  const ctx = contexts.get(t.id);
1109
- const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
1116
+ // Merged authors are their own fact beside the last dispatch: a carry-only seat authored nothing.
1117
+ const authors = authorsNote(authorsByTask.get(t.id), channel);
1118
+ const assignCol = [channel, ctx !== undefined ? `ctx ${ctx}` : "", authors ?? ""].filter(Boolean).join(divider);
1110
1119
  const gates = comparable
1111
1120
  ? gateSnapshot(t, events, rehashAt)
1112
1121
  : { states: defaultGateStates(t), priorGraph: false };
1113
1122
  const pane = panes.get(t.id);
1114
- return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, pane, summary, ...gates };
1123
+ return { t, st, merged, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, authors, livePhase, pane, summary, ...gates };
1115
1124
  });
1116
1125
  if (!unicode) {
1117
1126
  // Keep the machine task-title columns intact; projection details occupy their own line.
@@ -1215,7 +1224,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1215
1224
  }).join("");
1216
1225
  const gateHeader = GATE_NAMES.map((gate) => fitCells(gate.slice(0, 2), 3)).join("");
1217
1226
  const noteFor = (cell) => {
1218
- const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane } = cell;
1227
+ const { t, st, merged, failureKind, redTier, states, priorGraph, isStarved, phrase, ctx, livePhase, pane, authors } = cell;
1219
1228
  const effort = effortByTask.get(t.id);
1220
1229
  const blocking = graphTaskStatus(st, t.status) === "done" ? [] : dependents.get(t.id) ?? [];
1221
1230
  const failed = failedGates(states);
@@ -1242,6 +1251,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1242
1251
  blocking.length ? `blocks ${blocking.join(", ")}` : "",
1243
1252
  pane ? `pane ${pane}` : "",
1244
1253
  ctx !== undefined ? `ctx ${ctx}` : "",
1254
+ authors ?? "",
1245
1255
  ].filter(Boolean).join(" · ");
1246
1256
  };
1247
1257
  const staleWorker = (cell) => cell.livePhase?.phase === "worker"
@@ -39,6 +39,8 @@ export declare const COMMAND_HELP: {
39
39
  usage: string;
40
40
  description: string;
41
41
  options: {
42
+ "--pick <role>": string;
43
+ "--exclude-vendor <vendor>": string;
42
44
  "--print": string;
43
45
  "--why": string;
44
46
  "--global-dir <path>": string;
package/dist/cli/help.js CHANGED
@@ -30,6 +30,8 @@ export const COMMAND_HELP = {
30
30
  fleet: {
31
31
  usage: "fleet [options]", description: "Edit fleet configuration interactively, or print it for scripts.",
32
32
  options: {
33
+ "--pick <role>": "Resolve one role preference as JSON without launching a seat (review or consult; roles without prefer refuse).",
34
+ "--exclude-vendor <vendor>": "Exclude a vendor from --pick; repeat for multiple vendors.",
33
35
  "--print": "Print fleet configuration without opening the editor.",
34
36
  "--why": "Include routing eligibility explanations in printed output.",
35
37
  "--global-dir <path>": "Use this global configuration directory.",
@@ -246,7 +246,12 @@ export function provesCheckout(text, checkout) {
246
246
  * subshell inside it all start in the checkout and its exit status is the payload's (FX-N02).
247
247
  */
248
248
  export function checkoutPrefix(checkout) {
249
- return `cd ${shq(checkout)} && printf '%s\\n' ${shq(checkoutProofLine(checkout))} && sh -c `;
249
+ // OBS-1168: printf BUILDS the marker from two arguments, so the typed command never carries
250
+ // `TICKMARKR_CHECKOUT ` verbatim. The shell's echo of the typed line is repainted and truncated in
251
+ // the scrollback, which used to leave an INCOMPLETE frame beside the complete printed one and made
252
+ // provesCheckout refuse a correct launch. The printed bytes are exactly checkoutProofLine(checkout).
253
+ const head = CHECKOUT_MARK.slice(0, CHECKOUT_MARK.indexOf("_"));
254
+ return `cd ${shq(checkout)} && printf '%s%s\\n' ${head} ${shq(checkoutProofLine(checkout).slice(head.length))} && sh -c `;
250
255
  }
251
256
  /** The command a terminal on the tracked worktree runs so that it executes INSIDE the checkout. */
252
257
  export function inCheckout(checkout, cmd) {
@@ -36,9 +36,11 @@ export interface VerificationIdentity {
36
36
  gate?: string;
37
37
  /** which verifier produced it: a battery gate row or an integration-tip row (own report, own forgiveness rule) */
38
38
  scope?: VerificationScope;
39
- /** Checkout location is diagnostic metadata; equal trees share verdicts across worktrees. */
39
+ /** Checkout location is diagnostic metadata; build outputs additionally require an incarnation. */
40
40
  worktree?: string;
41
41
  tree: string;
42
+ /** Build success also promises local outputs, which die with the checkout. */
43
+ checkoutIncarnation?: string;
42
44
  command: string;
43
45
  baseline: string;
44
46
  environment: string;
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
- import { describeCapacity, inventoryDependencyLinks, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
6
+ import { checkoutIncarnation, describeCapacity, inventoryDependencyLinks, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
7
7
  import { shq } from "../adapters/types.js";
8
8
  export const DEFAULT_VERDICT_CACHE_BOUND = 128;
9
9
  let testCacheBound;
@@ -114,6 +114,9 @@ export async function computeVerificationIdentity(params) {
114
114
  const tree = params.tree ?? (await getWorktreeTree(params.worktree));
115
115
  if (!tree)
116
116
  return undefined;
117
+ const incarnation = params.gate === "build" ? checkoutIncarnation(params.worktree) : undefined;
118
+ if (params.gate === "build" && !incarnation)
119
+ return undefined;
117
120
  const baseline = baselineIdentity(params.baseline);
118
121
  const env = environmentFingerprint({
119
122
  worktree: params.worktree,
@@ -129,6 +132,7 @@ export async function computeVerificationIdentity(params) {
129
132
  scope: params.scope ?? "battery",
130
133
  worktree: realpathSync(params.worktree),
131
134
  tree,
135
+ ...(incarnation ? { checkoutIncarnation: incarnation } : {}),
132
136
  command: params.command,
133
137
  baseline,
134
138
  environment: env.fingerprint,
@@ -138,8 +142,10 @@ export async function computeVerificationIdentity(params) {
138
142
  export function verificationIdentityKey(id) {
139
143
  const cmdHash = createHash("sha256").update(id.command).digest("hex").slice(0, 16);
140
144
  const gate = id.gate ?? "gate";
141
- // Checkout location is diagnostic only. Scope separates each verifier's evidence policy.
142
- const workingTree = createHash("sha256").update(id.tree).digest("hex");
145
+ // Lint/test remain content-addressed. Builds also promise outputs in this physical checkout.
146
+ // Scope continues to separate each verifier's evidence policy.
147
+ const workingTree = createHash("sha256").update(id.tree)
148
+ .update(id.gate === "build" ? `\0checkout:${id.checkoutIncarnation ?? "unbound"}` : "").digest("hex");
143
149
  return `${id.scope ?? "battery"}-${gate}-${workingTree}-${cmdHash}-${id.baseline}-${id.environment}`;
144
150
  }
145
151
  export function formatReusedDetails(originalDetails, id) {
@@ -179,6 +185,7 @@ export function reusedIdentity(id) {
179
185
  gate: id.gate ?? "gate",
180
186
  scope: id.scope ?? "battery",
181
187
  tree: id.tree,
188
+ ...(id.checkoutIncarnation ? { checkoutIncarnation: id.checkoutIncarnation } : {}),
182
189
  ...(id.worktree ? { worktree: id.worktree } : {}),
183
190
  command: id.command,
184
191
  baseline: id.baseline,
package/dist/gates/llm.js CHANGED
@@ -403,7 +403,10 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
403
403
  // That seat was killed by its configured timeout, not an early launch reroute.
404
404
  if (now - startedAt >= timeoutMs)
405
405
  break;
406
- if (reviewing && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
406
+ // OBS-1177: the beat is armed for an INTERACTIVE (pane) driver only. On the subprocess driver
407
+ // a headless `claude -p` buffers every byte until it exits, so 0 seat bytes at 30 s is not a
408
+ // dead launch; that seat keeps its full ceiling, which still bounds it (fail-closed by timeout).
409
+ if (reviewing && via.driver.interactive && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
407
410
  firstLivenessObserved = true;
408
411
  // RS-2: the beat reads seat-authored bytes ALONE. CPU evidence never holds a preamble-only
409
412
  // capture open to the ceiling; a seat that has not written one byte of its own is re-routed.
@@ -8,7 +8,7 @@ import { getAdapter } from "../adapters/registry.js";
8
8
  import { shOk } from "../run/git.js";
9
9
  import { carryReviewFindings, observedReviewFingerprints, reviewFingerprintMatches, structuredFindings, UNIDENTIFIED } from "../run/journal.js";
10
10
  import { redactSecrets } from "../run/redact.js";
11
- import { marginalCostRank } from "../route/router.js";
11
+ import { rankPreferredChannels, reviewPreferenceTieBreak } from "../route/role-pick.js";
12
12
  import { modelProvider } from "../route/preference.js";
13
13
  import { resolveStateDir } from "./cache.js";
14
14
  import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, parseAnchoredComments, runLlmDetailed, verdictNonceLine } from "./llm.js";
@@ -227,13 +227,6 @@ export function isReviewClosureMismatch(v, priorIds) {
227
227
  const priors = priorIds instanceof Set ? priorIds : new Set(priorIds);
228
228
  return ids.some((id) => matchClosureId(id, priors) === undefined);
229
229
  }
230
- // v1.53 T2: same entry grammar as routing.map.prefer (router.ts preferIndex — router is out of this
231
- // module's dependency direction for a private fn, so the 3 lines live here too): `adapter` matches
232
- // every channel of that adapter, `adapter:model` exactly one; unmatched channels sort after all entries.
233
- function reviewPreferIndex(c, prefer) {
234
- const i = prefer.findIndex((p) => p === c.adapter || p === channelKey(c));
235
- return i === -1 ? prefer.length : i;
236
- }
237
230
  /**
238
231
  * RF-1 (OBS-922 add.2/3): the tier a reviewer must meet is the maximum of the author's tier, the
239
232
  * task-declared floor, a configured `review.floor` tier and, on a second round or a retry, the prior
@@ -290,7 +283,7 @@ excludeVendors = new Set(), authors = [channelKey(author)]) {
290
283
  return null;
291
284
  // RF-1: every caller inherits the author-tier floor — a reviewer is never seated below its author.
292
285
  const effectiveFloor = resolveReviewerFloor(author.tier, floor).floor;
293
- const ranked = channels
286
+ const eligible = channels
294
287
  // Three independent axes: different vendor, different resolved provider identity (OBS-946: on initial pick
295
288
  // as well as failover, so an aggregator channel stamped "mixed" never seats the author's own provider),
296
289
  // and different base-model identity (ADDED TO the vendor rule, never replacing it). The diversity
@@ -300,8 +293,11 @@ excludeVendors = new Set(), authors = [channelKey(author)]) {
300
293
  && modelId(c.model) !== modelId(a.model))
301
294
  && !exclude.includes(channelKey(c))
302
295
  && !excludeVendors.has(c.vendor)
303
- && TIER_RANK[c.tier] >= TIER_RANK[effectiveFloor])
304
- .sort((a, b) => reviewPreferIndex(a, prefer) - reviewPreferIndex(b, prefer) || TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b));
296
+ && TIER_RANK[c.tier] >= TIER_RANK[effectiveFloor]);
297
+ const ranked = rankPreferredChannels(eligible, prefer, {
298
+ includeUnpreferred: true,
299
+ tieBreak: reviewPreferenceTieBreak,
300
+ });
305
301
  const reviewer = [...ranked].sort((a, b) => Number(demoted.has(channelKey(a))) - Number(demoted.has(channelKey(b)))
306
302
  || history.lastIndexOf(channelKey(a)) - history.lastIndexOf(channelKey(b))
307
303
  || ranked.indexOf(a) - ranked.indexOf(b))[0] ?? null;
@@ -7,6 +7,7 @@ import { type GateVia } from "./llm.js";
7
7
  import { type PriorReviewer } from "./review.js";
8
8
  import type { GateResult } from "./types.js";
9
9
  import { type VerificationRetryCause } from "../run/recovery.js";
10
+ import { type PreserveProducer } from "../run/git.js";
10
11
  import { type StructuredFinding } from "../run/journal.js";
11
12
  import { type VerificationScope } from "./cache.js";
12
13
  export type LoadProvider = () => number;
@@ -71,6 +72,9 @@ export interface GateContext {
71
72
  /** Explicit worker funding requires fresh red measurements, never a gate waiver. */
72
73
  cachedRedBypass?: "operator-rerun";
73
74
  carriedAuthors?: readonly string[];
75
+ /** The attempt whose worker last wrote the gated checkout; a dirty-tree refusal stamps it on the
76
+ * preserve commit and its row. Absent (standalone verify, gate-only restores) preserves as "unknown". */
77
+ producer?: PreserveProducer;
74
78
  reviewHistory?: string[];
75
79
  priorReviewers?: PriorReviewer[];
76
80
  artifactDir?: string;
@@ -17,7 +17,7 @@ import { scopeGate } from "./scope.js";
17
17
  import { evaluateManifestedTest, isVitestTestCommand } from "./test-manifest.js";
18
18
  import { executionSignal } from "../run/execution-budget.js";
19
19
  import { failureDisposition } from "../run/recovery.js";
20
- import { dependencyLinkRefusal, preserveWorktree, shGit, resolvedCapacity, verificationProtocol } from "../run/git.js";
20
+ import { dependencyLinkRefusal, preserveWorktree, producerFields, shGit, resolvedCapacity, verificationProtocol } from "../run/git.js";
21
21
  import { withJudgeInvocationEvidence } from "../run/journal.js";
22
22
  import { computeVerificationIdentity, verificationIdentityKey, formatReusedRow, getVerdictStore, isInfraResult, resolveStateDir, reusedIdentity, } from "./cache.js";
23
23
  const productionLoadProvider = () => loadavg()[0] ?? 0;
@@ -223,6 +223,7 @@ async function runVitestManifestGate(worktree, cmd, baseline, selected, artifact
223
223
  overallCeilingMs: effectiveCeilingMs(entry),
224
224
  artifactDir,
225
225
  evidence: retry.evidence,
226
+ retryBaseCommand: retry.retryBaseCommand, // OBS-1166: the un-narrowed command for a selected screen's stranded retry
226
227
  });
227
228
  const reportPath = outcome.reportPath;
228
229
  const evidence = { evidenceReceipt: outcome.evidenceReceipt, evidenceReceipts: outcome.evidenceReceipts };
@@ -482,7 +483,7 @@ export async function runGates(task, ctx) {
482
483
  let preservedRef;
483
484
  let preservationError;
484
485
  try {
485
- preservedRef = await preserveWorktree(ctx.worktree);
486
+ preservedRef = await preserveWorktree(ctx.worktree, ctx.producer);
486
487
  }
487
488
  catch (error) {
488
489
  // Never masks the refusal, but never pretends a snapshot exists either — surfaced below.
@@ -537,6 +538,7 @@ export async function runGates(task, ctx) {
537
538
  dirtyWorktree: true,
538
539
  ref: preservedRef,
539
540
  preservedRef,
541
+ ...producerFields(ctx.producer),
540
542
  paths: dirtyPaths,
541
543
  files: dirtyPaths,
542
544
  path: primaryFile,
@@ -583,7 +585,7 @@ export async function runGates(task, ctx) {
583
585
  let preservedRef;
584
586
  let preservationError;
585
587
  try {
586
- preservedRef = await preserveWorktree(ctx.worktree);
588
+ preservedRef = await preserveWorktree(ctx.worktree, ctx.producer);
587
589
  }
588
590
  catch (error) {
589
591
  preservationError = error instanceof Error ? error.message : String(error);
@@ -614,6 +616,7 @@ export async function runGates(task, ctx) {
614
616
  dirtyAtRoundEnd: true,
615
617
  ref: preservedRef,
616
618
  preservedRef,
619
+ ...producerFields(ctx.producer),
617
620
  paths: dirtyPaths,
618
621
  files: dirtyPaths,
619
622
  path: primaryFile,
@@ -677,7 +680,7 @@ export async function runGates(task, ctx) {
677
680
  // other scripted test command keeps today's exit-code contract byte-identically.
678
681
  const useManifest = g === "test" && commands.test !== undefined && isVitestTestCommand(commands.test, ctx.worktree);
679
682
  r = useManifest
680
- ? await measure(g, () => runVitestManifestGate(ctx.worktree, commands.test, ctx.baseline, selected, ctx.artifactDir, { ...retryOptions(identity), evidence }))
683
+ ? await measure(g, () => runVitestManifestGate(ctx.worktree, commands.test, ctx.baseline, selected, ctx.artifactDir, { ...retryOptions(identity), evidence, ...(selected ? { retryBaseCommand: ctx.commands.test } : {}) }))
681
684
  : (await measure(g, () => compareToBaseline(ctx.worktree, commands, ctx.baseline, [g], { ...retryOptions(identity), evidence, ...(g === "build" ? { onReceipt: buildReceipt, taskBuildAttribution: beginBuild } : {}), ...(g === "test" && selected ? { selected } : {}) })))[0];
682
685
  }
683
686
  finally {
@@ -1,5 +1,7 @@
1
1
  import { type GateEvidenceOptions, type BaselineFileDuration } from "./baseline.js";
2
2
  import type { GateEvidenceReceipt } from "../run/protocol.js";
3
+ export declare const VITEST_CACHE_ENV = "TICKMARKR_VITEST_CACHE_DIR";
4
+ export declare function worktreeVitestCache(cwd: string, inherited?: string): string;
3
5
  export declare function isVitestTestCommand(cmd: string, cwd: string): boolean;
4
6
  /** One identity for every path this module compares: repo-relative, forward-slash. `vitest list
5
7
  * --json` and `TestModule.moduleId` both hand back an absolute filesystem path already resolved
@@ -144,6 +146,13 @@ export declare function discoverTestManifest(cmd: string, cwd: string, opts: {
144
146
  * runner cannot list (a null compares nothing — it never manufactures a deficit). The suite is not
145
147
  * run here; the capture already ran it once. */
146
148
  export declare function manifestFileCount(cmd: string, cwd: string): Promise<number | null>;
149
+ /** The stranded single-fork retry: positional filters are substring matches that OR with any filter
150
+ * the command already carries, and under a `projects` config the CLI `--exclude` never subtracts such a
151
+ * selection (OBS-1166: a selected screen's retry rediscovered the whole selection and refused). So the
152
+ * retry is built from the UN-narrowed base command, its own `--` rule, the stranded files as the only
153
+ * positional filters, and an `--exclude` of every completed file; the caller then requires discovery
154
+ * to prove the exact retry set before launch. */
155
+ export declare function singleForkRetryCommand(base: string, cwd: string, stranded: readonly string[], completed: readonly string[]): string;
147
156
  /** One configured runner execution, and its own collection under the same arguments and environment.
148
157
  * The installed runner is trusted (R28 add.1 option B); the nonce catches stale artifacts, not forgery. */
149
158
  export declare function evaluateManifestedTest(cmd: string, cwd: string, opts: {
@@ -152,4 +161,7 @@ export declare function evaluateManifestedTest(cmd: string, cwd: string, opts: {
152
161
  overallCeilingMs?: number;
153
162
  artifactDir?: string;
154
163
  evidence?: GateEvidenceOptions;
164
+ /** OBS-1166: the configured command BEFORE a selected screen narrowed it, so a stranded retry's
165
+ * only positional filters are the stranded files. Absent (a full suite), `cmd` is that command. */
166
+ retryBaseCommand?: string;
155
167
  }): Promise<ManifestGateOutcome>;
@@ -1,11 +1,20 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { isAbsolute, join, relative, sep } from "node:path";
4
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { TEST_REPORTER_SOURCE } from "./test-reporter.js";
6
6
  import { shq } from "../adapters/types.js";
7
7
  import { beginGateEvidence, redactGateOutput } from "./baseline.js";
8
8
  import { FORK_CAP_ENV, ROUTING_ENV_SEAMS, SUITE_PARENT_ENV, shell, resolvedCapacity, verificationProtocol } from "../run/git.js";
9
+ // Outside the shared dependency symlink and ignored by the shipped repository.
10
+ export const VITEST_CACHE_ENV = "TICKMARKR_VITEST_CACHE_DIR";
11
+ export function worktreeVitestCache(cwd, inherited) {
12
+ const local = resolve(cwd, ".vitest-cache");
13
+ const candidate = inherited ? resolve(cwd, inherited) : local;
14
+ const within = relative(local, candidate);
15
+ return within === "" || (!isAbsolute(within) && within !== ".." && !within.startsWith(`..${sep}`))
16
+ ? candidate : local;
17
+ }
9
18
  /**
10
19
  * VL-1 (OBS-985 lineage): a test gate's completion must be the runner's OWN report, never a stdout
11
20
  * count. `fileCountDeficit` (baseline.ts) reads a summary LINE — a selected screen's smaller count
@@ -387,6 +396,7 @@ export function runManifestedTest(cmd, cwd, opts) {
387
396
  * verdict measured with `pretest` hooks is never compared to one without. */
388
397
  function manifestEnvironment(cwd) {
389
398
  const env = { ...process.env, PATH: `${join(cwd, "node_modules/.bin")}:${process.env.PATH ?? ""}`,
399
+ [VITEST_CACHE_ENV]: worktreeVitestCache(cwd),
390
400
  [FORK_CAP_ENV]: String(resolvedCapacity().forkCap), [SUITE_PARENT_ENV]: String(process.pid) };
391
401
  const verification = verificationProtocol(env, cwd);
392
402
  for (const key of ROUTING_ENV_SEAMS)
@@ -436,6 +446,9 @@ export async function manifestFileCount(cmd, cwd) {
436
446
  catch {
437
447
  return null;
438
448
  }
449
+ finally {
450
+ rmSync(dir, { recursive: true, force: true });
451
+ } // OBS-1155: the listing directory is the capture's alone, listed or not
439
452
  }
440
453
  /** Vitest's forks pool awaits the parallel phase, then throws before the single-fork phase
441
454
  * on any rejected worker. Recover only that exact, fully accounted-for boundary. */
@@ -474,6 +487,16 @@ function strandedSingleForkFiles(files, nonce, run) {
474
487
  return;
475
488
  return single;
476
489
  }
490
+ /** The stranded single-fork retry: positional filters are substring matches that OR with any filter
491
+ * the command already carries, and under a `projects` config the CLI `--exclude` never subtracts such a
492
+ * selection (OBS-1166: a selected screen's retry rediscovered the whole selection and refused). So the
493
+ * retry is built from the UN-narrowed base command, its own `--` rule, the stranded files as the only
494
+ * positional filters, and an `--exclude` of every completed file; the caller then requires discovery
495
+ * to prove the exact retry set before launch. */
496
+ export function singleForkRetryCommand(base, cwd, stranded, completed) {
497
+ 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}`;
499
+ }
477
500
  /** One configured runner execution, and its own collection under the same arguments and environment.
478
501
  * The installed runner is trusted (R28 add.1 option B); the nonce catches stale artifacts, not forgery. */
479
502
  export async function evaluateManifestedTest(cmd, cwd, opts) {
@@ -518,10 +541,7 @@ export async function evaluateManifestedTest(cmd, cwd, opts) {
518
541
  recovery = { firstNonce: nonce, firstReportPath: reportPath, retryNonce, files: stranded };
519
542
  nonce = retryNonce;
520
543
  reportPath = join(dir, `test-manifest-report-${nonce}.json`);
521
- // Positional filters are substring matches (and OR with existing filters). Exclude every
522
- // completed file as well, then require discovery to prove the exact retry set before launch.
523
- const excluded = files.filter(f => !stranded.includes(f)).map(f => `--exclude=${shq(f.replace(/[\\*?[\]{}()!+@]/g, "\\$&"))}`).join(" ");
524
- const retryCommand = `${cmd}${invocation.separator} ${stranded.map(f => shq(join(cwd, f))).join(" ")} ${excluded}`;
544
+ const retryCommand = singleForkRetryCommand(opts.retryBaseCommand ?? cmd, cwd, stranded, files.filter(f => !stranded.includes(f)));
525
545
  const listed = await discoverTestManifest(retryCommand, cwd, { dir, nonce, env,
526
546
  overallCeilingMs: opts.overallCeilingMs, evidence: { ...opts.evidence, artifactDir: opts.evidence?.artifactDir ?? dir } });
527
547
  evidenceReceipts.push(...listed.evidenceReceipts);
@@ -36,8 +36,12 @@ export declare function addEvidence(g: RunGraph, id: string, patch: {
36
36
  gateResults?: unknown[];
37
37
  }): RunGraph;
38
38
  export declare function chainDepth(g: RunGraph): Map<string, number>;
39
- export declare function readyTasks(g: RunGraph): Task[];
40
- export declare function dispatchWaves(g: RunGraph, concurrency: number): Map<string, number>;
39
+ export declare function readyTasks(g: RunGraph, prioritized?: ReadonlySet<string>): Task[];
40
+ export declare function batteryPriority(actions: Iterable<{
41
+ taskId: string;
42
+ authority: string;
43
+ }>): Set<string>;
44
+ export declare function dispatchWaves(g: RunGraph, concurrency: number, prioritized?: ReadonlySet<string>): Map<string, number>;
41
45
  export declare function isComplete(g: RunGraph): boolean;
42
46
  export declare function isStalled(g: RunGraph): boolean;
43
47
  export declare function closureReaches(g: RunGraph, taskId: string, pred: (t: Task) => boolean): boolean;
@@ -191,21 +191,32 @@ export function chainDepth(g) {
191
191
  // OBS-1018: admission is critical-path order — deepest chain root first, ties in declaration order.
192
192
  // The daemon's dispatch loop slices this list unchanged. Stated limit: resume-restore of previously
193
193
  // in-flight attempts keeps its own precedence (src/run/daemon.ts); only fresh admission is ordered here.
194
- export function readyTasks(g) {
194
+ // OBS-1158: `prioritized` names tasks holding a pending battery (recheck) approval. Among READY tasks
195
+ // of equal depth an approved recheck precedes fresh work; deeper fresh work keeps its rank, and a
196
+ // recheck that is not ready (dependency-blocked) is not admitted at all, so it gains nothing here.
197
+ export function readyTasks(g, prioritized = NO_PRIORITY) {
195
198
  const done = new Set(g.tasks.filter((t) => t.status === "done").map((t) => t.id));
196
199
  const depth = chainDepth(g);
200
+ const rank = (t) => (prioritized.has(t.id) ? 1 : 0);
197
201
  return g.tasks
198
202
  .filter((t) => t.status === "pending" && t.deps.every((d) => done.has(d)))
199
- .sort((a, b) => depth.get(b.id) - depth.get(a.id)); // Array#sort is stable: equal depths keep declaration order
203
+ .sort((a, b) => depth.get(b.id) - depth.get(a.id) || rank(b) - rank(a)); // Array#sort is stable: remaining ties keep declaration order
204
+ }
205
+ const NO_PRIORITY = new Set();
206
+ // OBS-1158: the one seam plan and the daemon share for battery priority — journal approval actions
207
+ // in (src/run/journal.ts pendingApprovalActions), the ids whose pending authority is `battery` out.
208
+ // Waivers, worker-funding approvals and inert releases never qualify; a consumed approval is absent.
209
+ export function batteryPriority(actions) {
210
+ return new Set([...actions].filter((a) => a.authority === "battery").map((a) => a.taskId));
200
211
  }
201
212
  // OBS-1018: the dispatch wave each pending task would enter at `concurrency` slots if every wave
202
213
  // took one tick — computed by draining readyTasks, so plan and daemon can never disagree on order.
203
214
  // Tasks already past pending carry no wave.
204
- export function dispatchWaves(g, concurrency) {
215
+ export function dispatchWaves(g, concurrency, prioritized = NO_PRIORITY) {
205
216
  const waves = new Map();
206
217
  let sim = g;
207
218
  for (let wave = 1;; wave++) {
208
- const batch = readyTasks(sim).slice(0, Math.max(1, concurrency));
219
+ const batch = readyTasks(sim, prioritized).slice(0, Math.max(1, concurrency));
209
220
  if (!batch.length)
210
221
  return waves;
211
222
  for (const t of batch) {
@@ -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;