tickmarkr 1.85.0 → 1.86.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 (71) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +96 -0
  5. package/dist/adapters/catalog.js +176 -0
  6. package/dist/adapters/claude-code.js +5 -3
  7. package/dist/adapters/fake.js +33 -4
  8. package/dist/adapters/model-lints.d.ts +25 -5
  9. package/dist/adapters/model-lints.js +184 -50
  10. package/dist/adapters/model-windows.d.ts +31 -0
  11. package/dist/adapters/model-windows.js +69 -0
  12. package/dist/adapters/prompt.d.ts +5 -1
  13. package/dist/adapters/prompt.js +13 -4
  14. package/dist/adapters/registry.d.ts +25 -26
  15. package/dist/adapters/registry.js +173 -110
  16. package/dist/adapters/types.js +15 -3
  17. package/dist/brand.d.ts +5 -1
  18. package/dist/brand.js +18 -2
  19. package/dist/cli/commands/doctor.d.ts +3 -0
  20. package/dist/cli/commands/doctor.js +43 -21
  21. package/dist/cli/commands/fleet.d.ts +7 -0
  22. package/dist/cli/commands/fleet.js +94 -74
  23. package/dist/cli/commands/init.js +118 -5
  24. package/dist/cli/commands/status.js +44 -18
  25. package/dist/compile/gsd.d.ts +2 -1
  26. package/dist/compile/gsd.js +68 -2
  27. package/dist/compile/native.d.ts +14 -0
  28. package/dist/compile/native.js +161 -12
  29. package/dist/config/config.d.ts +20 -5
  30. package/dist/config/config.js +96 -64
  31. package/dist/config/fleet-overlay.d.ts +25 -20
  32. package/dist/config/fleet-overlay.js +195 -77
  33. package/dist/config/fleet-why.d.ts +23 -0
  34. package/dist/config/fleet-why.js +42 -0
  35. package/dist/drivers/herdr.d.ts +1 -0
  36. package/dist/drivers/herdr.js +70 -19
  37. package/dist/gates/acceptance.js +7 -2
  38. package/dist/gates/llm.d.ts +0 -1
  39. package/dist/gates/llm.js +5 -30
  40. package/dist/gates/review.d.ts +2 -1
  41. package/dist/gates/review.js +9 -7
  42. package/dist/gates/verdict-cause.d.ts +4 -0
  43. package/dist/gates/verdict-cause.js +63 -0
  44. package/dist/graph/schema.d.ts +6 -0
  45. package/dist/graph/schema.js +8 -5
  46. package/dist/route/router.d.ts +0 -5
  47. package/dist/route/router.js +16 -20
  48. package/dist/run/consult.d.ts +6 -0
  49. package/dist/run/consult.js +35 -25
  50. package/dist/run/daemon.js +71 -15
  51. package/dist/run/journal.js +87 -7
  52. package/dist/tui/cockpit/capture.d.ts +12 -0
  53. package/dist/tui/cockpit/capture.js +37 -1
  54. package/dist/tui/cockpit/components.js +8 -8
  55. package/dist/tui/cockpit/theme.d.ts +32 -26
  56. package/dist/tui/cockpit/theme.js +11 -5
  57. package/dist/tui/ink/components.d.ts +0 -15
  58. package/dist/tui/ink/components.js +0 -17
  59. package/dist/tui/ink/fleet-app.d.ts +4 -1
  60. package/dist/tui/ink/fleet-app.js +134 -13
  61. package/fixtures/sample.native.md +1 -1
  62. package/package.json +1 -1
  63. package/skills/tickmarkr-overseer/SKILL.md +354 -34
  64. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
  65. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  66. package/dist/tui/ink/studio-app.d.ts +0 -59
  67. package/dist/tui/ink/studio-app.js +0 -320
  68. package/dist/tui/save.d.ts +0 -38
  69. package/dist/tui/save.js +0 -96
  70. package/dist/tui/staging.d.ts +0 -29
  71. package/dist/tui/staging.js +0 -78
