tickmarkr 2.5.8 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/adapters/claude-code.js +19 -6
  2. package/dist/adapters/prompt.js +1 -1
  3. package/dist/adapters/qwen.js +30 -3
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/cli/commands/approve.js +15 -3
  6. package/dist/cli/commands/beat.d.ts +2 -0
  7. package/dist/cli/commands/beat.js +28 -29
  8. package/dist/cli/commands/compile.js +11 -0
  9. package/dist/cli/commands/fleet.js +61 -53
  10. package/dist/cli/commands/init.js +1 -1
  11. package/dist/cli/commands/report.js +11 -1
  12. package/dist/cli/commands/verify.d.ts +4 -1
  13. package/dist/cli/commands/verify.js +18 -5
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +6 -4
  16. package/dist/compile/native.js +39 -4
  17. package/dist/config/config.d.ts +41 -3
  18. package/dist/config/config.js +48 -17
  19. package/dist/config/fleet-overlay.js +47 -62
  20. package/dist/drivers/orca.d.ts +1 -1
  21. package/dist/drivers/orca.js +21 -4
  22. package/dist/gates/baseline.d.ts +67 -0
  23. package/dist/gates/baseline.js +172 -10
  24. package/dist/gates/cache.d.ts +8 -0
  25. package/dist/gates/cache.js +26 -13
  26. package/dist/gates/review.d.ts +8 -7
  27. package/dist/gates/review.js +83 -45
  28. package/dist/gates/run-gates.d.ts +4 -1
  29. package/dist/gates/run-gates.js +32 -12
  30. package/dist/gates/test-manifest.d.ts +16 -1
  31. package/dist/gates/test-manifest.js +127 -27
  32. package/dist/gates/test-reporter.js +4 -0
  33. package/dist/graph/graph.d.ts +1 -1
  34. package/dist/graph/graph.js +6 -1
  35. package/dist/graph/schema.d.ts +2 -0
  36. package/dist/graph/schema.js +2 -0
  37. package/dist/route/preference.d.ts +1 -1
  38. package/dist/route/preference.js +10 -39
  39. package/dist/run/daemon.d.ts +7 -1
  40. package/dist/run/daemon.js +237 -53
  41. package/dist/run/git.d.ts +10 -0
  42. package/dist/run/git.js +49 -1
  43. package/dist/run/journal.d.ts +18 -3
  44. package/dist/run/journal.js +141 -17
  45. package/dist/run/merge.d.ts +13 -2
  46. package/dist/run/merge.js +98 -12
  47. package/dist/run/protocol.d.ts +82 -0
  48. package/dist/run/protocol.js +35 -0
  49. package/dist/run/receipt-resolver.d.ts +18 -0
  50. package/dist/run/receipt-resolver.js +132 -0
  51. package/dist/run/supervision.d.ts +14 -1
  52. package/dist/run/supervision.js +122 -24
  53. package/dist/tui/cockpit/evidence-view.d.ts +10 -1
  54. package/dist/tui/cockpit/evidence-view.js +37 -5
  55. package/dist/tui/cockpit/live-runtime.d.ts +4 -0
  56. package/dist/tui/cockpit/live-runtime.js +37 -3
  57. package/dist/tui/cockpit/live-store.d.ts +2 -0
  58. package/dist/tui/ink/fleet-app.d.ts +12 -22
  59. package/dist/tui/ink/fleet-app.js +520 -131
  60. package/package.json +1 -1
  61. package/schema/rungraph.schema.json +7 -0
  62. package/skills/tickmarkr-overseer/SKILL.md +91 -41
  63. package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
