tickmarkr 1.69.0 → 1.71.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.
@@ -1,9 +1,10 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
5
4
  import { probeVersion } from "./claude-code.js";
6
5
  import { parseWorkerResult } from "./prompt.js";
6
+ import { runInteractiveSeed } from "../run/interactive-seed.js";
7
+ import { sh } from "../run/git.js";
7
8
  import { channelsFromConfig, MODEL_ID_RE, shq, TokenUsageSchema } from "./types.js";
8
9
  // KIMI-03 → v1.58 T5: the "no harness-readable counter" block (research F-6, 2026-07-17) is
9
10
  // LIFTED for collectUsage — kimi 0.27.0 writes a wire journal per agent at
@@ -117,40 +118,18 @@ export function confirmKimiSeedBanner(banner, assignedModel) {
117
118
  }
118
119
  return { ok: true, sessionId: kimiBannerSessionId(banner) };
119
120
  }
120
- // Shared launch-then-seed surface (T6) + banner confirm helpers (T7). One definition so the
121
- // adapter property and runKimiInteractiveSeed cannot drift.
121
+ // Shared launch-then-seed surface (T6) + banner confirm (T7/T2). One definition so the adapter
122
+ // property and the daemon's generic runInteractiveSeed path cannot drift.
122
123
  const KIMI_SEED = {
123
124
  launch: (model) => `kimi -y -m ${shq(kimiAlias(model))}`,
124
125
  readinessMatch: "Send /help for help information.",
125
126
  seedLine: (promptFile) => `Read ${promptFile} and do exactly what it says.`,
127
+ confirmBanner: confirmKimiSeedBanner,
126
128
  };
127
- // v1.69 T7: kimi-local launch-then-seed that confirms the banner model and captures the session id
128
- // from the SAME pane text already present for the readiness match. No extra probe, no extra
129
- // dispatch — one read of the launch banner, then seed or fail closed.
129
+ // v1.69 T7 / v1.71 T2: thin wrapper over the daemon's generic dispatch path for kimi-only tests.
130
130
  export async function runKimiInteractiveSeed(opts) {
131
- await opts.driver.run(opts.slot, KIMI_SEED.launch(opts.assignment.model));
132
- const ready = await opts.driver.waitOutput(opts.slot, KIMI_SEED.readinessMatch, opts.taskTimeoutMinutes * 60_000);
133
- // Banner text already on the pane for the readiness match — the only text model/session use.
134
- const banner = await opts.driver.read(opts.slot, 1000);
135
- if (!ready) {
136
- return { output: banner, seedFailed: true, seedError: `readiness pattern not seen: ${KIMI_SEED.readinessMatch}` };
137
- }
138
- const confirm = confirmKimiSeedBanner(banner, opts.assignment.model);
139
- if (!confirm.ok) {
140
- return { output: banner, seedFailed: true, seedError: confirm.error };
141
- }
142
- const seedText = KIMI_SEED.seedLine(opts.promptFile);
143
- await opts.driver.run(opts.slot, seedText);
144
- let output = "";
145
- for (let attempt = 0; attempt < 5; attempt++) {
146
- await new Promise((r) => setTimeout(r, 200));
147
- output = await opts.driver.read(opts.slot, 1000);
148
- const bottom = output.trimEnd().split("\n").pop() ?? "";
149
- if (!bottom.includes(seedText)) {
150
- return { output, seedFailed: false, sessionId: confirm.sessionId };
151
- }
152
- }
153
- return { output, seedFailed: true, seedError: "seed line never left the input box", sessionId: confirm.sessionId };
131
+ const r = await runInteractiveSeed({ ...opts, adapter: kimi });
132
+ return { output: r.output, seedFailed: r.seedFailed, seedError: r.seedError, sessionId: r.sessionId };
154
133
  }
