tickmarkr 2.6.4 → 2.6.6

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 (73) hide show
  1. package/README.md +7 -3
  2. package/dist/adapters/catalog-remote.d.ts +23 -0
  3. package/dist/adapters/catalog-remote.js +38 -0
  4. package/dist/adapters/claude-code.d.ts +1 -1
  5. package/dist/adapters/claude-code.js +4 -1
  6. package/dist/adapters/codex-commit-check.d.ts +36 -0
  7. package/dist/adapters/codex-commit-check.js +205 -0
  8. package/dist/adapters/codex.d.ts +1 -0
  9. package/dist/adapters/codex.js +12 -6
  10. package/dist/adapters/model-discovery-check.d.ts +33 -0
  11. package/dist/adapters/model-discovery-check.js +123 -0
  12. package/dist/adapters/model-lints.d.ts +1 -1
  13. package/dist/adapters/model-lints.js +4 -0
  14. package/dist/adapters/prompt.js +3 -2
  15. package/dist/adapters/types.d.ts +10 -0
  16. package/dist/cli/commands/approve.js +10 -4
  17. package/dist/cli/commands/doctor.d.ts +2 -0
  18. package/dist/cli/commands/doctor.js +17 -3
  19. package/dist/cli/commands/fleet.d.ts +10 -1
  20. package/dist/cli/commands/fleet.js +363 -106
  21. package/dist/cli/commands/init.js +10 -3
  22. package/dist/cli/commands/plan.js +76 -4
  23. package/dist/cli/commands/verify.d.ts +1 -1
  24. package/dist/cli/commands/verify.js +65 -15
  25. package/dist/config/fleet-overlay.d.ts +16 -1
  26. package/dist/config/fleet-overlay.js +230 -28
  27. package/dist/drivers/orca.d.ts +34 -2
  28. package/dist/drivers/orca.js +206 -32
  29. package/dist/gates/cache.d.ts +4 -0
  30. package/dist/gates/cache.js +10 -2
  31. package/dist/gates/llm.d.ts +4 -1
  32. package/dist/gates/llm.js +6 -1
  33. package/dist/gates/review.js +8 -3
  34. package/dist/gates/run-gates.d.ts +5 -3
  35. package/dist/gates/run-gates.js +112 -56
  36. package/dist/gates/test-manifest.d.ts +3 -0
  37. package/dist/gates/test-manifest.js +1 -1
  38. package/dist/gates/timeout-shaped.d.ts +3 -0
  39. package/dist/gates/timeout-shaped.js +10 -0
  40. package/dist/run/daemon.js +326 -130
  41. package/dist/run/git.js +9 -0
  42. package/dist/run/journal.d.ts +6 -2
  43. package/dist/run/journal.js +22 -7
  44. package/dist/run/launchservices-check.d.ts +56 -0
  45. package/dist/run/launchservices-check.js +117 -0
  46. package/dist/run/operator-state.d.ts +22 -2
  47. package/dist/run/operator-state.js +20 -3
  48. package/dist/run/title-environment.d.ts +45 -0
  49. package/dist/run/title-environment.js +127 -0
  50. package/dist/run/title-preload.cjs +38 -0
  51. package/dist/run/title-preload.d.cts +1 -0
  52. package/dist/tui/cockpit/board.js +12 -6
  53. package/dist/tui/cockpit/keys.d.ts +8 -0
  54. package/dist/tui/cockpit/keys.js +4 -1
  55. package/dist/tui/cockpit/layout.d.ts +2 -2
  56. package/dist/tui/cockpit/layout.js +2 -2
  57. package/dist/tui/cockpit/live-runtime.js +46 -20
  58. package/dist/tui/cockpit/live-store.d.ts +24 -1
  59. package/dist/tui/cockpit/live-store.js +99 -8
  60. package/dist/tui/cockpit/log-view.d.ts +28 -0
  61. package/dist/tui/cockpit/log-view.js +60 -0
  62. package/dist/tui/cockpit/run-view.js +4 -0
  63. package/dist/tui/cockpit/shell.d.ts +2 -1
  64. package/dist/tui/cockpit/shell.js +6 -4
  65. package/dist/tui/ink/fleet-app.d.ts +37 -10
  66. package/dist/tui/ink/fleet-app.js +65 -21
  67. package/package.json +1 -1
  68. package/skills/tickmarkr-auto/SKILL.md +32 -2
  69. package/skills/tickmarkr-loop/SKILL.md +46 -2
  70. package/skills/tickmarkr-overseer/SKILL.md +83 -8
  71. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +58 -16
  72. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +19 -6
  73. package/skills/tickmarkr-overseer/scripts/watch-parks.sh +121 -64