@@ -101,6 +101,14 @@ export const CLAUDE_INPUT_BOX = declareInputBox("claude-code", {
101
101
  export function claudeSlug(real) {
102
102
  return real.replace(/[^A-Za-z0-9]/g, "-");
103
103
  }
104
+ // Shared resolution for context and resume identity: never scan a neighbouring session.
105
+ function sessionTranscriptPath(session) {
106
+ const real = realpathSync(session.cwd);
107
+ const sid = session.id.replace(/\.jsonl$/i, "");
108
+ if (!sid || sid.includes("/") || sid.includes("\\") || sid.includes(".."))
109
+ return null;
110
+ return join(homedir(), ".claude", "projects", claudeSlug(real), `${sid}.jsonl`);
111
+ }
104
112
  // newest-first by mtime, bounded — mtime picks WHICH files to scan, never a record's cursor.
105
113
  // Shared by collectUsage (spend) and readClaudeAliasIdentity (OBS-145): same store, same bounds.
106
114
  function newestSessionFiles(dir) {
@@ -318,19 +326,24 @@ export const claudeCode = {
318
326
  return undefined; // missing dir / any throw ⇒ fail open
319
327
  }
320
328
  },
329
+ readSessionTranscript(session) {
330
+ try {
331
+ const file = sessionTranscriptPath(session);
332
+ return file ? { bytes: readFileSync(file).byteLength } : null;
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ },
321
338
  // v1.23 T1: last-turn context fill from ~/.claude/projects/<slug>/<sessionId>.jsonl ONLY.
322
339
  // tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens of the LAST
323
340
  // assistant usage record (not a sum over turns — ctx-watch.sh class; overseer wake signal).
324
341
  // Disk read only: no claude spawn, no pane, no network. null = unknown.
325
342
  contextUsage(session) {
326
343
  try {
327
- const real = realpathSync(session.cwd);
328
- const slug = claudeSlug(real);
329
- // session id is a filename stem (herdr agent_session.value); refuse path traversal.
330
- const sid = session.id.replace(/\.jsonl$/i, "");
331
- if (!sid || sid.includes("/") || sid.includes("\\") || sid.includes(".."))
344
+ const file = sessionTranscriptPath(session);
345
+ if (!file)
332
346
  return null;
333
- const file = join(homedir(), ".claude", "projects", slug, `${sid}.jsonl`);
334
347
  let text;
335
348
  try {
336
349
  text = readFileSync(file, "utf8").slice(0, MAX_SESSION_BYTES);
@@ -18,7 +18,7 @@ Goal: ${task.goal}
18
18
 
19
19
  ## Acceptance criteria (you will be judged against these, verbatim)
20
20
  ${list(task.acceptance.map(renderAcceptanceItem))}
21
- ${task.files.length ? `\n## File scope — touch ONLY paths matching:\n${list(task.files)}\n` : ""}${task.context.length ? `\n## Context (read these first)\n${list(task.context)}\n` : ""}
21
+ ${task.files.length ? `\n## File scope — touch ONLY paths matching:\n${list(task.files)}\n` : ""}${task.context.length ? `\n## Context (read these first)\n${list(task.context)}\n` : ""}${task.outOfScope?.length ? `\n## Out of scope\nStay within the task’s declared bounds; do not pursue these items:\n${list(task.outOfScope)}\n` : ""}
22
22
  ## Rules
23
23
  - Work only inside the current directory (your isolated worktree). Never push. Never switch branches.
24
24
  - Make small atomic git commits as you go (git add + git commit, conventional messages).
@@ -21,7 +21,7 @@ function decodeQwenEvents(events) {
21
21
  continue;
22
22
  if ("type" in event && event.type === "assistant" && "message" in event) {
23
23
  const message = event.message;
24
- if (message && typeof message === "object" && "content" in message && Array.isArray(message.content)) {
24
+ if (message && typeof message === "object" && (!("role" in message) || message.role === "assistant") && "content" in message && Array.isArray(message.content)) {
25
25
  for (const content of message.content) {
26
26
  if (!content || typeof content !== "object" || !(("type" in content) && content.type === "text"))
27
27
  continue;
@@ -64,7 +64,7 @@ function decodeQwenEvents(events) {
64
64
  failed = true;
65
65
  }
66
66
  }
67
- const assistantText = text.join("\n");
67
+ const assistantText = text.join("");
68
68
  const apiError = assistantText.match(/\[API Error:[^\n]*/)?.[0];
69
69
  if (apiError)
70
70
  failed = true;
@@ -95,6 +95,33 @@ function eventArray(raw) {
95
95
  }
96
96
  return undefined;
97
97
  }
98
+ // OBS-1085: a pane capture can begin inside an event's string. Do not repair that
99
+ // event or scan its prose for trailers. A candidate must decode all the way to
100
+ // the surviving array close: this prevents a nested object or an escaped tool
101
+ // echo from masquerading as a top-level event. Keep terminal failures, but only
102
+ // assistant events can contribute completion text. No partial tail is accepted.
103
+ function eventSuffix(raw) {
104
+ // Shell prompts and stderr after the stream may contain `]`; use the event
105
+ // array's object-plus-close boundary, just as eventArray does.
106
+ const close = raw.lastIndexOf("}]");
107
+ if (close === -1)
108
+ return undefined;
109
+ const end = close + 1;
110
+ const boundaries = /,\s*(?=\{)/g;
111
+ for (const boundary of raw.matchAll(boundaries)) {
112
+ const start = boundary.index + boundary[0].length;
113
+ if (start >= end)
114
+ break;
115
+ try {
116
+ const events = JSON.parse(`[${raw.slice(start, end + 1)}`);
117
+ if (!Array.isArray(events) || !events.every((event) => event !== null && typeof event === "object" && "type" in event && typeof event.type === "string"))
118
+ continue;
119
+ return events;
120
+ }
121
+ catch { /* still inside the clipped event; try the next boundary */ }
122
+ }
123
+ return undefined;
124
+ }
98
125
  // Decode qwen's JSON envelope before scanning only decoded assistant text for the worker trailer.
99
126
  export function parseQwenResult(raw, nonce) {
100
127
  const malformed = {
@@ -104,7 +131,7 @@ export function parseQwenResult(raw, nonce) {
104
131
  raw,
105
132
  cause: raw.trim() ? "malformed-verdict" : "empty-output",
106
133
  };
107
- const parsed = eventArray(raw);
134
+ const parsed = eventArray(raw) ?? eventSuffix(raw);
108
135
  if (!parsed)
109
136
  return malformed;
110
137
  const decoded = decodeQwenEvents(parsed);
@@ -188,6 +188,9 @@ export interface WorkerAdapter {
188
188
  listModelsFetchedAt?(): string | undefined;
189
189
  collectUsage?(cwd: string, sinceMs: number): TokenUsage | undefined;
190
190
  contextUsage?(session: SessionRef): ContextUsage | null;
191
+ readSessionTranscript?(session: SessionRef): {
192
+ bytes: number;
193
+ } | null;
191
194
  trust?(repoRoot: string): TrustVerdict;
192
195
  trustDialog: TrustDialog;
193
196
  inputBox?: InputBox;
@@ -88,10 +88,16 @@ export function permittedDecisionVerbs(park) {
88
88
  }
89
89
  if (park.kind === "infra")
90
90
  return ["approve", "recheck"];
91
+ // OBS-1084: an authoring park whose newest gate row is a red tool gate is a runner report (a load
92
+ // flake in an unowned suite), so the battery re-runs on the preserved ref with no worker.
93
+ if (park.kind === "authoring" && isToolGate(park.failedGate))
94
+ return ["approve", "recheck"];
91
95
  if (park.kind === "diff-cap")
92
96
  return ["recheck"]; // OBS-1007: a cap trip is re-gated under a raised cap, never re-bought
93
97
  return ["approve"];
94
98
  }
99
+ const TOOL_GATES = ["build", "test", "lint"];
100
+ const isToolGate = (gate) => gate !== undefined && TOOL_GATES.includes(gate);
95
101
  /** The release marker this command appends for a verb on a park — the fact a read-back must match. */
96
102
  export function releaseForDecision(verb, park) {
97
103
  if (verb === "waive")
@@ -170,7 +176,8 @@ export async function approve(argv, cwd = process.cwd()) {
170
176
  const capPark = park?.kind === ATTEMPT_CAP_RELEASE;
171
177
  const gateFailPark = park?.kind === "gate-fail";
172
178
  const infraPark = park?.kind === "infra" || park?.kind === "diff-cap";
173
- const failedGate = gateFailPark ? park?.failedGate : undefined;
179
+ const authoringRunnerPark = park?.kind === "authoring" && isToolGate(park.failedGate);
180
+ const failedGate = gateFailPark || authoringRunnerPark ? park?.failedGate : undefined;
174
181
  if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
175
182
  if (park?.kind !== "scope-request")
176
183
  throw new Error("--files requires a scope-request park");
@@ -203,6 +210,7 @@ export async function approve(argv, cwd = process.cwd()) {
203
210
  }
204
211
  journal.append("task-approved", taskId, {
205
212
  by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
213
+ ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }), // OBS-1083
206
214
  amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line, definition: taskDefinitionFingerprint(task) },
207
215
  });
208
216
  // Do not write graph.json from this process while the daemon owns it: its sweep materializes
@@ -231,14 +239,18 @@ export async function approve(argv, cwd = process.cwd()) {
231
239
  return disposition(cwd, runId, "fund-fixed-attempt", `upheld the reviewer for ${taskId} in ${runId} — by ${by}`, serialization.contended);
232
240
  }
233
241
  if (recheck) {
234
- if ((!gateFailPark || !failedGate) && !infraPark) {
235
- throw new Error(`--recheck applies to a gate-fail, infra or diff-cap park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
242
+ if ((!gateFailPark || !failedGate) && !infraPark && !authoringRunnerPark) {
243
+ throw new Error(`--recheck applies to a gate-fail, infra, diff-cap or red-tool-gate authoring park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
236
244
  }
237
245
  journal.append("task-approved", taskId, {
238
246
  by,
239
247
  ...(reason ? { reason } : {}),
240
248
  via: "cli",
241
249
  release: RECHECK_RELEASE,
250
+ // OBS-1084: failedGate names the runner report for every recheck. Authoring rechecks additionally
251
+ // carry gate so their durable release row identifies the red tool gate that made recheck admissible.
252
+ ...(failedGate ? { failedGate } : {}),
253
+ ...(authoringRunnerPark && failedGate ? { gate: failedGate } : {}),
242
254
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
243
255
  });
244
256
  return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : `${park.kind} park`}; no gate marked satisfied`, serialization.contended);
@@ -1 +1,3 @@
1
+ import { type SupervisionTier } from "../../run/supervision.js";
1
2
  export declare function beat(argv: string[], cwd?: string): Promise<string>;
3
+ export declare function standDownTier(repoRoot: string, tier: SupervisionTier, seat: string): string;
@@ -1,23 +1,13 @@
1
- import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import { tickmarkrDir } from "../../graph/graph.js";
4
- import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, resolveSupervisionRoot, supervisionStandDownPath, } from "../../run/supervision.js";
5
- // SUP-04: the writer side of supervision, as a VERB. `beatSupervision` and `SUPERVISION_BEAT_MS` shipped
6
- // with exactly one in-repo caller — the daemon, on one tier — so `status` printed
7
- // `orchestrator ARMED / overseer ABSENT / watch ABSENT` while a real overseer worked the run: two thirds
8
- // of a supervision claim were constants dressed as measurements. A seat that supervises from a shell
9
- // (the overseer skill's watcher loop) has no way into a node module; a command does.
10
- //
11
- // ONE-SHOT AND ERROR-PROPAGATING, deliberately not `armSupervision`. That helper is the right shape for
12
- // a long-lived node process and the wrong shape here twice over. Its recurring interval would outlive
13
- // the invocation — in a long-lived dispatcher a later `--stand-down` would disarm only its own timer
14
- // while an earlier one kept writing, flipping DISARMED back to ARMED ten seconds later: the exact
15
- // fail-open this instrument exists to catch. And it swallows write failures by design (an unwritten
16
- // beat ages out, which is the truth for a watcher that must not be crashed by its own instrument) —
17
- // but a CLI that swallows them PRINTS a state nothing recorded, so `beat` says ARMED while `status`
18
- // reads UNREADABLE. Here the writes are unguarded: a failure leaves the process with a non-zero exit
19
- // and a message instead of a claim. The LOOP belongs to the caller, which is what makes the beat
20
- // evidence: stop calling it and the tier ages into STALE on its own.
5
+ import { SUPERVISION_BEAT_MS, SUPERVISION_DEFAULT_THRESHOLD_PCT, SUPERVISION_STALE_MS, SUPERVISION_TIERS, beatSupervision, newSupervisionArm, resolveSupervisionRoot, supervisionStandDownPath, } from "../../run/supervision.js";
6
+ // One-shot ticks reuse a durable arm; only --new-arm (or --loop) acknowledges a stand-down.
7
+ // The loop runs in this process, so its recorded pid belongs to the actual repeating writer.
8
+ // Writes are unguarded and a refused ONE-SHOT tick THROWS: the shipped watcher gates on the exit
9
+ // code with stdout discarded, so a refusal that exits 0 leaves a live watcher believing it is armed
10
+ // while status reads DISARMED. Only the loop returns on stand-down — exiting is its designed end.
21
11
  const VALUE_OPTIONS = ["--seat", "--arm-id", "--pct", "--threshold-pct"];
22
12
  export async function beat(argv, cwd = process.cwd()) {
23
13
  const standDown = argv.includes("--stand-down");
@@ -28,11 +18,11 @@ export async function beat(argv, cwd = process.cwd()) {
28
18
  const named = argv.find((a, i) => !a.startsWith("--") && !VALUE_OPTIONS.includes(argv[i - 1] ?? ""));
29
19
  if (!isTier(named)) {
30
20
  throw new Error(`usage: tickmarkr beat <${SUPERVISION_TIERS.join("|")}> --seat <identity> ` +
31
- `[--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
21
+ `[--new-arm] [--loop] [--arm-id <identity> --pct <0..100> --threshold-pct <0..100>] [--stand-down] — ` +
32
22
  `got ${named ? `\`${named}\`` : "no tier"}`);
33
23
  }
34
24
  // SUP-05: NO SEAT, NO BEAT — and the refusal comes before any write, so a refused invocation leaves
35
- // the tier exactly as it found it. This verb is one-shot: the exitedWriterPid it records has
25
+ // the tier exactly as it found it. In one-shot mode the exitedWriterPid it records has
36
26
  // already exited by the time anyone reads the record, so tier + writer + instant is a beat nobody can
37
27
  // attribute to a seat. Measured 2026-08-26: a consult seat ran the documented loop verbatim and the
38
28
  // board read that tier ARMED with no seat of that tier having armed anything, and a seatless ARMED
@@ -46,12 +36,21 @@ export async function beat(argv, cwd = process.cwd()) {
46
36
  const repoRoot = resolveSupervisionRoot(cwd);
47
37
  if (standDown)
48
38
  return standDownTier(repoRoot, named, seat);
49
- // Clear a stand-down marker left by an earlier session BEFORE beating: a valid marker outranks every
50
- // beat that does not strictly follow it, and two writes landing in the same millisecond do not. An
51
- // uncleared marker would render DISARMED while this verb claimed ARMED, so the removal is unguarded
52
- // too — `force` makes the ordinary "no marker" case a no-op, and anything else is a real failure.
53
- rmSync(supervisionStandDownPath(repoRoot, named), { force: true, recursive: true });
54
- beatSupervision(repoRoot, named, seat, pct === undefined ? undefined : { armId: armId ?? seat, pct, thresholdPct });
39
+ const loop = argv.includes("--loop");
40
+ const arm = argv.includes("--new-arm") || loop ? newSupervisionArm(repoRoot, named, armId) : undefined;
41
+ // Context arm ids retain their existing obligation semantics; they do not implicitly re-arm liveness.
42
+ const observation = pct === undefined ? undefined : { armId: armId ?? arm?.armId ?? seat, pct, thresholdPct };
43
+ do {
44
+ if (!beatSupervision(repoRoot, named, seat, observation, { arm, armId, loop })) {
45
+ const refusal = `${named} DISARMED — stood down; use --new-arm to resume beating`;
46
+ if (loop)
47
+ return refusal;
48
+ throw new Error(refusal);
49
+ }
50
+ if (!loop)
51
+ break;
52
+ await new Promise((resolve) => setTimeout(resolve, SUPERVISION_BEAT_MS));
53
+ } while (loop);
55
54
  return `${named} ARMED as ${seat} — beat again every ${SUPERVISION_BEAT_MS / 1_000}s; the tier reads STALE ${SUPERVISION_STALE_MS / 1_000}s after the last beat`;
56
55
  }
57
56
  /** `--seat <identity>` or `--seat=<identity>`; blank and missing are the same answer — none. */
@@ -84,15 +83,15 @@ function percentageOf(argv, name) {
84
83
  // so the tier reads DISARMED rather than ageing out as a death. Published atomically — written aside,
85
84
  // renamed over — because a torn marker is rejected by the reader, and a rejected stand-down reports a
86
85
  // deliberate hand-off as a death.
87
- function standDownTier(repoRoot, tier, seat) {
86
+ export function standDownTier(repoRoot, tier, seat) {
88
87
  tickmarkrDir(repoRoot); // the write path DOES create — markers land inside the gitignored state dir
89
88
  const p = supervisionStandDownPath(repoRoot, tier);
90
- const tmp = `${p}.${process.pid}.tmp`;
89
+ const tmp = `${p}.${randomUUID()}.tmp`;
91
90
  mkdirSync(dirname(p), { recursive: true });
92
91
  // The marker names the seat for the same reason the beat does: "someone stood this tier down" is not
93
92
  // a hand-off anyone can act on, and on a seat tier the reader rejects an anonymous one outright.
94
93
  writeFileSync(tmp, JSON.stringify({
95
- tier, seat, exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
94
+ tier, seat, standDownId: randomUUID(), exitedWriterPid: process.pid, disarmedAt: new Date().toISOString(),
96
95
  }) + "\n");
97
96
  renameSync(tmp, p);
98
97
  return `${tier} DISARMED — ${seat} handed off; status reads it stood down, not dead`;
@@ -2,6 +2,8 @@ import { isAbsolute, join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { collateralLints, sourceScopeFindings, sourceScopeLints, } from "../../compile/collateral.js";
4
4
  import { CompileError } from "../../compile/common.js";
5
+ import { loadConfig } from "../../config/config.js";
6
+ import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
5
7
  import { compileSource } from "../../compile/index.js";
6
8
  import { retiredLiteralErrors } from "../../compile/retired-literals.js";
7
9
  import { clearCompileRefusal, saveCompileRefusal, saveGraph, stateDirName } from "../../graph/graph.js";
@@ -82,6 +84,15 @@ export async function compile(argv, cwd = process.cwd(), harnessFrom = process.a
82
84
  if (uncovered.length > 0) {
83
85
  throw new CompileError(`${src} has uncovered declared pin obligations:\n${uncovered.map((line) => ` - ${line}`).join("\n")}${diagnostics}`);
84
86
  }
87
+ // v2.5.9 T11 (OBS-1086): a shape whose pool the config denies in full is unroutable for every task
88
+ // that uses it; plan already said so, compile now refuses the seal before any state write so a dry
89
+ // run reaches the same refusal. Pool only: a partial deny leaves live members, and pin/prefer
90
+ // collisions on the default map stay doctor's and resume's business. Scoped to the graph's shapes.
91
+ const deadPools = denyPreferCollisions(loadConfig(cwd), g.tasks.map((task) => task.shape))
92
+ .filter((collision) => collision.kind === "pool");
93
+ if (deadPools.length > 0) {
94
+ throw new CompileError(`${src} routes a task shape to a fully denied pool:\n${deadPools.map((collision) => ` - ${denyPreferCollisionLine(collision)}`).join("\n")}${diagnostics}`);
95
+ }
85
96
  // One bounded read supplies both cross-run surfaces: unresolved findings below and merge facts for
86
97
  // the ancestry check. Neither fact mutates the compiled graph; status and every readiness predicate
87
98
  // remain the source compiler's answer.
@@ -5,7 +5,7 @@ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthE
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";
8
- import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, loadConfigWithMode, overlayBytesLoadError, readOverlayFile, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
8
+ import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, overlayBytesLoadError, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
9
9
  import { exclusionReason, projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { doctor } from "./doctor.js";
@@ -320,14 +320,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
320
320
  ]));
321
321
  const channels = discoverChannels(cfg, adapters, health);
322
322
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
323
+ const stagedDenyOf = (editable) => Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), editable[scope.key] ?? []]));
323
324
  const stagedEditable = (map, deny) => ({
324
325
  ...structuredClone(initial),
325
- denyAdapters: deny.adapters,
326
- denyModels: deny.models,
326
+ ...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, deny[stagedDenyKeyOf(scope)]])),
327
327
  // OBS-1046: the allow complement is staged beside the deny lists, never folded into them
328
328
  allowOut: deny.allowOut ?? initial.allowOut,
329
- denyWorkersAdapters: deny.workersAdapters,
330
- denyWorkersModels: deny.workersModels,
331
329
  map,
332
330
  });
333
331
  const candidateMemo = new Map();
@@ -573,39 +571,57 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
573
571
  const floor = cfgPreview.routing.floors[shape];
574
572
  const collisions = denyPreferCollisions(cfgPreview, [shape]);
575
573
  const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
576
- // discovery already dropped a channel whose model probe failed — it still gets its own row
577
- const unauthedRows = [
578
- ...unauthedClis.map((adapter) => `${adapter} — CLI unauthed — re-probe with tickmarkr doctor`),
579
- ...unauthedModels.filter(({ key }) => !discovered.has(key))
580
- .map(({ key, reason }) => `${key.replace(":", "/")} — unauthed (${reason}) — re-probe with tickmarkr doctor`),
581
- ];
582
- return [...unauthedRows, ...previewChannels.flatMap((c) => {
583
- const key = `${c.adapter}:${c.model}`;
584
- const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
585
- if (health[c.adapter]?.authed === false)
586
- reasons.push(`${c.adapter} CLI unauthed — re-probe with tickmarkr doctor`);
587
- else if (health[c.adapter]?.modelAuth?.[c.model]?.authed === false)
588
- reasons.push("unauthed — re-probe with tickmarkr doctor");
589
- if (entry?.pin && key !== `${entry.pin.via}:${entry.pin.model}`) {
590
- reasons.push(`not routing.map.${shape}.pin (${entry.pin.via}:${entry.pin.model}) — not manageable here`);
591
- }
592
- if (entry?.pool && !entry.pool.channels.includes(key)) {
593
- reasons.push(`outside routing.map.${shape}.pool — not manageable here`);
594
- }
595
- if (floor && TIER_RANK[c.tier] < TIER_RANK[floor]) {
596
- reasons.push(`below routing.floors.${shape} (${floor}) — not manageable here`);
597
- }
598
- if (entry?.prefer?.some((p) => entryMatchesChannel(p, c))) {
599
- reasons.push(`task hint: routing.map.${shape}.prefer names it — not manageable here`);
600
- }
601
- for (const collision of collisions) {
602
- const named = collision.detail.split(" > ").some((p) => entryMatchesChannel(p, c));
603
- if (named || offered.size === 0)
604
- reasons.push(denyPreferCollisionLine(collision));
605
- }
606
- // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
607
- return reasons.length ? [`${c.adapter}/${c.model} — ${reasons.join("; ")}`] : [];
608
- })];
574
+ // T2 review (D-225): discovery already dropped a channel whose model probe failed — it is
575
+ // rebuilt as a structured channel from its adapter's own declaration and walks the SAME
576
+ // reach + collector path as every other row, the auth reason appended LAST; never a bare string
577
+ const withIdentity = (adapter, declared) => {
578
+ const identity = health[adapter]?.modelAuth?.[declared.model]?.identity ?? health[adapter]?.modelIdentities?.[declared.model];
579
+ return identity ? { ...declared, identity } : declared;
580
+ };
581
+ const failedProbes = unauthedModels.filter(({ key }) => !discovered.has(key)).flatMap(({ key, adapter, reason }) => {
582
+ const declared = adapters.find((a) => a.id === adapter)?.channels(cfg).find((c) => `${c.adapter}:${c.model}` === key);
583
+ return declared ? [{ ...withIdentity(adapter, declared), authReason: `unauthed (${reason}) — re-probe with tickmarkr doctor` }] : [];
584
+ });
585
+ // T2 review round 6: an installed-but-unauthed CLI is dropped by discovery AND by the model
586
+ // probe list, so its configured channels are rebuilt from the adapter's own declaration too —
587
+ // each greys with its reach and covering entry, the CLI auth reason last; never one bare adapter line
588
+ const unauthedCliChannels = unauthedClis.flatMap((adapter) => (adapters.find((a) => a.id === adapter)?.channels(cfg) ?? [])
589
+ .map((declared) => ({ ...withIdentity(adapter, declared), authReason: `${adapter} CLI unauthed — re-probe with tickmarkr doctor` })));
590
+ // D-225: the ledger is the EXCLUDED half of a closed partition — a channel the picker offers
591
+ // never greys beside itself; its pin/pool/floor/prefer captions stay in excludedNote
592
+ const ledgerChannels = [...previewChannels.map((c) => ({ ...c, authReason: undefined })), ...failedProbes, ...unauthedCliChannels]
593
+ .filter((c) => !offered.has(`${c.adapter}:${c.model}`));
594
+ return ledgerChannels.flatMap((c) => {
595
+ const key = `${c.adapter}:${c.model}`;
596
+ const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
597
+ // OBS-1065: a greyed picker row carries its reach beside its reasons, like the models view
598
+ const allSeats = exclusionCollector(c, cfgPreview.routing, "judge");
599
+ const reach = allSeats.some((scope) => scope.by === "deny") ? "out all"
600
+ : allSeats.length ? "out allow" : reasons.length ? "out workers" : "in";
601
+ if (!c.authReason && health[c.adapter]?.modelAuth?.[c.model]?.authed === false)
602
+ reasons.push("unauthed — re-probe with tickmarkr doctor");
603
+ if (entry?.pin && key !== `${entry.pin.via}:${entry.pin.model}`) {
604
+ reasons.push(`not routing.map.${shape}.pin (${entry.pin.via}:${entry.pin.model}) — not manageable here`);
605
+ }
606
+ if (entry?.pool && !entry.pool.channels.includes(key)) {
607
+ reasons.push(`outside routing.map.${shape}.pool — not manageable here`);
608
+ }
609
+ if (floor && TIER_RANK[c.tier] < TIER_RANK[floor]) {
610
+ reasons.push(`below routing.floors.${shape} (${floor}) — not manageable here`);
611
+ }
612
+ if (entry?.prefer?.some((p) => entryMatchesChannel(p, c))) {
613
+ reasons.push(`task hint: routing.map.${shape}.prefer names it — not manageable here`);
614
+ }
615
+ for (const collision of collisions) {
616
+ const named = collision.detail.split(" > ").some((p) => entryMatchesChannel(p, c));
617
+ if (named || offered.size === 0)
618
+ reasons.push(denyPreferCollisionLine(collision));
619
+ }
620
+ if (c.authReason)
621
+ reasons.push(c.authReason);
622
+ // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
623
+ return reasons.length ? [`${c.adapter}/${c.model} — reach: ${reach} — ${reasons.join("; ")}`] : [];
624
+ });
609
625
  };
610
626
  const candidatesForShape = (shape, mode, map, deny) => {
611
627
  const preview = previewCfg(mode, map, deny);
@@ -616,6 +632,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
616
632
  id: `${candidate.assignment.adapter}:${candidate.assignment.model}`,
617
633
  label: candidateRow(candidate, cfg.pricing),
618
634
  pin: { via: candidate.assignment.adapter, model: candidate.assignment.model },
635
+ // OBS-1065: a picker lift reads eligibility from here, not the ledger strings
636
+ belowFloor: candidate.belowFloor,
619
637
  }));
620
638
  const offered = new Set(rows.map((row) => row.id));
621
639
  const notes = [excludedNoteFor(cfgPreview), ...shapeExclusionCaptions(shape, map, cfgPreview)].filter((note) => note !== undefined);
@@ -644,12 +662,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
644
662
  let pendingWrite = null;
645
663
  const reviewOverlay = (state) => {
646
664
  const staged = structuredClone(initial);
647
- staged.denyAdapters = state.denyAdapters;
648
- staged.denyModels = state.denyModels;
665
+ // OBS-994/FL-1, OBS-1099 add.1: every schema-enumerated deny scope rides the same review/write funnel
666
+ for (const scope of DENY_SCOPES)
667
+ staged[scope.key] = state[scope.key];
649
668
  staged.allowOut = state.allowOut ?? initial.allowOut;
650
- // OBS-994/FL-1: the workers-only deny scope rides the same review/write funnel as the flat one.
651
- staged.denyWorkersAdapters = state.denyWorkersAdapters;
652
- staged.denyWorkersModels = state.denyWorkersModels;
653
669
  staged.map = state.map;
654
670
  const today = new Date().toISOString().slice(0, 10);
655
671
  for (const classification of state.classifications) {
@@ -700,11 +716,8 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
700
716
  ageMs: doctorAgeMs(cwd),
701
717
  adapters,
702
718
  health,
703
- initialDenyAdapters: editable.denyAdapters,
704
- initialDenyModels: editable.denyModels,
719
+ ...Object.fromEntries(DENY_SCOPES.map((scope) => [initialDenyPropOf(scope), editable[scope.key] ?? []])),
705
720
  initialAllowOut: editable.allowOut,
706
- initialDenyWorkersAdapters: editable.denyWorkersAdapters,
707
- initialDenyWorkersModels: editable.denyWorkersModels,
708
721
  modelGroups,
709
722
  initialMode: rm.mode.mode,
710
723
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
@@ -768,11 +781,6 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
768
781
  return {
769
782
  props,
770
783
  commit,
771
- renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, {
772
- adapters: editable.denyAdapters,
773
- models: editable.denyModels,
774
- workersAdapters: editable.denyWorkersAdapters ?? [],
775
- workersModels: editable.denyWorkersModels ?? [],
776
- })),
784
+ renderWhy: () => renderFleetWhy(projectedShapeRows(rm.mode.mode, editable.map, stagedDenyOf(editable))),
777
785
  };
778
786
  }
@@ -115,7 +115,7 @@ A run is green only when ALL of these hold: the run-end event exists in the jour
115
115
 
116
116
  ### Verified handoffs
117
117
 
118
- When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the TARGET composer afterward; if the sent text still sits unsubmitted after ~15 s, send-keys enter to that pane once and read again (OBS-975); never report "relayed" without read-back.
118
+ When relaying missions between agents, never use bare send-text (\`herdr agent send\` / pane send-text) — it omits Enter. Use \`herdr pane run <pane> "<message>"\` or \`herdr notification show "<message>"\`. Confirm delivery by reading the TARGET composer afterward; if the sent text still sits unsubmitted after ~15 s, retry the Enter only when that composer read shows the staged text and no permission prompt or numbered choice holds focus — an Enter sent onto an active prompt approves it or picks a choice instead of submitting the message (OBS-1119). Under that guard, send-keys enter to that pane once and read again (OBS-975); when a prompt or choice holds focus instead, resolve it first and re-read before retrying. Never report "relayed" without read-back.
119
119
 
120
120
  ### Orient before you act — this block may be the ONLY guidance your host loaded
121
121
 
@@ -11,6 +11,7 @@ import { estimateCosts } from "../../report/cost.js";
11
11
  import { assignmentChannel, buildOperatorRecord, formatChannelMoney, formatChannelTokens, formatTokenUsage as fields, totalTokens as total, } from "../../report/operator-record.js";
12
12
  import { cellsOf, cellSummary } from "../../route/profile.js";
13
13
  import { Journal, loadRoutingProfile } from "../../run/journal.js";
14
+ import { formatTipProof, runEndTipProof } from "../../run/daemon.js";
14
15
  import { deriveRunCockpitData } from "../../tui/cockpit/derive.js";
15
16
  const n = (x) => x.toLocaleString("en-US"); // explicit locale — CI/darwin flake guard
16
17
  const EM = "—";
@@ -169,6 +170,15 @@ const VERIFICATION_READING = {
169
170
  failed: "FAILED — the run did not verify its own tip",
170
171
  absent: "absent — no tip verification recorded: neither passed nor failed",
171
172
  };
173
+ function verificationReading(runId, events) {
174
+ const state = verificationOf(runId, events);
175
+ const proof = runEndTipProof(closedCycle(events));
176
+ if ((state === "passed" || state === "cached") && proof.gates?.length) {
177
+ const allCarried = proof.gates.every(({ kind }) => kind === "reused");
178
+ return `${allCarried ? "cached" : "passed"} — ${formatTipProof(proof)}`;
179
+ }
180
+ return VERIFICATION_READING[state];
181
+ }
172
182
  // VIS-07 / REC-01: derived only from the run journal, telemetry, and local configuration.
173
183
  export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
174
184
  const runStart = events.find((e) => e.event === "run-start");
@@ -205,7 +215,7 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
205
215
  `- **done:** ${count("done")}`,
206
216
  `- **failed:** ${count("failed")}`,
207
217
  `- **human:** ${count("human")}`,
208
- `- **verification:** ${VERIFICATION_READING[verificationOf(runId, events)]}`,
218
+ `- **verification:** ${verificationReading(runId, events)}`,
209
219
  "",
210
220
  "## Usage & efficiency",
211
221
  "",
@@ -1,4 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../../adapters/types.js";
2
+ import { type GateEvidenceOptions } from "../../gates/baseline.js";
2
3
  import { type AcceptanceItem } from "../../graph/schema.js";
3
4
  export declare function parseCriteria(text: string): AcceptanceItem[];
4
5
  export declare const HUMAN_CHANNEL: BillingChannel;
@@ -10,7 +11,9 @@ export declare function verifyStateRoot(cwd: string): Promise<string>;
10
11
  export declare function baselineCachePath(cwd: string, baseSha: string, commands: Record<string, string>): string;
11
12
  export declare function excludeAuthorProvider(channels: BillingChannel[], author: BillingChannel): BillingChannel[];
12
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.";
13
- export declare function verify(argv: string[], cwd?: string): Promise<{
14
+ export declare function verify(argv: string[], cwd?: string, options?: {
15
+ evidence?: GateEvidenceOptions;
16
+ }): Promise<{
14
17
  out: string;
15
18
  code: number;
16
19
  }>;
@@ -130,7 +130,7 @@ async function warnWideTaskRange(cwd, stateRoot, taskId, mergeBase) {
130
130
  export const VERIFY_HELP = `usage: tickmarkr verify [--base <ref>] [--criteria <file> | --task <id>] [--json]
131
131
  The final verdict and JSON result are written to stdout; progress and diagnostics are written to stderr.
132
132
  Do not merge stdout and stderr (for example with 2>&1): doing so corrupts the verdict stream.`;
133
- export async function verify(argv, cwd = process.cwd()) {
133
+ export async function verify(argv, cwd = process.cwd(), options = {}) {
134
134
  if (argv.some((arg) => arg === "--help" || arg === "-h"))
135
135
  return { out: VERIFY_HELP, code: 0 };
136
136
  const { values } = parseArgs({
@@ -307,6 +307,8 @@ export async function verify(argv, cwd = process.cwd()) {
307
307
  try {
308
308
  linkNodeModules(cwd, baseDir, { force: true });
309
309
  baseline = await captureBaseline(baseDir, commands);
310
+ if (baseline.refusal)
311
+ return { out: baseline.refusal, code: 1 };
310
312
  const missing = verdictlessCommands(baseline, commands);
311
313
  if (missing.length) {
312
314
  writeFileSync(verdictlessMarker, JSON.stringify({ base: mergeBase, commands: missing }) + "\n");
@@ -328,6 +330,7 @@ export async function verify(argv, cwd = process.cwd()) {
328
330
  worktree: cwd, baseRef: mergeBase,
329
331
  result: { ok: true, summary: "standalone verify — no worker claims to trust", deviations: [], raw: "" },
330
332
  author, commands, baseline, channels, ...(judgeChannels ? { judgeChannels } : {}), adapters, cfg,
333
+ evidence: options.evidence,
331
334
  verificationScope: "standalone", artifactDir, stateDir: join(stateRoot, ".tickmarkr"),
332
335
  onGate: (e) => {
333
336
  if (e.phase === "start")
@@ -345,17 +348,27 @@ export async function verify(argv, cwd = process.cwd()) {
345
348
  return match ? [{ classification: match[1], note: match[2], reviewer: result.meta?.reviewer }] : [];
346
349
  }));
347
350
  const artifactPath = join(artifactDir, "verify-results.json");
348
- writeFileSync(artifactPath, JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
349
- console.error(`verify: artifacts written to ${artifactPath}`);
351
+ const artifactBytes = Buffer.from(JSON.stringify({ base: baseTip, head, mergeBase, green, gateRows: results, reviewFindings }, null, 2) + "\n");
352
+ let artifactSha256 = null;
353
+ let artifactAvailability = "capture-failed";
354
+ try {
355
+ writeFileSync(artifactPath, artifactBytes, { flag: "wx" });
356
+ artifactSha256 = createHash("sha256").update(artifactBytes).digest("hex");
357
+ artifactAvailability = "available";
358
+ console.error(`verify: artifacts written to ${artifactPath}`);
359
+ }
360
+ catch {
361
+ console.error(`verify: evidence capture failed for ${artifactPath}`);
362
+ }
350
363
  const review = reviewRows.at(-1);
351
364
  if (recordJournal && review) {
352
365
  recordJournal.append("review-leg2", values.task ?? "VERIFY", {
353
- base: baseTip, head, mergeBase, author: channelKey(author), artifactPath,
366
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath, artifactSha256, artifactAvailability,
354
367
  ...review,
355
368
  });
356
369
  }
357
370
  if (values.json) {
358
- return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, results }, null, 2), code: green ? 0 : 2 };
371
+ return { out: JSON.stringify({ base: baseTip, head, mergeBase, green, artifactPath, artifactSha256, artifactAvailability, results }, null, 2), code: green ? 0 : 2 };
359
372
  }
360
373
  const lines = results.map((r) => `${r.pass ? "PASS" : "FAIL"} ${r.gate}\n${r.details.split("\n").map((l) => ` ${l}`).join("\n")}`);
361
374
  const verdict = green
@@ -176,6 +176,8 @@ export declare const COMMAND_HELP: {
176
176
  description: string;
177
177
  options: {
178
178
  "--seat <identity>": string;
179
+ "--new-arm": string;
180
+ "--loop": string;
179
181
  "--arm-id <identity>": string;
180
182
  "--pct <0..100>": string;
181
183
  "--threshold-pct <0..100>": string;