155
134
  export const kimi = {
156
135
  id: "kimi",
@@ -183,10 +162,8 @@ export const kimi = {
183
162
  // null → the daemon's print fallback (types.ts:101) keeps kimi workers visible without a TUI.
184
163
  interactiveCommand: () => null,
185
164
  // v1.69 T6/T7: launch the real TUI, wait for the cold-start readiness marker, then submit the
186
- // prompt as one user turn. Banner model/session confirmation for seed mode lives in
187
- // runKimiInteractiveSeed / confirmKimiSeedBanner (same pane text as the readiness match — no
188
- // extra probe). Live-probed 2026-07-22 (kimi 0.28.1): `kimi -y` opens the TUI with yolo active
189
- // and prints the model/session lines plus "Send /help for help information." before the input box.
165
+ // prompt as one user turn. Banner model/session confirmation runs on the daemon's generic
166
+ // runInteractiveSeed path via confirmBanner on KIMI_SEED (not a separate dispatch helper).
190
167
  interactiveSeed: KIMI_SEED,
191
168
  // v1.53 T3 resume — live-probed 2026-07-18: `-p` + `-S <id>` compose cleanly (no OBS-67-class
192
169
  // flag rejection) and the resumed session carries prior conversation state. `-S <id>` is the
@@ -298,7 +275,7 @@ export const kimi = {
298
275
  }
299
276
  },
300
277
  listModels: async () => {
301
- const r = spawnSync("kimi", ["provider", "list", "--json"], { encoding: "utf8", timeout: 15000 });
302
- return r.error || r.status !== 0 ? [] : parseKimiModels(r.stdout || "");
278
+ const r = await sh("kimi provider list --json", process.cwd(), 15000);
279
+ return r.code !== 0 ? [] : parseKimiModels(r.stdout || "");
303
280
  },
304
281
  };
@@ -3,6 +3,28 @@ import { type RoutingProfile } from "../route/profile.js";
3
3
  import { type AuthHealth, type BillingChannel, type WorkerAdapter } from "./types.js";
4
4
  export declare const CANDIDATE_CLI_CATALOG: readonly ["kimi", "gemini", "qwen", "aider", "goose", "amp", "droid", "auggie", "crush"];
5
5
  export declare const REGISTERED_ADAPTER_BINARIES: readonly ["claude", "codex", "cursor-agent", "opencode", "pi", "grok"];