@@ -132,7 +132,11 @@ you were handed the whole picture — list the repository root, open every guida
132
132
  - **The gates are the product.** Seven, defined in \`src/graph/schema.ts\`:
133
133
  \`build test lint evidence scope acceptance review\`. **That is DECLARATION order, not execution order** —
134
134
  the first five run as a battery that stops at its first red, then \`acceptance\` and \`review\` run
135
- CONCURRENTLY (\`run-gates.ts:39\`, *"judge ‖ review"*). The first five are MANDATORY; only \`acceptance\` and
135
+ CONCURRENTLY (\`run-gates.ts:39\`, *"judge ‖ review"*). One repair reorders them: a repair the REVIEW sent
136
+ back (carried \`review:material\`, test selection on, no required repair test) runs build, lint, evidence and
137
+ scope, then \`acceptance\` ‖ \`review\`, then its test screen, so a review that re-reds an unchanged subject
138
+ starts no test at all; a test-red repair keeps the battery first. Either way nothing merges until the FULL
139
+ suite has passed on the merge-candidate commit. The first five are MANDATORY; only \`acceptance\` and
136
140
  \`review\` may be omitted per task. Implementations are in \`src/gates/\` — \`baseline.ts\` (build/test/lint,
137
141
  diffed against a recorded baseline so pre-existing failures are forgiven), \`evidence.ts\`, \`scope.ts\`,
138
142
  \`acceptance.ts\` (its judge reads the DIFF and every criterion must cite a changed hunk), \`review.ts\`
@@ -635,7 +639,8 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
635
639
  const interactive = input.isTTY === true && output.isTTY === true && !(values.yes ?? false);
636
640
  // Act 1 — preferences. Only when the repo config does not exist yet: init never rewrites an
637
641
  // operator's config; re-tuning is `tickmarkr fleet` (act 3 below still runs). Act 1 writes only
638
- // project execution preferences; model choices are act 3's, saved to the USER overlay (B2).
642
+ // project execution preferences; model choices are act 3's, saved by Fleet's one destination rule:
643
+ // the USER overlay (B2), except a membership edit the repo overlay holds (T10).
639
644
  if (!repoConfigExists) {
640
645
  if (interactive) {
641
646
  emitBanner();
@@ -673,7 +678,9 @@ export async function init(argv, cwd = process.cwd(), io = {}) {
673
678
  // Act 3 — fleet. The compact discovery surface prints BETWEEN the acts so the operator reads
674
679
  // what the fleet browser (and its presets overlay, raised on the first Shapes entry) ranks
675
680
  // with; the final summary then carries a pointer, not a repeat. Its confirmation is the shared
676
- // Fleet actuator's: the reviewed choices land in the user overlay (gdir), never the repo's.
681
+ // Fleet actuator's: a preset (routing.mode) lands in the user overlay (gdir), and so does every
682
+ // choice except a routing.allow/deny membership edit this repo overlay holds — T10 writes that one
683
+ // where it is effective, and the review names the destination before y.
677
684
  if (interactive) {
678
685
  output.write(`${doc}\n`);
679
686
  doc = "discovery shown above — full matrix any time with `tickmarkr doctor`";
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
4
  import { allAdapters, doctorAgeMs, modelAuthExclusions, probeAll, readDoctor, rolePools, servableExclusions, servabilityLine } from "../../adapters/registry.js";
5
+ import { aliasIdentityHeadlines } from "../../adapters/model-discovery-check.js";
5
6
  import { formatModelAuthLine, contextWindowLints, modelLints, preferEntryLints, ttyVisual } from "../../adapters/model-lints.js";
6
7
  import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
7
8
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
@@ -15,9 +16,11 @@ import { disallowedBy, excludedChannels, exclusionLine, observedSeat, routingEnt
15
16
  import { decayWeight, HALF_LIFE_RUNS, staffLedEvidence } from "../../route/profile.js";
16
17
  import { resolvedFloor, route, RoutingError } from "../../route/router.js";
17
18
  import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
18
- import { modelId, modelProvider, pickReviewer } from "../../gates/review.js";
19
+ import { carriedAuthorVendors, gateReviewerFloor, modelId, modelProvider, pickReviewer } from "../../gates/review.js";
19
20
  import { Journal, loadRoutingProfile, readProfileCursor, recordedGraphDefinitionHash, RUNS_WINDOW } from "../../run/journal.js";
20
21
  import { harnessLine, resolveHarness } from "../harness.js";
22
+ import { codexCommitHeadline, codexEscapeRoleLine } from "../../adapters/codex-commit-check.js";
23
+ import { codex } from "../../adapters/codex.js";
21
24
  import { channelKey, shq } from "../../adapters/types.js";
22
25
  import { shGit } from "../../run/git.js";
23
26
  import { runLockRunId, runStatusLine } from "../../run/lock.js";
@@ -135,6 +138,13 @@ async function taskInputFindings(tasks, cwd) {
135
138
  }
136
139
  return findings;
137
140
  }
141
+ /**
142
+ * v2.6.5 T9 (D-862): a task the run never dispatched gates under the fresh route's author, so plan can
143
+ * mirror its review eligibility exactly. Once dispatched, resume state only the daemon decides (a restored
144
+ * or failed-over author, a tier climb, carried commits, a parked recheck) picks the author — fail closed.
145
+ * ponytail: unknown, not exact; call production's own resume functions once daemon.ts can export them (POOL).
146
+ */
147
+ const resumeDecidesAuthor = (events, taskId) => events.some((row) => row.taskId === taskId && row.event === "task-dispatch");
138
148
  // v1.89 T4: harnessFrom is the resolver's INPUT — a caller (the byte-pinned goldens) fixes the location
139
149
  // and keeps this machine's absolute paths out of a fixture. The default is the INVOKED entrypoint,
140
150
  // `process.argv[1]`: the bin symlink a global install puts on PATH, which resolves to dist/cli/index.js.
@@ -183,6 +193,26 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
183
193
  .filter((event) => event.event === "gate-result" && event.data.gate === "review" && typeof event.data.reviewer === "string")
184
194
  .map((event) => event.data.reviewer)
185
195
  : [];
196
+ // daemon.ts replayedReviewerExclusions: a journaled review-pool-demotion is excluded from every review
197
+ // pick of the resumed run this plan previews (reviewer role only).
198
+ const replayedReviewerExclusions = [...new Set((matchingEvents ?? [])
199
+ .filter((event) => event.event === "review-pool-demotion" && typeof event.data.reviewer === "string")
200
+ .map((event) => event.data.reviewer))];
201
+ const demotedReviewers = new Set(replayedReviewerExclusions);
202
+ // daemon.ts demotedChannels: the resumed run's worker exclusions (journal replayExcludedChannels).
203
+ const runJournal = matchingRunId ? Journal.open(cwd, matchingRunId) : undefined;
204
+ const demotedWorkers = runJournal?.replayExcludedChannels() ?? new Set();
205
+ // run-gates.ts retired (OBS-1025 add.2): seats with two no-verdicts this run are out of every pick, tallied
206
+ // as daemon.ts seeds reviewNoVerdicts — noVerdict rows only, and a checkout-proof timeout never strikes.
207
+ const noVerdicts = new Map();
208
+ for (const event of matchingEvents ?? []) {
209
+ if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
210
+ continue;
211
+ if (event.data.cause === "seat-launch-failed" && event.data.launchCause === "checkout-proof-timeout")
212
+ continue;
213
+ noVerdicts.set(event.data.reviewer, (noVerdicts.get(event.data.reviewer) ?? 0) + 1);
214
+ }
215
+ const retiredReviewers = [...noVerdicts].filter(([, n]) => n >= 2).map(([seat]) => seat);
186
216
  // OBS-947 repair: a resumed run re-dispatches only tasks whose replayed status is pending (or that the
187
217
  // journal never mentions). done, failed and human are all skipped alike, so rendering their preview
188
218
  // picks must not advance the shared reviewHistory — a phantom draw wraps the LRU and makes a later
@@ -243,9 +273,23 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
243
273
  if (judgeDeny?.by === "deny") {
244
274
  lines.push(`REFUSAL OBS-576: judge seat ${cfg.judge.adapter}:${cfg.judge.model} is removed by flat routing.deny (${judgeDeny.entry}) — move worker-only policy to routing.deny.workers`, "");
245
275
  }
276
+ // v2.6.5 T9 (H): alias drift is read from the identity doctor persisted, never re-probed here.
277
+ const aliasHeadlines = aliasIdentityHeadlines(cfg, health);
278
+ if (aliasHeadlines.length)
279
+ lines.push(...aliasHeadlines, "");
246
280
  const entrySeats = routingEntrySeatLines(cfg);
247
281
  if (entrySeats.length)
248
282
  lines.push(...entrySeats, "");
283
+ // v2.6.6 T9 (K): Codex judge/review/consult seats launch with the worker grant (llm.ts, consult.ts), so a
284
+ // doctor-probed escape blocks them too; worker rows carry their own headline below.
285
+ const codexRoles = [
286
+ ...(cfg.judge.adapter === codex.id ? ["judge"] : []),
287
+ ...(pools.review.some((c) => c.adapter === codex.id) ? ["review"] : []),
288
+ ...(cfg.consult.adapter === codex.id || pools.consult.some((c) => c.adapter === codex.id) ? ["consult"] : []),
289
+ ];
290
+ const codexEscape = codexEscapeRoleLine(codexRoles, adapters, health);
291
+ if (codexEscape)
292
+ lines.push(codexEscape, "");
249
293
  // OBS-1185: the floor route() resolved for THIS task — a task hint over the configured/mode floor.
250
294
  const derivation = (t) => {
251
295
  const floor = resolvedFloor(t, cfg);
@@ -359,9 +403,36 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
359
403
  return { line: "none — reviewPolicy judge-only: declared leaf paths", cost: 0 };
360
404
  let rotationSeat = 1;
361
405
  let rotationCount = 0;
362
- const reviewer = pickReviewer(author, pools.review, [], cfg.review.prefer ?? [], task.routingHints?.floor, reviewHistory, (seat, count) => { rotationSeat = seat; rotationCount = count; });
406
+ // Dispatched before: no count, no single-seat claim, no previewed reviewer (and so no rotation draw this
407
+ // plan could name). Never a guess.
408
+ if (resumeDecidesAuthor(matchingEvents ?? [], task.id)) {
409
+ lints.push(`${task.id}: eligible cross-vendor review seat count unknown — resume state decides the author`);
410
+ return { line: "unknown — resume state decides the author", cost: 0 };
411
+ }
412
+ // Never dispatched, daemon.ts subjectAuthors is just the fresh author; its vendor stays off the review.
413
+ const authors = [channelKey(author)];
414
+ const authorVendors = carriedAuthorVendors(pools.review, authors);
415
+ // Production's inputs as the task's first review round sees them: excludeReviewers = daemon badReviewers =
416
+ // replayedReviewerExclusions. run-gates.ts:1303 floors on THIS task's journaled reviewers at their dispatched
417
+ // tiers (daemon taskReviewers) ∪ excludeReviewers, so a replayed exclusion RAISES the floor; :1305 picks
418
+ // with excludeReviewers ∪ retired. prefer only reorders (the seat count below passes none).
419
+ const priorReviewers = [...(matchingEvents ?? []).filter((e) => e.taskId === task.id && typeof e.data.reviewer === "string"
420
+ && ((e.event === "gate-result" && e.data.gate === "review") || e.event === "review-no-verdict"))
421
+ .map((e) => ({ reviewer: e.data.reviewer, tier: e.data.reviewerTier })), ...replayedReviewerExclusions];
422
+ const reviewFloor = gateReviewerFloor(task, cfg, author, pools.review, priorReviewers).floor;
423
+ const exclusions = [...replayedReviewerExclusions, ...retiredReviewers];
424
+ const reviewer = pickReviewer(author, pools.review, exclusions, cfg.review.prefer ?? [], reviewFloor, reviewHistory, (seat, count) => { rotationSeat = seat; rotationCount = count; }, demotedReviewers, authorVendors, authors);
363
425
  if (!reviewer)
364
426
  return { line: "none — no cross-vendor reviewer available", cost: 0 };
427
+ // v2.6.5 T9 (H): one flaked or demoted seat away from no reviewer. Enumerate the production filter
428
+ // (pickReviewer at the gate's floor) and count served identities, so two aliases of one model count once.
429
+ const seats = [];
430
+ for (let s; (s = pickReviewer(author, pools.review, [...exclusions, ...seats.map(channelKey)], [], reviewFloor, [], undefined, demotedReviewers, authorVendors, authors));)
431
+ seats.push(s);
432
+ const identities = new Set(seats.map((c) => c.identity ?? modelId(c.model)));
433
+ if (identities.size === 1) {
434
+ lints.push(`${task.id}: exactly one eligible cross-vendor review seat remains — identity ${[...identities][0]} via ${seats.map(channelKey).join(", ")}; one flake or demotion leaves no reviewer`);
435
+ }
365
436
  // Advance the simulated rotation only for tasks the run would actually dispatch next — the same
366
437
  // rule as readyTasks (src/graph/graph.ts): pending, with every dependency already done. A pending
367
438
  // task behind a parked/failed/pending dependency cannot run, so it consumes no draw (RULING-229-14).
@@ -402,7 +473,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
402
473
  continue;
403
474
  }
404
475
  try {
405
- const r = route(t, cfg, channels, dispatchProfile);
476
+ // daemon.ts:3322: the resumed run routes around its replayed worker exclusions.
477
+ const r = route(t, cfg, channels, dispatchProfile, undefined, demotedWorkers);
406
478
  routed.push({ taskId: t.id, adapter: r.assignment.adapter, model: r.assignment.model });
407
479
  for (const l of r.lints) {
408
480
  const suf = exclusionReason(l);
@@ -412,7 +484,7 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
412
484
  const judge = judgeSeat(t);
413
485
  const review = reviewSeat(t, r.assignment);
414
486
  cost += est + judge.cost + review.cost;
415
- lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`, chainLine(t));
487
+ lines.push(` ${t.id.padEnd(6)} ${t.shape.padEnd(10)} c${String(t.complexity).padEnd(3)}→ ${r.assignment.adapter}:${r.assignment.model} [${r.assignment.channel}/${r.assignment.tier}]${t.timeoutMinutes !== undefined ? ` (timeout ${t.timeoutMinutes}m)` : ""}${t.humanGate ? " (human gate)" : ""}${est ? ` ~$${est.toFixed(2)}` : ""} — ${r.provenance}`, ...[codexCommitHeadline(r.assignment.adapter, adapters, health)].filter((l) => l !== null), ` judge: ${judge.line}${judge.cost ? ` ~$${judge.cost.toFixed(2)}` : ""}`, ` review: ${review.line}${review.cost ? ` ~$${review.cost.toFixed(2)}` : ""}`, chainLine(t));
416
488
  const d = derivation(t);