package/dist/gates/llm.js CHANGED
@@ -69,29 +69,6 @@ export function extractPromptNonce(prompt) {
69
69
  export function gateExitTrailer(nonce) {
70
70
  return `printf '\\nTICKMARKR_''EXIT_${nonce}:%s\\n' $?`;
71
71
  }
72
- // v1.64: scripted fake judge verdicts predate the required per-criterion evidence field — quote the
73
- // first line of the prompt's own diff block into rows lacking one so zero-token fixtures keep their
74
- // outcomes. Rows scripting an explicit evidence value pass through verbatim (tests exercise both paths).
75
- function injectFakeEvidence(obj, prompt) {
76
- if (!prompt.startsWith("TICKMARKR-JUDGE") || !Array.isArray(obj.criteria))
77
- return obj;
78
- const line = /```diff\n([\s\S]*?)```/.exec(prompt)?.[1].split("\n").find((l) => l.trim());
79
- if (!line)
80
- return obj;
81
- const criteria = obj.criteria.map((row) => row && typeof row === "object" && !("evidence" in row) ? { ...row, evidence: line } : row);
82
- return { ...obj, criteria };
83
- }
84
- // ponytail: fake adapter serves static verdict JSON without nonce; append a bound copy for zero-token tests.
85
- export function augmentFakeVerdictOutput(adapter, out, nonce, prompt = "") {
86
- if (adapter.id !== "fake")
87
- return out;
88
- const obj = extractJson(out);
89
- if (!obj || typeof obj !== "object" || obj.nonce === nonce)
90
- return out;
91
- if (typeof obj.nonce === "string")
92
- return out;
93
- return `${out}\n${JSON.stringify(injectFakeEvidence({ ...obj, nonce }, prompt))}`;
94
- }
95
72
  /** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
96
73
  export function gatePaneName(role, taskId, suffix = "") {
97
74
  return `${role}${GATE_PANE_SEP}${taskId}${suffix}`;
@@ -130,11 +107,7 @@ export async function runHeadless(adapter, model, prompt, cwd, timeoutMs = 30000
130
107
  const pf = join(mkdtempSync(join(tmpdir(), "tickmarkr-llm-")), "prompt.md");
131
108
  writeFileSync(pf, prompt);
132
109
  const r = await sh(adapter.headlessCommand(pf, model), cwd, timeoutMs);
133
- const nonce = extractPromptNonce(prompt);
134
- let out = r.stdout + "\n" + r.stderr;
135
- if (nonce)
136
- out = augmentFakeVerdictOutput(adapter, out, nonce, prompt);
137
- return out;
110
+ return r.stdout + "\n" + r.stderr;
138
111
  }
139
112
  // v1.1 default path: the same headless CLI call, but dispatched through the driver
140
113
  // as a visible named agent (herdr pane), with the quote-split completion wrapper.
@@ -157,10 +130,9 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
157
130
  // nonce-suffixed exit only: a displayed bare "TICKMARKR_EXIT:" or another call's marker must not
158
131
  // false-complete — same guard the worker path uses (daemon.ts:330-331).
159
132
  await via.driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, timeoutMs, { regex: true });
160
- let out = await via.driver.read(slot, 400);
133
+ const out = await via.driver.read(slot, 400);
161
134
  if (!via.keep)
162
135
  await via.driver.close(slot);
163
- out = augmentFakeVerdictOutput(adapter, out, nonce, prompt);
164
136
  return dewrapPaneVerdict(out, nonce);
165
137
  }
166
138
  // OBS-155: a TUI renders the verdict as a bullet and HARD-wraps it at pane width with a 2-space
@@ -179,6 +151,9 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
179
151
  export function dewrapPaneVerdict(out, nonce) {
180
152
  if (!out.includes(nonce))
181
153
  return out;
154
+ // Preserve already-readable responder bytes; only a genuinely wrapped verdict needs reconstruction.
155
+ if (extractVerdictJson(out, nonce))
156
+ return out;
182
157
  const lines = out.split("\n");
183
158
  // OBS-209: EVERY brace-start is a candidate, scanned newest-first. findIndex took only the first,
184
159
  // so any earlier line beginning with `{` — a quoted snippet, a lone brace in the reviewer's own
@@ -3,6 +3,7 @@ import { type TickmarkrConfig } from "../config/config.js";
3
3
  import { type Task } from "../graph/schema.js";
4
4
  import { type GateVia } from "./llm.js";
5
5
  import type { GateResult } from "./types.js";
6
+ import { type VerdictUnparseableCause } from "./verdict-cause.js";
6
7
  export type ReviewSeverity = "material" | "minor";
7
8
  export interface ReviewFinding {
8
9
  note: string;
@@ -44,5 +45,5 @@ export declare function diffCapParkReason(results: GateResult[]): string | null;
44
45
  export declare function modelId(model: string): string;
45
46
  export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[], // v1.1 failover: reviewer channels that already produced garbage for this task
46
47
  prefer?: string[]): BillingChannel | null;
47
- export type ReviewUnparseableCause = "empty-output" | "no-verdict" | "malformed-verdict";
48
+ export type ReviewUnparseableCause = VerdictUnparseableCause;
48
49
  export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string): Promise<GateResult>;
@@ -8,6 +8,7 @@ import { shOk } from "../run/git.js";
8
8
  import { redactSecrets } from "../run/redact.js";
9
9
  import { marginalCostRank } from "../route/router.js";
10
10
  import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
11
+ import { classifyVerdictCause } from "./verdict-cause.js";
11
12
  // legacy flat `issues` shape — every issue blocks; the approve flag must agree with the list.
12
13
  function classifyReviewIssues(approve, issues) {
13
14
  const inconsistencies = [];
@@ -303,9 +304,9 @@ artifactDir) {
303
304
  // "a law caps complexity at 3, the gate starts at 7" unreachability OBS-186 measured.
304
305
  //
305
306
  // COLLATERAL this rescoped task closed: the run-gates/daemon participation assertions are rewritten
306
- // path-keyed, the NamedFake review fixtures carry their own nonce trailer (llm.ts's injection is
307
- // scoped to adapter id "fake" and a renamed fake is a different adapter to it — the check is not
308
- // weakened, the fixture is fixed), the merge decision reads `gateSatisfied`, and the daemon writes a
307
+ // path-keyed, the NamedFake review fixtures author their own nonce-bound verdict (a renamed fake is
308
+ // a distinct responder and does not inherit the registered fake's producer contract — the check is
309
+ // not weakened, the fixture is fixed), the merge decision reads `gateSatisfied`, and the daemon writes a
309
310
  // parallel round's gate-result rows in GATE_NAMES order (src/run/daemon.ts).
310
311
  //
311
312
  // That last one is why: retiring the switch makes fixtures that used to SKIP review journal TWO
@@ -419,9 +420,7 @@ The top-level comments array is optional. Use it only for actionable line-anchor
419
420
  if (!v || (findings === null && (typeof v.approve !== "boolean" || !Array.isArray(v.issues)))) {
420
421
  // OBS-196: name the cause and persist the raw bytes — a ruled-on "unparseable" without its
421
422
  // evidence cannot be audited, and a cutoff must never be indistinguishable from a parse defect.
422
- const cause = raw.trim().length === 0
423
- ? "empty-output"
424
- : !raw.includes(nonce) ? "no-verdict" : "malformed-verdict";
423
+ const cause = classifyVerdictCause(raw, nonce, "approve");
425
424
  let saved;
426
425
  if (artifactDir) {
427
426
  try {
@@ -432,10 +431,13 @@ The top-level comments array is optional. Use it only for actionable line-anchor
432
431
  saved = undefined; // persistence is evidence, not a gate input — never fail the gate on it
433
432
  }
434
433
  }
434
+ const failure = cause === "malformed-verdict"
435
+ ? "review output unparseable"
436
+ : "review dispatch failed — no structurally valid nonce-bound response; output unparseable";
435
437
  return {
436
438
  gate: "review",
437
439
  pass: false,
438
- details: `review output unparseable (reviewer ${reviewer.adapter}:${reviewer.model}; cause: ${cause}${saved ? `; raw saved: ${saved}` : ""}) — failing closed`,
440
+ details: `${failure} (reviewer ${reviewer.adapter}:${reviewer.model}; cause: ${cause}${saved ? `; raw saved: ${saved}` : ""}) — failing closed`,
439
441
  meta: { ...policyMeta, reviewer: channelKey(reviewer), unparseable: true, cause },
440
442
  };
441
443
  }
@@ -0,0 +1,4 @@
1
+ export type VerdictDiscriminator = "approve" | "pass" | "ok" | "action";
2
+ export type VerdictUnparseableCause = "empty-output" | "no-verdict" | "malformed-verdict";
3
+ export declare function hasVerdictParticipationWitness(raw: string, nonce: string, discriminator: VerdictDiscriminator): boolean;
4
+ export declare function classifyVerdictCause(raw: string, nonce: string, discriminator: VerdictDiscriminator): VerdictUnparseableCause;
@@ -0,0 +1,63 @@
1
+ const MAX_WITNESS_BYTES = 4096;
2
+ const JSON_STRING_VALUE = String.raw `"(?:\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})|[^"\\\u0000-\u001F])*"`;
3
+ function escapeRegex(value) {
4
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+ // Pane renderers hard-wrap at physical columns and prefix continuation lines with whitespace or box
7
+ // chrome. Joining those renderer lines is deliberately narrower than parsing: it restores split keys,
8
+ // values and delimiters, but it does not require the response to be complete or valid JSON.
9
+ function joinRendererLines(raw) {
10
+ return raw.replace(/\r\n?/g, "\n").split("\n")
11
+ .map((line) => line.replace(/^[\t │|]+/, "").replace(/[\t │|]+$/, ""))
12
+ .join("");
13
+ }
14
+ // Yield only the first structural prefix of each object candidate. Stopping at the next unquoted
15
+ // opening brace prevents a nonce from one object binding a discriminator from another; the prompts
16
+ // put their discriminator before any nested object, so no valid boundary shape is lost.
17
+ function objectPrefixes(raw) {
18
+ const joined = joinRendererLines(raw);
19
+ const prefixes = [];
20
+ for (let open = joined.indexOf("{"); open !== -1; open = joined.indexOf("{", open + 1)) {
21
+ const ceiling = Math.min(joined.length, open + MAX_WITNESS_BYTES);
22
+ let end = ceiling;
23
+ let quoted = false;
24
+ let escaped = false;
25
+ for (let i = open + 1; i < ceiling; i++) {
26
+ const char = joined[i];
27
+ if (quoted) {
28
+ if (escaped)
29
+ escaped = false;
30
+ else if (char === "\\")
31
+ escaped = true;
32
+ else if (char === '"')
33
+ quoted = false;
34
+ continue;
35
+ }
36
+ if (char === '"')
37
+ quoted = true;
38
+ else if (char === "{") {
39
+ end = i;
40
+ break;
41
+ }
42
+ else if (char === "}") {
43
+ end = i + 1;
44
+ break;
45
+ }
46
+ }
47
+ prefixes.push(joined.slice(open, end));
48
+ }
49
+ return prefixes;
50
+ }
51
+ export function hasVerdictParticipationWitness(raw, nonce, discriminator) {
52
+ const noncePattern = new RegExp(String.raw `"nonce"\s*:\s*${escapeRegex(JSON.stringify(nonce))}\s*[,]`);
53
+ const valuePattern = discriminator === "action" ? JSON_STRING_VALUE : "(?:true|false)";
54
+ const discriminatorPattern = new RegExp(String.raw `"${discriminator}"\s*:\s*${valuePattern}\s*[,}]`);
55
+ return objectPrefixes(raw).some((prefix) => noncePattern.test(prefix) && discriminatorPattern.test(prefix));
56
+ }
57
+ export function classifyVerdictCause(raw, nonce, discriminator) {
58
+ if (raw.trim().length === 0)
59
+ return "empty-output";
60
+ return hasVerdictParticipationWitness(raw, nonce, discriminator)
61
+ ? "malformed-verdict"
62
+ : "no-verdict";
63
+ }
@@ -12,9 +12,11 @@ export type Oracle = (typeof ORACLES)[number];
12
12
  export declare const AcceptanceItemSchema: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
13
13
  oracle: z.ZodLiteral<"command">;
14
14
  command: z.ZodString;
15
+ text: z.ZodOptional<z.ZodString>;
15
16
  }, z.core.$strip>, z.ZodObject<{
16
17
  oracle: z.ZodLiteral<"test">;
17
18
  test: z.ZodString;
19
+ text: z.ZodOptional<z.ZodString>;
18
20
  }, z.core.$strip>, z.ZodObject<{
19
21
  oracle: z.ZodLiteral<"judge">;
20
22
  text: z.ZodString;
@@ -43,9 +45,11 @@ export declare const TaskSchema: z.ZodObject<{
43
45
  acceptance: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
44
46
  oracle: z.ZodLiteral<"command">;
45
47
  command: z.ZodString;
48
+ text: z.ZodOptional<z.ZodString>;
46
49
  }, z.core.$strip>, z.ZodObject<{
47
50
  oracle: z.ZodLiteral<"test">;
48
51
  test: z.ZodString;
52
+ text: z.ZodOptional<z.ZodString>;
49
53
  }, z.core.$strip>, z.ZodObject<{
50
54
  oracle: z.ZodLiteral<"judge">;
51
55
  text: z.ZodString;
@@ -133,9 +137,11 @@ export declare const RunGraphSchema: z.ZodObject<{
133
137
  acceptance: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
134
138
  oracle: z.ZodLiteral<"command">;
135
139
  command: z.ZodString;
140
+ text: z.ZodOptional<z.ZodString>;
136
141
  }, z.core.$strip>, z.ZodObject<{
137
142
  oracle: z.ZodLiteral<"test">;
138
143
  test: z.ZodString;
144
+ text: z.ZodOptional<z.ZodString>;
139
145
  }, z.core.$strip>, z.ZodObject<{
140
146
  oracle: z.ZodLiteral<"judge">;
141
147
  text: z.ZodString;
@@ -11,11 +11,14 @@ export const TIERS = ["cheap", "mid", "frontier"];
11
11
  // A plain string is the read-old/write-new compat form — semantically a judge oracle (spec §2).
12
12
  export const ORACLES = ["command", "test", "judge"];
13
13
  // Typed acceptance oracle: command carries the thing to run, test the test name, judge free text.
14
- // Anything else (a typed object naming an unknown oracle) fails validation loudly here.
14
+ // text is OPTIONAL non-empty on command/test (declared prose beside the oracle; judge requires it).
15
+ // Declaring it matters: z.object strips unknown keys, and loadGraph revalidates on every read —
16
+ // an undeclared text would be silently discarded at the next load. Anything else (a typed object
17
+ // naming an unknown oracle, or a text that is not a non-empty string) fails validation loudly here.
15
18
  export const AcceptanceItemSchema = z.union([
16
19
  z.string().min(1),
17
- z.object({ oracle: z.literal("command"), command: z.string().min(1) }),
18
- z.object({ oracle: z.literal("test"), test: z.string().min(1) }),
20
+ z.object({ oracle: z.literal("command"), command: z.string().min(1), text: z.string().min(1).optional() }),
21
+ z.object({ oracle: z.literal("test"), test: z.string().min(1), text: z.string().min(1).optional() }),
19
22
  z.object({ oracle: z.literal("judge"), text: z.string().min(1) }),
20
23
  ]);
21
24
  // Shared text rendering of one acceptance item — every consumer (worker prompt, acceptance gate,
@@ -24,9 +27,9 @@ export function renderAcceptanceItem(item) {
24
27
  if (typeof item === "string")
25
28
  return item;
26
29
  if (item.oracle === "command")
27
- return `$ ${item.command}`;
30
+ return item.text ?? `$ ${item.command}`;
28
31
  if (item.oracle === "test")
29
- return `test: ${item.test}`;
32
+ return item.text ?? `test: ${item.test}`;
30
33
  return item.text; // judge — bare text, byte-identical to a plain-string judge criterion
31
34
  }
32
35
  export const TaskSchema = z.object({
@@ -19,11 +19,6 @@ export interface Route {
19
19
  deviation?: RouteDeviation;
20
20
  }
21
21
  export interface RoutingPreferContext {
22
- autoPrefer?: {
23
- derivedAt: string;
24
- [shape: string]: string[] | string;
25
- };
26
- doctorFresh: boolean;
27
22
  overlayPreferShapes: ReadonlySet<string>;
28
23
  }
29
24
  export interface ExploreContext {
@@ -28,19 +28,9 @@ const exploreOff = (task, cfg, exploreCtx) => {
28
28
  return true;
29
29
  return false;
30
30
  };
31
- const autoPreferList = (doc, shape) => {
32
- const v = doc?.[shape];
33
- return Array.isArray(v) ? v : undefined;
34
- };
35
- const preferFromAuto = (shape, preferCtx) => !!preferCtx?.doctorFresh && !!preferCtx.autoPrefer && !preferCtx.overlayPreferShapes.has(shape) &&
36
- autoPreferList(preferCtx.autoPrefer, shape) !== undefined;
37
- const effectivePrefer = (shape, entry, preferCtx) => {
38
- if (preferCtx?.overlayPreferShapes.has(shape))
39
- return entry?.prefer;
40
- if (preferCtx?.doctorFresh && preferCtx.autoPrefer)
41
- return autoPreferList(preferCtx.autoPrefer, shape) ?? entry?.prefer;
42
- return entry?.prefer;
43
- };
31
+ // v1.86 T3: autoPrefer is deleted — prefer is operator-declared only. Nothing is ever derived
32
+ // from probe health, tier data, or a machine-built shape table.
33
+ const effectivePrefer = (entry) => entry?.prefer;
44
34
  export class RoutingError extends Error {
45
35
  constructor(msg) {
46
36
  super(msg);
@@ -134,7 +124,7 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
134
124
  // ponytail: sla is plan-time advisory only — never thread into learnedScore (would reroute warm rivals).
135
125
  const scoreOpts = { availWeight: cfg.routing.learnedTuning?.availWeight };
136
126
  const entry = cfg.routing.map[task.shape];
137
- const prefer = effectivePrefer(task.shape, entry, preferCtx);
127
+ const prefer = effectivePrefer(entry);
138
128
  const prefActive = !!(cfg.routing.allow || cfg.routing.deny);
139
129
  const disallowedPin = (via, model, kind) => {
140
130
  const d = disallowedBy({ adapter: via, model }, cfg.routing);
@@ -167,6 +157,14 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
167
157
  }
168
158
  };
169
159
  const taskFloor = task.routingHints?.floor;
160
+ const mapPinFloor = taskFloor && (!advisoryFloor || TIER_RANK[taskFloor] >= TIER_RANK[advisoryFloor])
161
+ ? { tier: taskFloor, source: "task" }
162
+ : advisoryFloor ? { tier: advisoryFloor, source: "config" } : undefined;
163
+ const lintMapPinFloor = (tier) => {
164
+ if (mapPinFloor && TIER_RANK[tier] < TIER_RANK[mapPinFloor.tier]) {
165
+ lints.push(`${task.id} (${task.shape}): map pin routes ${tier}, below ${mapPinFloor.source} floor ${mapPinFloor.tier} — map pins are supreme`);
166
+ }
167
+ };
170
168
  const source = task.routingHints?.source;
171
169
  const src = source ? `, ${source}` : ""; // never interpolate a possibly-undefined source
172
170
  // task pin: planner-authored, try-first — degrades on miss or below-floor (D-05, research A3), never throws
@@ -188,12 +186,13 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
188
186
  if (entry?.pin) {
189
187
  disallowedPin(entry.pin.via, entry.pin.model, "map pin (config routing.map)");
190
188
  const c = resolvePin(entry.pin, channels);
191
- lintFloor(c.tier, "map pin");
189
+ lintMapPinFloor(c.tier);
192
190
  maybeSlaLint(lints, task, profile, slaMinutes, c);
193
191
  return { assignment: toAssignment(c), ladder: ladderFor(task, entry), lints, provenance: `${degraded}pin ${entry.pin.via}:${entry.pin.model} (config routing.map)` };
194
192
  }
195
193
  const baseTier = floor ?? "cheap";
196
- const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier; // task floor is a hard >= constraint in all paths (D-04)
194
+ // D-04: a task floor is hard only on floor/auto paths; the map-pin branch above stays supreme.
195
+ const minTier = taskFloor && TIER_RANK[taskFloor] > TIER_RANK[baseTier] ? taskFloor : baseTier;
197
196
  if (prefActive)
198
197
  for (const p of prefer ?? [])
199
198
  preflightPrefer(p);
@@ -293,13 +292,10 @@ export function route(task, cfg, channels, profile, preferCtx, exclude, exploreC
293
292
  "tier cheap (default)";
294
293
  // name the key that actually broke the tie: prefer outranks the marginal-cost/tier keys, so if the
295
294
  // winner matched a prefer entry, prefer decided it — not "cheapest sufficient tier" (ROUTE-03, WR-01)
296
- const preferVia = preferFromAuto(task.shape, preferCtx)
297
- ? `via prefer (auto-modernized ${preferCtx.autoPrefer.derivedAt.slice(0, 10)})`
298
- : "via prefer";
299
295
  // a spread-decided winner is never inside a prefer band (the spread skips those runs), so the
300
296
  // three arms below are mutually exclusive by construction
301
297
  const chosenBy = learnedChosen || (spreadDecided ? "via frontier spread"
302
- : prefer && preferIndex(eligible[0], prefer) < prefer.length ? preferVia : "cheapest sufficient tier");
298
+ : prefer && preferIndex(eligible[0], prefer) < prefer.length ? "via prefer" : "cheapest sufficient tier");
303
299
  maybeSlaLint(lints, task, profile, slaMinutes, eligible[0]);
304
300
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
305
301
  }
@@ -2,6 +2,7 @@ import type { WorkerAdapter } from "../adapters/types.js";
2
2
  import type { TickmarkrConfig } from "../config/config.js";
3
3
  import type { ExecutorDriver, Slot } from "../drivers/types.js";
4
4
  import type { GateResult } from "../gates/types.js";
5
+ import { type VerdictUnparseableCause } from "../gates/verdict-cause.js";
5
6
  export interface ConsultVerdict {
6
7
  action: "retry" | "reroute" | "decompose" | "human";
7
8
  notes: string;
@@ -23,6 +24,11 @@ export interface Dossier {
23
24
  diff: string;
24
25
  gates: GateResult[];
25
26
  }
27
+ export interface ConsultParseResult {
28
+ verdict: ConsultVerdict | null;
29
+ cause?: VerdictUnparseableCause;
30
+ }
31
+ export declare function parseConsultVerdict(out: string, nonce: string): ConsultParseResult;
26
32
  export declare function buildDossierPrompt(d: Dossier, nonce: string): string;
27
33
  export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: WorkerAdapter[], driver: ExecutorDriver, cwd: string, runDir: string, opts?: {
28
34
  keep?: boolean;
@@ -2,7 +2,8 @@ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAdapter } from "../adapters/registry.js";
4
4
  import { bannerShell, paneDispatchCommand } from "../brand.js";
5
- import { augmentFakeVerdictOutput, extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
5
+ import { extractVerdictJson, gateExitTrailer, gatePaneName, generateVerdictNonce, verdictNonceLine } from "../gates/llm.js";
6
+ import { classifyVerdictCause } from "../gates/verdict-cause.js";
6
7
  import { sh } from "./git.js";
7
8
  import { redactSecrets } from "./redact.js";
8
9
  import { filterLlmTranscript } from "./stall.js";
@@ -57,6 +58,33 @@ export function augmentRetryBrief(feedback, opts) {
57
58
  return parts.join("\n\n");
58
59
  }
59
60
  const ACTIONS = ["retry", "reroute", "decompose", "human"];
61
+ export function parseConsultVerdict(out, nonce) {
62
+ const v = extractVerdictJson(out, nonce);
63
+ if (!v)
64
+ return { verdict: null, cause: classifyVerdictCause(out, nonce, "action") };
65
+ // A parsed object with an unknown action is a content rejection, not silence. It keeps the existing
66
+ // null result without manufacturing a classifier cause for a state the classifier never saw.
67
+ if (!ACTIONS.includes(v.action))
68
+ return { verdict: null };
69
+ // fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
70
+ // empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
71
+ // and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
72
+ // zero-match expansion as channel-level reroute.
73
+ const raw = v.excludeAdapter;
74
+ const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
75
+ const reason = typeof v.reason === "string" ? v.reason : undefined;
76
+ const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
77
+ const notes = String(v.notes ?? guidance ?? reason ?? "");
78
+ return {
79
+ verdict: {
80
+ action: v.action,
81
+ notes,
82
+ ...(reason ? { reason } : {}),
83
+ ...(guidance ? { guidance } : {}),
84
+ ...(excludeAdapter ? { excludeAdapter } : {}),
85
+ },
86
+ };
87
+ }
60
88
  // v1.65 T2: transcript noise (spinner repaints, CR churn, pass-run spam) is squashed at build time —
61
89
  // the filtered form is what persists to the consults/ artifact AND what the model reads; fail-open.
62
90
  export function buildDossierPrompt(d, nonce) {
@@ -108,7 +136,8 @@ opts = {}) {
108
136
  // consult model reads) is masked at this seam; the in-memory Dossier stays untouched.
109
137
  writeFileSync(promptFile, redactSecrets(buildDossierPrompt(d, nonce)));
110
138
  // One seat = the WHOLE invoke-and-parse unit, both visibility branches (OBS-69 class: a headless-only
111
- // failover would leave the production pane path hard-failing on seat one). null = no parseable verdict.
139
+ // failover would leave the production pane path hard-failing on seat one). A null verdict retains
140
+ // the classifier's cause when extraction failed; parsed content rejections deliberately have none.
112
141
  const invokeSeat = async (seatAdapter, seatModel, seatIdx) => {
113
142
  const adapter = getAdapter(seatAdapter, adapters);
114
143
  let out;
@@ -143,26 +172,7 @@ opts = {}) {
143
172
  await driver.close(slot);
144
173
  }
145
174
  }
146
- out = augmentFakeVerdictOutput(adapter, out, nonce);
147
- const v = extractVerdictJson(out, nonce);
148
- if (!v || !ACTIONS.includes(v.action))
149
- return null;
150
- // fail-closed exclusion: only a non-empty string survives. Malformed values (number/array/object/
151
- // empty) are dropped — the verdict stays a normal channel-level reroute/retry/…, never a crash
152
- // and never silently forced to human. Unknown adapter ids pass through; the daemon treats a
153
- // zero-match expansion as channel-level reroute.
154
- const raw = v.excludeAdapter;
155
- const excludeAdapter = typeof raw === "string" && raw.length > 0 ? raw : undefined;
156
- const reason = typeof v.reason === "string" ? v.reason : undefined;
157
- const guidance = typeof v.guidance === "string" ? v.guidance : undefined;
158
- const notes = String(v.notes ?? guidance ?? reason ?? "");
159
- return {
160
- action: v.action,
161
- notes,
162
- ...(reason ? { reason } : {}),
163
- ...(guidance ? { guidance } : {}),
164
- ...(excludeAdapter ? { excludeAdapter } : {}),
165
- };
175
+ return parseConsultVerdict(out, nonce);
166
176
  };
167
177
  // v1.54 T1: ranked seat failover. Walk consult.prefer (adapter:model entries) to the first entry
168
178
  // whose adapter is in the live channel set; a failed seat or unparseable verdict falls to the next;
@@ -176,9 +186,9 @@ opts = {}) {
176
186
  seats.push({ adapter: cfg.consult.adapter, model: cfg.consult.model });
177
187
  for (const [i, seat] of seats.entries()) {
178
188
  try {
179
- const v = await invokeSeat(seat.adapter, seat.model, i);
180
- if (v)
181
- return v;
189
+ const parsed = await invokeSeat(seat.adapter, seat.model, i);
190
+ if (parsed.verdict)
191
+ return parsed.verdict;
182
192
  }
183
193
  catch {
184
194
  // failed seat (unknown adapter, dead driver/pane, shell error) — fall to the next entry
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { shq } from "../adapters/types.js";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { stringify } from "yaml";
@@ -631,6 +631,71 @@ async function cherryPickCommits(wt, commits) {
631
631
  }
632
632
  return carried;
633
633
  }
634
+ // T7 (v1.86): a first run-end append that fails AFTER partial bytes landed leaves a torn tail at
635
+ // EOF with no newline; a blind retry would glue the run-end line onto those bytes and readJsonl's
636
+ // torn-line tolerance would drop the retry too — no terminal record despite a successful write.
637
+ // Terminating the torn fragment keeps it on disk (dropped as a malformed line, never truncated) so
638
+ // the retried run-end lands on a line of its own.
639
+ const terminateTornJournalTail = (journalPath) => {
640
+ if (!existsSync(journalPath))
641
+ return;
642
+ const size = statSync(journalPath).size;
643
+ if (size === 0)
644
+ return;
645
+ const fd = openSync(journalPath, "r");
646
+ try {
647
+ const tail = Buffer.alloc(1);
648
+ if (readSync(fd, tail, 0, 1, size - 1) === 1 && tail[0] !== 0x0a)
649
+ appendFileSync(journalPath, "\n");
650
+ }
651
+ finally {
652
+ closeSync(fd);
653
+ }
654
+ };
655
+ // T7 (v1.86): the fatal handler must never eat the error it reports. Both journal calls are guarded,
656
+ // so a sink failure is reported ALONGSIDE the original error (console.error — the dispatcher's
657
+ // operator-visible line stays the one-line form), never instead of it: a read failure degrades the
658
+ // duplicate-run-end check to "unknown" and fails toward recording; the append is retried ONCE; and
659
+ // a persistently unwritable sink reports a crash naming the journal path and carrying NO terminal
660
+ // record, rather than fabricating an ended run on evidence the harness could not write. (OBS-313:
661
+ // with the sink dead the crash CAUSE is unrecordable — recorded as an observation, not papered over.)
662
+ function recordFatalRunEnd(journal, runId, branch, err) {
663
+ const original = err instanceof Error ? err.message : String(err);
664
+ try {
665
+ if (journal.read().some((e) => e.event === "run-end"))
666
+ return; // already terminal — nothing to add
667
+ }
668
+ catch (readErr) {
669
+ console.error(`tickmarkr ${runId}: journal read failed while recording the fatal run-end (${readErr instanceof Error ? readErr.message : String(readErr)}) — original error: ${original}`);
670
+ }
671
+ const record = {
672
+ runId,
673
+ branch,
674
+ done: [],
675
+ failed: [],
676
+ human: [],
677
+ blocked: [],
678
+ pending: [],
679
+ phase: "setup",
680
+ fatal: true,
681
+ error: original,
682
+ };
683
+ try {
684
+ journal.append("run-end", undefined, record);
685
+ return;
686
+ }
687
+ catch {
688
+ // one retry below — the first failure is reported only if the retry also fails
689
+ }
690
+ const journalPath = join(journal.dir, "journal.jsonl");
691
+ try {
692
+ terminateTornJournalTail(journalPath);
693
+ journal.append("run-end", undefined, record);
694
+ }
695
+ catch (retryErr) {
696
+ console.error(`tickmarkr ${runId}: run crashed — no terminal record written; journal sink unwritable at ${journalPath} (${retryErr instanceof Error ? retryErr.message : String(retryErr)}) — original error: ${original}`);
697
+ }
698
+ }
634
699
  export async function runDaemon(repoRoot, opts = {}) {
635
700
  // v1.51 T2 / OBS-89 (v1.60): retired --quality env seam. Mode resolution owns premium routing;
636
701
  // route() no longer reads the retired env at all, so the old entrypoint scrub is gone with it.
@@ -2719,20 +2784,11 @@ export async function runDaemon(repoRoot, opts = {}) {
2719
2784
  return summary;
2720
2785
  }
2721
2786
  catch (err) {
2722
- if (runStarted && !taskLoopStarted && !journal.read().some((e) => e.event === "run-end")) {
2723
- journal.append("run-end", undefined, {
2724
- runId,
2725
- branch,
2726
- done: [],
2727
- failed: [],
2728
- human: [],
2729
- blocked: [],
2730
- pending: [],
2731
- phase: "setup",
2732
- fatal: true,
2733
- error: err instanceof Error ? err.message : String(err),
2734
- });
2735
- }
2787
+ // T7 (v1.86): guarded — a journal read/append failure while recording the fatal run-end is
2788
+ // reported alongside err, never instead of it; recordFatalRunEnd never throws, so the original
2789
+ // error (message, stack, cause) always reaches the caller verbatim.
2790
+ if (runStarted && !taskLoopStarted)
2791
+ recordFatalRunEnd(journal, runId, branch, err);
2736
2792
  throw err;
2737
2793
  }
2738
2794
  finally {