6
+ export declare function resolveShellBinary(bin: string, cwd?: string): {
7
+ resolved?: string;
8
+ all: string[];
9
+ };
10
+ export declare function probeVersionShell(bin: string, cwd?: string): AuthHealth & {
11
+ binaryPaths?: string[];
12
+ };
13
+ export declare function binaryShadowWarnings(adapters: WorkerAdapter[], health: Record<string, AuthHealth>, cwd?: string, resolve?: (bin: string, cwd: string) => {
14
+ resolved?: string;
15
+ all: string[];
16
+ }): string[];
17
+ export declare function invalidConfiguredModels(cfg: TickmarkrConfig, adapterId: string, h: AuthHealth | undefined): string[];
18
+ export declare function modelAliasExclusions(cfg: TickmarkrConfig, adapters: WorkerAdapter[], health: Record<string, AuthHealth>): {
19
+ key: string;
20
+ adapter: string;
21
+ model: string;
22
+ }[];
23
+ export declare function modelAliasLine(excluded: {
24
+ key: string;
25
+ adapter: string;
26
+ model: string;
27
+ }[]): string;
6
28
  export type CandidateCliDetection = {
7
29
  binary: string;
8
30
  version: string | undefined;
@@ -19,7 +41,9 @@ export declare function allAdapters(opts?: {
19
41
  fakeScriptPath?: string;
20
42
  }): WorkerAdapter[];
21
43
  export declare function getAdapter(id: string, adapters: WorkerAdapter[]): WorkerAdapter;
22
- export declare function probeAll(adapters: WorkerAdapter[]): Promise<Record<string, AuthHealth>>;
44
+ export declare function probeAll(adapters: WorkerAdapter[], opts?: {
45
+ cwd?: string;
46
+ }): Promise<Record<string, AuthHealth>>;
23
47
  export type ProbeModelStatus = "ok" | "timeout" | "failed";
24
48
  export type ProbeModelProgress = (adapter: string, model: string, status: ProbeModelStatus, durationMs: number) => void;
25
49
  export interface AutoPreferDoc {
@@ -18,11 +18,89 @@ import { grok } from "./grok.js";
18
18
  import { kimi } from "./kimi.js";
19
19
  import { opencode } from "./opencode.js";
20
20
  import { pi } from "./pi.js";
21
- import { channelKey, modelAuthed, QUOTA_RE } from "./types.js";
21
+ import { sealHerdrEnv } from "../drivers/subprocess.js";
22
+ import { channelKey, modelAuthed, QUOTA_RE, shq } from "./types.js";
22
23
  // Agent-CLI binary names with no tickmarkr adapter — doctor sweeps these advisory-only (v1.48 T1).
23
24
  export const CANDIDATE_CLI_CATALOG = ["kimi", "gemini", "qwen", "aider", "goose", "amp", "droid", "auggie", "crush"];
24
25
  // Binaries probed by registered adapters — kept beside the catalog so a test can forbid overlap.
25
26
  export const REGISTERED_ADAPTER_BINARIES = ["claude", "codex", "cursor-agent", "opencode", "pi", "grok"];
27
+ // OBS-117: worker panes run commands through `bash -lc` (SubprocessDriver + sh()), not bare spawnSync(bin).
28
+ // Doctor must resolve adapter binaries the same way so a shadow install on PATH cannot disagree with dispatch.
29
+ function shellSpawnSync(cmd, cwd, timeoutMs = 10000) {
30
+ const r = spawnSync("bash", ["-lc", cmd], {
31
+ encoding: "utf8",
32
+ cwd,
33
+ timeout: timeoutMs,
34
+ env: sealHerdrEnv(process.env),
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ return { code: r.status ?? 1, stdout: (r.stdout || "").trim(), stderr: (r.stderr || "").trim() };
38
+ }
39
+ export function resolveShellBinary(bin, cwd = process.cwd()) {
40
+ const r = shellSpawnSync(`type -a ${shq(bin)} 2>/dev/null | sed -n 's/^.* is \\(.*\\)$/\\1/p'`, cwd);
41
+ const all = r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
42
+ return { resolved: all[0], all };
43
+ }
44
+ export function probeVersionShell(bin, cwd = process.cwd()) {
45
+ const { resolved, all } = resolveShellBinary(bin, cwd);
46
+ if (!resolved)
47
+ return { installed: false, authed: false, models: [] };
48
+ const r = shellSpawnSync(`${shq(resolved)} --version`, cwd);
49
+ if (r.code !== 0)
50
+ return { installed: false, authed: false, models: [], binaryPaths: all };
51
+ return {
52
+ installed: true,
53
+ authed: true,
54
+ version: (r.stdout || r.stderr).trim().split("\n")[0],
55
+ models: [],
56
+ note: "auth assumed; verified at dispatch (failover on auth/quota errors)",
57
+ binaryPaths: all,
58
+ };
59
+ }
60
+ export function binaryShadowWarnings(adapters, health, cwd = process.cwd(), resolve) {
61
+ const resolveFn = resolve ?? resolveShellBinary;
62
+ const out = [];
63
+ for (const a of adapters) {
64
+ const bin = a.hardcodedFlags?.binary;
65
+ if (!bin || !health[a.id]?.installed)
66
+ continue;
67
+ const { resolved, all } = resolveFn(bin, cwd);
68
+ if (all.length <= 1)
69
+ continue;
70
+ out.push(`binary shadow: ${a.id} resolves to ${resolved ?? bin} but ${all.length} installs on PATH — ${all.join(", ")}`);
71
+ }
72
+ return out;
73
+ }
74
+ function modelListValidated(h) {
75
+ return !!h?.modelsDetectedAt && Array.isArray(h.models);
76
+ }
77
+ export function invalidConfiguredModels(cfg, adapterId, h) {
78
+ if (!modelListValidated(h))
79
+ return [];
80
+ const listed = new Set(h.models);
81
+ const configured = Object.keys(cfg.tiers[adapterId]?.models ?? {});
82
+ // Fail open until the CLI list intersects at least one configured model — proves the list is authoritative
83
+ // for this adapter's tier namespace (MODEL-04: unrelated detected ids must not invalidate routing).
84
+ if (!configured.some((m) => listed.has(m)))
85
+ return [];
86
+ return configured.filter((m) => !listed.has(m));
87
+ }
88
+ export function modelAliasExclusions(cfg, adapters, health) {
89
+ const out = [];
90
+ for (const a of adapters) {
91
+ const h = health[a.id];
92
+ if (!h?.installed || !h?.authed)
93
+ continue;
94
+ for (const model of invalidConfiguredModels(cfg, a.id, h)) {
95
+ out.push({ key: channelKey({ adapter: a.id, model }), adapter: a.id, model });
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ export function modelAliasLine(excluded) {
101
+ const parts = excluded.map(({ key, adapter }) => `${key} (not in ${adapter}'s reported model list)`);
102
+ return `model alias: ${excluded.length} channel(s) invalid — ${parts.join(", ")}`;
103
+ }
26
104
  function candidateOnPath(bin, pathEnv) {
27
105
  const r = spawnSync("which", [bin], { encoding: "utf8", env: { ...process.env, PATH: pathEnv } });
28
106
  return r.status === 0 && r.stdout.trim().length > 0;
@@ -105,9 +183,20 @@ export function getAdapter(id, adapters) {
105
183
  throw new Error(`unknown adapter ${id} (have: ${adapters.map((a) => a.id).join(", ")})`);
106
184
  return a;
107
185
  }
108
- export async function probeAll(adapters) {
186
+ export async function probeAll(adapters, opts = {}) {
187
+ const cwd = opts.cwd ?? process.cwd();
109
188
  const out = {};
110
- await Promise.all(adapters.map(async (a) => { out[a.id] = await a.probe(); }));
189
+ await Promise.all(adapters.map(async (a) => {
190
+ let h = await a.probe();
191
+ const bin = a.hardcodedFlags?.binary;
192
+ if (bin) {
193
+ const shell = probeVersionShell(bin, cwd);
194
+ h = { ...h, installed: shell.installed, version: shell.version ?? h.version };
195
+ if (!shell.installed)
196
+ h = { ...h, installed: false, authed: false };
197
+ }
198
+ out[a.id] = h;
199
+ }));
111
200
  return out;
112
201
  }
113
202
  const MODEL_PROBE_PROMPT = "Reply with exactly OK and nothing else.";
@@ -317,10 +406,12 @@ export function discoverChannels(cfg, adapters, health) {
317
406
  .flatMap((a) => {
318
407
  const h = health[a.id];
319
408
  const s = h?.servable;
409
+ const invalid = new Set(invalidConfiguredModels(cfg, a.id, h));
320
410
  // T2 (2026-07-13): only a model doctor marked authed (modelAuth[model].authed===true) advertises a
321
411
  // channel — routing an unknown or 403 into dispatch is fail-closed. Operators with pre-v1.21 doctor.json
322
412
  // can opt into the prior unknown-is-routable behavior with routing.allowUnverifiedModels.
323
- return a.channels(cfg).filter((c) => modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)));
413
+ // OBS-117: configured aliases absent from the adapter CLI's own model list are not dispatchable.
414
+ return a.channels(cfg).filter((c) => !invalid.has(c.model) && modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)));
324
415
  });
325
416
  if (!cfg.routing.allow && !cfg.routing.deny)
326
417
  return base;
@@ -48,10 +48,18 @@ export interface WorkerResult {
48
48
  deviations: string[];
49
49
  raw: string;
50
50
  }
51
+ export type SeedBannerConfirmResult = {
52
+ ok: true;
53
+ sessionId?: string;
54
+ } | {
55
+ ok: false;
56
+ error: string;
57
+ };
51
58
  export interface InteractiveSeed {
52
59
  launch(model: string): string;
53
60
  readinessMatch: string;
54
61
  seedLine(promptFile: string): string;
62
+ confirmBanner?(banner: string, assignedModel: string): SeedBannerConfirmResult;
55
63
  }
56
64
  export interface ContextUsage {
57
65
  tokens: number;
@@ -1,12 +1,12 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { allAdapters, detectCandidateClis, flagDriftWarnings, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
3
+ import { allAdapters, binaryShadowWarnings, detectCandidateClis, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, readAutoPrefer, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
4
4
  import { BANNER, dim, fail, kvRow, legend, ok, rule, statusRow, title } from "../../brand.js";
5
5
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
6
6
  import { declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
7
7
  import { DEFAULT_CONFIG, loadConfig, overlayPreferShapes } from "../../config/config.js";
8
8
  import { HerdrDriver } from "../../drivers/herdr.js";
9
- import { disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
9
+ import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
10
10
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
11
11
  const alignedStatusRow = (verdict, key, value) => ` ${statusRow(verdict, kvRow(key, value).slice(2))}`;
12
12
  const attentionRow = (text) => ` ${statusRow("warn", text)}`;
@@ -19,7 +19,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
19
19
  // otherwise prints nothing until the end — silence here reads as a hang (v1.33.1)
20
20
  console.error("probing installed agent CLIs — one short LLM call per configured model, may take a minute...");
21
21
  const probeProgressTTY = process.stderr.isTTY === true;
22
- const health = await probeAll(adapters);
22
+ const health = await probeAll(adapters, { cwd });
23
23
  // MODEL-02: detect models where the adapter exposes a list surface, BEFORE writing doctor.json (write once, below).
24
24
  // Fail OPEN — the inverse of gates' fail-closed: detection is advisory, so a broken list surface NEVER fails doctor.
25
25
  for (const a of adapters) {
@@ -81,6 +81,11 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
81
81
  const excluded = excludedChannels(cfg, adapters, health);
82
82
  if (excluded.length)
83
83
  rows.push(attentionRow(exclusionLine(excluded)));
84
+ rows.push(...denyPreferCollisions(cfg).map((c) => attentionRow(denyPreferCollisionLine(c))));
85
+ const aliasExcluded = modelAliasExclusions(cfg, adapters, health);
86
+ if (aliasExcluded.length)
87
+ rows.push(attentionRow(modelAliasLine(aliasExcluded)));
88
+ rows.push(...binaryShadowWarnings(adapters, health, cwd).map(attentionRow));
84
89
  // HYG-07(a): doctor just probed fresh (probeAll above), so servability attribution is current by construction.
85
90
  const servable = servableExclusions(cfg, adapters, health);
86
91
  if (servable.length)
@@ -1,8 +1,11 @@
1
+ import { writeFileSync } from "node:fs";
1
2
  import { parseArgs } from "node:util";
2
3
  import { ttyVisual } from "../../adapters/model-lints.js";
3
4
  import { addUsage } from "../../adapters/types.js";
4
5
  import { dim, rule, title } from "../../brand.js";
5
6
  import { loadConfig } from "../../config/config.js";
7
+ import { buildProofBundle } from "../../report/bundle.js";
8
+ import { compareRuns } from "../../report/compare.js";
6
9
  import { estimateCosts } from "../../report/cost.js";
7
10
  import { cellsOf, cellSummary } from "../../route/profile.js";
8
11
  import { Journal, loadRoutingProfile } from "../../run/journal.js";
@@ -316,17 +319,49 @@ const stylizeReport = (out) => {
316
319
  export async function report(argv, cwd = process.cwd()) {
317
320
  const { values, positionals } = parseArgs({
318
321
  args: argv,
319
- options: { md: { type: "boolean" } },
322
+ options: {
323
+ md: { type: "boolean" },
324
+ // v1.70 T3: baseline run id for cost/gate/duration delta + environment comparability guard
325
+ compare: { type: "string" },
326
+ // v1.70 T4: write a portable, schema-versioned proof packet (local file only — no network)
327
+ bundle: { type: "string" },
328
+ },
320
329
  allowPositionals: true,
321
330
  });
322
331
  const runId = positionals[0] ?? Journal.latestRunId(cwd, { withJournal: true });
323
332
  if (!runId)
324
- throw new Error("no runs found — usage: tickmarkr report <run-id> [--md]");
333
+ throw new Error("no runs found — usage: tickmarkr report <run-id> [--md] [--compare <baseline-run-id>] [--bundle <path>]");
325
334
  const j = Journal.open(cwd, runId);
326
335
  const events = j.read();
336
+ const rows = j.readTelemetry();
337
+ const cfg = loadConfig(cwd);
338
+ let bundleNote = "";
339
+ if (values.bundle) {
340
+ // Local-only write of the pure proof packet — no network path exists in buildProofBundle.
341
+ const packet = buildProofBundle(runId, events);
342
+ writeFileSync(values.bundle, JSON.stringify(packet, null, 2) + "\n");
343
+ bundleNote = `wrote proof bundle → ${values.bundle}\n`;
344
+ }
345
+ let comparison = "";
346
+ if (values.compare) {
347
+ const baselineRunId = values.compare;
348
+ const baseline = Journal.open(cwd, baselineRunId);
349
+ const outcome = compareRuns({
350
+ runId,
351
+ baselineRunId,
352
+ events,
353
+ baselineEvents: baseline.read(),
354
+ rows,
355
+ baselineRows: baseline.readTelemetry(),
356
+ cost: cfg.cost,
357
+ });
358
+ // Fail closed: missing run-start yields a clear reason, never a partial table.
359
+ if (!outcome.ok)
360
+ throw new Error(outcome.reason);
361
+ comparison = "\n" + outcome.text;
362
+ }
327
363
  if (values.md) {
328
- const rows = j.readTelemetry();
329
- return renderMarkdownRecord(runId, events, estimateCosts(rows, loadConfig(cwd).cost), rows);
364
+ return bundleNote + renderMarkdownRecord(runId, events, estimateCosts(rows, cfg.cost), rows) + comparison;
330
365
  }
331
- return stylizeReport(textReport(runId, events, j.readTelemetry(), cwd));
366
+ return bundleNote + stylizeReport(textReport(runId, events, rows, cwd)) + comparison;
332
367
  }
@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.js";
2
2
  import { pickDriver } from "../../drivers/index.js";
3
3
  import { formatSummary, runDaemon } from "../../run/daemon.js";
4
4
  import { formatJournalNarration } from "../../run/journal.js";
5
+ import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
5
6
  const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
6
7
  && s.tipVerify !== "failed";
7
8
  export async function resume(argv, cwd = process.cwd()) {
@@ -12,7 +13,12 @@ export async function resume(argv, cwd = process.cwd()) {
12
13
  // Fable F2) — the daemon refuses a mismatched/unbound journal unless this is set, then journals a
13
14
  // graph-rehash event naming both hashes. Strip the flag before runId resolution so a bare id still wins.
14
15
  const graphChanged = argv.includes("--graph-changed");
15
- const s = await runDaemon(cwd, { runId, resume: true, graphChanged, driver: pickDriver(loadConfig(cwd)), narrate: (event) => console.log(formatJournalNarration(event)) });
16
+ const cfg = loadConfig(cwd);
17
+ const collisions = denyPreferCollisions(cfg);
18
+ if (collisions.length) {
19
+ throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
20
+ }
21
+ const s = await runDaemon(cwd, { runId, resume: true, graphChanged, driver: pickDriver(cfg), narrate: (event) => console.log(formatJournalNarration(event)) });
16
22
  const out = `resumed ${s.runId} — ${formatSummary(s)}`;
17
23
  return { out, code: summaryGreen(s) ? 0 : 2 };
18
24
  }
@@ -2,7 +2,7 @@ import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } f
2
2
  import { blockedTasks, graphDefinitionHash, loadGraph } from "../../graph/graph.js";
3
3
  import { GATE_NAMES } from "../../graph/schema.js";
4
4
  import { foldActivity } from "../../run/activity.js";
5
- import { Journal, engagementComparable } from "../../run/journal.js";
5
+ import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
6
6
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
7
7
  const REFRESH_MS = 2000;
8
8
  const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
@@ -178,15 +178,23 @@ const renderFrame = (cwd) => {
178
178
  const divider = unicode ? " · " : " / ";
179
179
  const width = process.stdout.columns ?? 120;
180
180
  const done = effective.tasks.filter((t) => t.status === "done").length;
181
+ const ended = comparable && runHasEnded(events);
181
182
  const cells = g.tasks.map((t) => {
182
183
  const st = replayed?.get(t.id) ?? t.status;
184
+ const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
185
+ const terminal = st === "failed" || st === "human";
186
+ // Unknown legacy task-failed events stay red. Typed availability noise is warn-tier only while
187
+ // the run is live; verified quality parks and every unresolved task in an ended run are red.
188
+ const redTier = terminal && (ended
189
+ || (failureKind !== undefined && isQualityFailureParkKind(failureKind))
190
+ || (st === "failed" && failureKind === undefined));
183
191
  const isStarved = starved.has(t.id);
184
192
  const phrase = isStarved ? undefined : activity.cells.get(t.id);
185
193
  const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
186
194
  const channel = assignments.get(t.id) ?? "-";
187
195
  const ctx = contexts.get(t.id);
188
196
  const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
189
- return { t, st, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
197
+ return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
190
198
  });
191
199
  if (!unicode) {
192
200
  // machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
@@ -207,7 +215,7 @@ const renderFrame = (cwd) => {
207
215
  // rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
208
216
  // padEnd math)
209
217
  const dot = dim(" · ");
210
- const anyFailed = cells.some((c) => c.st === "failed");
218
+ const anyFailed = cells.some((c) => c.redTier);
211
219
  const gaugeCells = 10;
212
220
  const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
213
221
  const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
@@ -232,17 +240,20 @@ const renderFrame = (cwd) => {
232
240
  // id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
233
241
  // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
234
242
  // and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
235
- const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
243
+ const taskVerdict = (c) => c.st === "done" ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
244
+ const statusWord = (c) => c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
236
245
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
237
246
  // plain-text status suffixes for width math only — starved / failed gate names / approval hint
238
247
  const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
239
- const stW = Math.max(...cells.map((c) => String(c.st).length + suffixPlain(c).length));
248
+ const stW = Math.max(...cells.map((c) => statusWord(c).length + suffixPlain(c).length));
240
249
  const avail = Math.max(8, width - (5 + idW) - 2 - stW);
241
250
  const goals = cells.map((c) => shortGoal(c.t.goal, avail));
242
251
  const goalW = Math.max(8, ...goals.map((s) => s.length));
243
252
  const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
244
- const rows = cells.map(({ t, st, states, isStarved, phrase, channel, ctx }, i) => {
245
- const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
253
+ const rows = cells.map((c, i) => {
254
+ const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx } = c;
255
+ const word = statusWord(c);
256
+ const stWord = st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
246
257
  // a fail names its gate in words right here — the one moment gate identity is needed on a row
247
258
  const f = failedGates(states);
248
259
  const human = humanGateSuffix(t, st, states);
@@ -250,10 +261,11 @@ const renderFrame = (cwd) => {
250
261
  (isStarved ? dot + fail("starved") : "") +
251
262
  (f.length ? dot + fail(f.join(", ")) : "") +
252
263
  (human ? dot + warn("awaiting approval") : "");
253
- const line1 = ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`)}`;
264
+ const line1 = ` ${statusRow(taskVerdict(c), `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`)}`;
254
265
  // activity already names its channel for in-flight attempts — never repeat it
255
266
  const detail = [
256
267
  ...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
268
+ ...(failureKind && !phrase?.includes(failureKind) ? [failureKind] : []),
257
269
  ...(ctx !== undefined ? [`ctx ${ctx}`] : []),
258
270
  ].join(" · ");
259
271
  return [line1, `${indent}${gateChain(states, true)} ${dim(detail)}`, ""];
@@ -11,11 +11,14 @@ export declare class HerdrDriver implements ExecutorDriver {
11
11
  interactive: boolean;
12
12
  private groups;
13
13
  private groupSerial;
14
+ private deliverySerial;
14
15
  private ws;
15
16
  private callerPane;
16
17
  private watches;
17
18
  constructor(bin?: string, workersPerTab?: number);
18
19
  private serial;
20
+ private deliveryQueue;
21
+ private deliveryMatches;
19
22
  static available(): boolean;
20
23
  private herdr;
21
24
  private namedPaneId;
@@ -29,6 +32,8 @@ export declare class HerdrDriver implements ExecutorDriver {
29
32
  private glyphFor;
30
33
  private joinGroup;
31
34
  run(slot: Slot, cmd: string): Promise<void>;
35
+ private deliver;
36
+ private deliveryReadMatches;
32
37
  private waitOk;
33
38
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
34
39
  regex?: boolean;
@@ -25,6 +25,8 @@ export class HerdrDriver {
25
25
  // grouped slot()/close() mutate shared group state across awaits — serialize them so two
26
26
  // concurrent first members can never both create the group tab (mergeSerial idiom, daemon.ts)
27
27
  groupSerial = Promise.resolve();
28
+ // OBS-119: concurrent deliveries contend on herdr's send path — one chain per driver (mergeSerial idiom)
29
+ deliverySerial = Promise.resolve();
28
30
  // VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
29
31
  // operator's env before the driver is built). Required at slot() time, never in the constructor —
30
32
  // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
@@ -40,6 +42,18 @@ export class HerdrDriver {
40
42
  this.groupSerial = p.catch(() => undefined);
41
43
  return p;
42
44
  }
45
+ deliveryQueue(fn) {
46
+ const p = this.deliverySerial.then(fn, fn);
47
+ this.deliverySerial = p.catch(() => undefined);
48
+ return p;
49
+ }
50
+ // ponytail: narrow panes hard-wrap the input line — collapse whitespace before comparing.
51
+ deliveryMatches(transcript, cmd) {
52
+ const norm = (s) => s.replace(/\s+/g, "");
53
+ const hay = norm(transcript);
54
+ const needle = norm(cmd);
55
+ return needle.length > 0 && hay.includes(needle);
56
+ }
43
57
  static available() {
44
58
  return process.env.HERDR_ENV === "1";
45
59
  }
@@ -286,6 +300,9 @@ export class HerdrDriver {
286
300
  // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
287
301
  // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
288
302
  async run(slot, cmd) {
303
+ return this.deliveryQueue(() => this.deliver(slot, cmd));
304
+ }
305
+ async deliver(slot, cmd) {
289
306
  const pane = await this.paneId(slot);
290
307
  let transcript = "";
291
308
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
@@ -300,9 +317,7 @@ export class HerdrDriver {
300
317
  if (typed.code !== 0)
301
318
  throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
302
319
  const back = await this.herdr(`wait output ${shq(pane)} --match ${shq(cmd)} --timeout ${DELIVERY_VERIFY_TIMEOUT_MS}`, slot.cwd, DELIVERY_VERIFY_TIMEOUT_MS + 15_000);
303
- // ponytail: transcript containment (scrollback included); anchor to the prompt line if a
304
- // prior echo of the same command ever false-verifies. Dead pane → error envelope → unverified.
305
- if (this.waitOk(back.code, back.stdout)) {
320
+ if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
306
321
  const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
307
322
  if (enter.code !== 0)
308
323
  throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
@@ -313,6 +328,10 @@ export class HerdrDriver {
313
328
  }
314
329
  throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
315
330
  }
331
+ async deliveryReadMatches(pane, cmd, cwd) {
332
+ const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, cwd);
333
+ return read.code === 0 && this.deliveryMatches(read.stdout, cmd);
334
+ }
316
335
  waitOk(code, stdout) {
317
336
  if (code !== 0 || !stdout.trim())
318
337
  return code === 0; // herdr's successful waits may be silent
@@ -2,13 +2,17 @@ import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import { type Task } from "../graph/schema.js";
3
3
  import { type LlmVia } from "./llm.js";
4
4
  import type { GateResult } from "./types.js";
5
+ export interface EvidenceCitation {
6
+ path: string;
7
+ line: number;
8
+ }
5
9
  export interface JudgeVerdict {
6
10
  pass: boolean;
7
11
  criteria: Array<{
8
12
  criterion: string;
9
13
  met: boolean;
10
14
  reason: string;
11
- evidence: string;
15
+ evidence: string | EvidenceCitation;
12
16
  }>;
13
17
  }
14
18
  export declare function judgeCriterionId(index: number): string;