417
489
  if (d)
418
490
  lines.push(d);
@@ -7,7 +7,7 @@ export declare const HUMAN_AUTHOR: Assignment;
7
7
  export declare function verifyStateDir(cwd: string): string;
8
8
  /** Linked worktrees share operational state with the repository that owns the common git dir. */
9
9
  export declare function verifyStateRoot(cwd: string): Promise<string>;
10
- /** Cache identity is repository content, never a checkout path. */
10
+ /** Repository content plus the effective runner inputs the daemon hashes; never a checkout path. */
11
11
  export declare function baselineCachePath(cwd: string, baseSha: string, commands: Record<string, string>): string;
12
12
  export declare function excludeAuthorProvider(channels: BillingChannel[], author: BillingChannel): BillingChannel[];
13
13
  export declare const VERIFY_HELP = "usage: tickmarkr verify [--base <ref>] [--criteria <file> | --task <id>] [--json]\nThe final verdict and JSON result are written to stdout; progress and diagnostics are written to stderr.\nDo not merge stdout and stderr (for example with 2>&1): doing so corrupts the verdict stream.";
@@ -1,13 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
- import { allAdapters, probeAll, readDoctor, rolePools } from "../../adapters/registry.js";
6
+ import { allAdapters, probeAll, readDoctor, rolePools, writeDoctor } from "../../adapters/registry.js";
7
7
  import { channelKey } from "../../adapters/types.js";
8
8
  import { loadConfig } from "../../config/config.js";
9
9
  import { captureBaseline, detectGateCommands, staleFileCountCommands } from "../../gates/baseline.js";
10
- import { getVerdictStore } from "../../gates/cache.js";
10
+ import { getVerdictStore, runnerInputsHash } from "../../gates/cache.js";
11
11
  import { modelProvider } from "../../gates/review.js";
12
12
  import { runGates } from "../../gates/run-gates.js";
13
13
  import { getTask, loadGraph } from "../../graph/graph.js";
@@ -17,6 +17,7 @@ import { linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.j
17
17
  import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
18
18
  import { withRepositoryLease } from "../../run/lease.js";
19
19
  import { isPidLive } from "../../run/lock.js";
20
+ import { recordLaunchServices } from "../../run/launchservices-check.js";
20
21
  /**
21
22
  * tickmarkr verify — the gate battery as a standalone command (OPERATING-MODEL-2026-08-11 item 3).
22
23
  *
@@ -86,7 +87,7 @@ const hashParts = (parts) => {
86
87
  hash.update(part);
87
88
  return hash.digest("hex").slice(0, 12);
88
89
  };
89
- /** Cache identity is repository content, never a checkout path. */
90
+ /** Repository content plus the effective runner inputs the daemon hashes; never a checkout path. */
90
91
  export function baselineCachePath(cwd, baseSha, commands) {
91
92
  const lockParts = [];
92
93
  for (const file of LOCKFILES) {
@@ -94,13 +95,44 @@ export function baselineCachePath(cwd, baseSha, commands) {
94
95
  lockParts.push(file, existsSync(path) ? readFileSync(path) : "<absent>");
95
96
  }
96
97
  const commandParts = Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)).map(([name, command]) => `${name}\0${command}\0`);
97
- return join(realpathSync(tmpdir()), "tickmarkr-verify", "cache", `baseline-${baseSha.slice(0, 12)}-${hashParts(lockParts)}-${hashParts(commandParts)}.json`);
98
+ return join(realpathSync(tmpdir()), "tickmarkr-verify", "cache", `baseline-${baseSha.slice(0, 12)}-${hashParts(lockParts)}-${hashParts(commandParts)}-${runnerInputsHash()}.json`);
98
99
  }
99
100
  const verdictlessCommands = (baseline, commands) => Object.keys(commands).filter((name) => baseline.commands[name]?.exitCode === undefined);
100
101
  export function excludeAuthorProvider(channels, author) {
101
102
  const provider = modelProvider(author.model, author.vendor);
102
103
  return [author, ...channels.filter((candidate) => candidate !== author && modelProvider(candidate.model, candidate.vendor) !== provider)];
103
104
  }
105
+ /**
106
+ * G (D-874): `--author` names who wrote the diff so review can EXCLUDE it — it never asks for a seat. So
107
+ * it resolves against installed identities (an installed adapter's doctor-listed or configured model),
108
+ * not against the review pool, whose role allow/deny, model-auth and tier gates say nothing about
109
+ * authorship. A pool member resolves to itself, unchanged. Anything else is an author-only identity: its
110
+ * vendor is the configured declaration (the model's override, else the adapter entry's; the adapter's own
111
+ * only when config has no entry), never inferred, and an unseeded model carries no tier of its own — it
112
+ * holds the human sentinel's frontier floor, so the reviewer floor is never lowered. Same-vendor, so it
113
+ * can never be seated. An identity nothing declares refuses by name.
114
+ */
115
+ function authorIdentity(claim, cfg, adapters, health, pool) {
116
+ const [adapter = "", ...rest] = claim.split(":");
117
+ const model = rest.join(":");
118
+ const member = pool.find((c) => c.adapter === adapter && c.model === model);
119
+ if (member)
120
+ return member;
121
+ const a = adapters.find((x) => x.id === adapter);
122
+ const h = health[adapter];
123
+ const seeded = a?.channels(cfg).find((c) => c.model === model);
124
+ const entry = cfg.tiers[adapter];
125
+ const vendor = seeded?.vendor ?? (entry ? entry.modelOverrides?.[model]?.vendor ?? entry.vendor : a?.vendor);
126
+ const why = !a ? `no registered adapter "${adapter}"`
127
+ : !h?.installed ? `doctor does not record ${adapter} as installed`
128
+ : !seeded && !h.models?.includes(model) ? `${adapter} neither lists "${model}" in doctor nor configures it`
129
+ : !vendor ? `no vendor is declared for ${adapter}:${model}`
130
+ : undefined;
131
+ if (why) {
132
+ throw new Error(`--author ${claim} does not name a discoverable author identity (${why}) — name an installed adapter's doctor-listed or configured model as <adapter>:<model>, or human`);
133
+ }
134
+ return seeded ?? { adapter, vendor: vendor, model, channel: entry?.modelOverrides?.[model]?.channel ?? entry?.channel ?? "sub", tier: "frontier" };
135
+ }
104
136
  function recordedMerges(repoRoot) {
105
137
  const runs = join(repoRoot, ".tickmarkr", "runs");
106
138
  if (!existsSync(runs))
@@ -287,6 +319,9 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
287
319
  const pools = rolePools(cfg, adapters, health);
288
320
  judgeChannels = pools.judge;
289
321
  channels = pools.review;
322
+ // Resolved before either branch so an undiscoverable claim refuses here, ahead of any capture —
323
+ // even where recorded authors bind and the claim is otherwise ignored.
324
+ const claimed = values.author && values.author !== "human" ? authorIdentity(values.author, cfg, adapters, health, channels) : undefined;
290
325
  if (owed.length) {
291
326
  // The run's lifetime patch authors bind the reviewer's exclusions; --author never overrides
292
327
  // them. An author the pool cannot resolve leaves review unseatable, which fails closed.
@@ -297,15 +332,9 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
297
332
  if (first)
298
333
  author = { adapter: first.adapter, model: first.model, channel: first.channel, tier: first.tier };
299
334
  }
300
- else if (values.author && values.author !== "human") {
301
- const [adapter, ...rest] = values.author.split(":");
302
- const model = rest.join(":");
303
- const c = channels.find((ch) => ch.adapter === adapter && ch.model === model);
304
- if (!c) {
305
- throw new Error(`--author ${values.author} does not name a discoverable review channel — one of: ${channels.map(channelKey).join(", ") || "(none)"}`);
306
- }
307
- author = { adapter: c.adapter, model: c.model, channel: c.channel, tier: c.tier };
308
- channels = excludeAuthorProvider(channels, c);
335
+ else if (claimed) {
336
+ author = { adapter: claimed.adapter, model: claimed.model, channel: claimed.channel, tier: claimed.tier };
337
+ channels = excludeAuthorProvider(channels, claimed);
309
338
  }
310
339
  else {
311
340
  channels = [...channels, HUMAN_CHANNEL];
@@ -378,7 +407,7 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
378
407
  console.error(`verify: reusing cached baseline for ${mergeBase.slice(0, 12)} (${names.join(", ")}: ${cachePath})`);
379
408
  return cached;
380
409
  }
381
- console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} for ${names.join(", ")} (cached by base, lockfile and command hashes at ${cachePath})`);
410
+ console.error(`verify: capturing baseline at merge-base ${mergeBase.slice(0, 12)} for ${names.join(", ")} (cached by base, lockfile, command and effective runner hashes at ${cachePath})`);
382
411
  // Unleased captures of the SAME checkout overlap, so each owns a unique base worktree and removes
383
412
  // only its own; the pid in the name lets a later verify sweep what a killed one left behind.
384
413
  for (const entry of readdirSync(stateDir)) {
@@ -577,6 +606,27 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
577
606
  ...review,
578
607
  });
579
608
  }
609
+ // A1 (D-787/D-789): one bounded launchservicesd identity read AFTER the verdict is final, so a suite
610
+ // that restarted the daemon is caught and the verdict, exit code and retries never depend on it.
611
+ // It rides the doctor.json verify read; with none there it creates no state file.
612
+ const doctorRoot = fileRoot("doctor.json");
613
+ const priorDoctor = readDoctor(doctorRoot);
614
+ const launchServices = priorDoctor ? await recordLaunchServices(priorDoctor, priorDoctor) : undefined;
615
+ if (launchServices && launchServices.health !== priorDoctor) {
616
+ // Evidence only, not a probe: keep doctor.json's mtime (the auth-cache freshness signal, doctorAgeMs),
617
+ // and a failed write is an advisory diagnostic — the verdict below is returned regardless.
618
+ const doctorFile = join(doctorRoot, ".tickmarkr", "doctor.json");
619
+ try {
620
+ const { atime, mtime } = statSync(doctorFile);
621
+ writeDoctor(doctorRoot, launchServices.health);
622
+ utimesSync(doctorFile, atime, mtime);
623
+ }
624
+ catch (e) {
625
+ console.error(`verify: launchservicesd evidence not persisted (${e instanceof Error ? e.message : String(e)}) (advisory — verdict unchanged)`);
626
+ }
627
+ }
628
+ if (launchServices?.line)
629
+ console.error(`verify: ${launchServices.line}`);
580
630
  if (values.json) {
581
631
  return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, artifactSha256, artifactAvailability, results,
582
632
  ...(owedAfter && owedLines.length ? { owed: { debt: owedAfter.debt, discharged: dischargeRefusal ? [] : owed.map((o) => o.id), ...(dischargeRefusal ? { refused: dischargeRefusal } : {}) } } : {}),
@@ -1,4 +1,4 @@
1
- import { type FleetEditable, type FleetUniverseRow, type LowerLayerModelOverrides, type RoutingMode } from "./config.js";
1
+ import { type FleetEditable, type FleetUniverseRow, type LowerLayerModelOverrides, type MapEntry, type RoutingMode } from "./config.js";
2
2
  /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
3
3
  export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
4
4
  type FleetOverlayWriteFields = {
@@ -20,6 +20,15 @@ type FleetOverlayWriteFields = {
20
20
  };
21
21
  };
22
22
  universe?: FleetUniverseRow[];
23
+ lowerMap?: Record<string, MapEntry>;
24
+ preservedAllow?: {
25
+ adapters?: string[];
26
+ models?: string[];
27
+ };
28
+ lowerAllow?: {
29
+ adapters?: string[];
30
+ models?: string[];
31
+ };
23
32
  };
24
33
  type EffortFree = FleetEditable & {
25
34
  efforts?: undefined;
@@ -39,6 +48,12 @@ export declare function fleetFirstTouchProvenance(note: string, firstTouch: Flee
39
48
  /** Apply only fields fleet authored to the parsed YAML document. Untouched nodes retain their
40
49
  * keys, ordering, scalar style, and comments; fresh provenance is written directly on its tier node. */
41
50
  export declare function renderFleetOverlayWrite(priorBytes: string, write: FleetOverlayWrite): string;
51
+ /** T10: the 1-based line of the key that holds `path` in these overlay bytes — the path's own key, the
52
+ * key whose `null` masks it from above, or a `<<` merge key on it (D-987: the write there refuses) — else
53
+ * undefined (the overlay neither declares nor masks it). */
54
+ export declare function overlayHoldingLine(bytes: string, path: readonly string[]): number | undefined;
55
+ /** T10: does `path` hold a mapping in these overlay bytes — neither absent nor a `null` mask? */
56
+ export declare function overlayHoldsMap(bytes: string, path: readonly string[]): boolean;
42
57
  /** Build the repo overlay fragment fleet would write for edits since session start. */
43
58
  export declare function fleetRepoOverlayFromDelta(initial: FleetEditable, edited: FleetEditable, existingRepo?: Record<string, unknown>, firstTouches?: Readonly<Record<string, FleetFirstTouch>>, universe?: FleetUniverseRow[]): Record<string, unknown>;
44
59
  export declare function repoOverlayYaml(overlay: Record<string, unknown>): string;