tickmarkr 1.52.0 → 1.53.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.
@@ -2,4 +2,5 @@ import { type WorkerAdapter, type WorkerResult } from "./types.js";
2
2
  export declare function kimiAuthed(credentialsText: string, nowMs: number): boolean;
3
3
  export declare function parseKimiModels(raw: string): string[];
4
4
  export declare function parseKimiResult(raw: string, nonce: string): WorkerResult;
5
+ export declare function kimiSessionId(output: string): string | undefined;
5
6
  export declare const kimi: WorkerAdapter;
@@ -46,6 +46,21 @@ export function parseKimiResult(raw, nonce) {
46
46
  const stripped = raw.split("\n").map((l) => l.replace(/^[\s]*[•*-]\s+/, "")).join("\n");
47
47
  return parseWorkerResult(stripped, nonce);
48
48
  }
49
+ // v1.53 T3: session-id capture from the run-output trailer — every `kimi -p` run (fresh or resumed)
50
+ // ends with `To resume this session: kimi -r session_<uuid>` (live probe 2026-07-18). Anchored full
51
+ // line only: prompt/model prose can contain lookalike text, and the anchored charset keeps a
52
+ // captured id shell-safe by construction (shq in resumeCommand is the second layer). Last valid
53
+ // line wins — a run may echo stale resume lines mid-transcript.
54
+ const RESUME_TRAILER_RE = /^\s*To resume this session: kimi -r (session_[0-9a-f-]+)\s*$/;
55
+ export function kimiSessionId(output) {
56
+ let id;
57
+ for (const line of output.split("\n")) {
58
+ const m = RESUME_TRAILER_RE.exec(line);
59
+ if (m)
60
+ id = m[1];
61
+ }
62
+ return id;
63
+ }
49
64
  export const kimi = {
50
65
  id: "kimi",
51
66
  vendor: "moonshot",
@@ -73,6 +88,15 @@ export const kimi = {
73
88
  // ("unknown command '…'", live-verified 2026-07-17, OBS-67) and -p is non-interactive-only.
74
89
  // null → the daemon's print fallback (types.ts:101) keeps kimi workers visible without a TUI.
75
90
  interactiveCommand: () => null,
91
+ // v1.53 T3 resume — live-probed 2026-07-18: `-p` + `-S <id>` compose cleanly (no OBS-67-class
92
+ // flag rejection) and the resumed session carries prior conversation state. `-S <id>` is the
93
+ // deterministic form; `-c` rejected as primary — cwd-keyed, nondeterministic under worktree
94
+ // recreation (probe finding 4). Never bare `-S` (it launches the interactive session picker).
95
+ resumeCommand: (sessionId, promptFile, model) => `kimi -S ${shq(sessionId)} -p "$(cat ${shq(promptFile)})" --model ${shq(model)} --output-format text`,
96
+ sessionIdFrom: kimiSessionId,
97
+ // KIMI-03: no contextUsage surface exists, so a known-context requirement would leave resume
98
+ // dead code — declare the unknown-context opt-in the daemon retry seam enforces.
99
+ resumeUnknownContext: true,
76
100
  invoke(task, _cwd, a, ctx) {
77
101
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
78
102
  },
@@ -79,6 +79,8 @@ export interface WorkerAdapter {
79
79
  headlessCommand(promptFile: string, model: string): string;
80
80
  interactiveCommand(promptFile: string, model: string): string | null;
81
81
  resumeCommand?(sessionId: string, promptFile: string, model: string): string;
82
+ sessionIdFrom?(output: string): string | undefined;
83
+ resumeUnknownContext?: boolean;
82
84
  invoke(task: Task, cwd: string, a: Assignment, ctx: {
83
85
  promptFile: string;
84
86
  }): Invocation;
@@ -140,6 +140,9 @@ function openTerm(input, output) {
140
140
  finally {
141
141
  rl.close();
142
142
  setRaw(true);
143
+ // rl.close() pauses the input stream — without a resume the next key() await has
144
+ // nothing keeping the event loop alive and the process silently exits 0 (OBS-77)
145
+ input.resume();
143
146
  queue.splice(typedFrom);
144
147
  prevLines = 0;
145
148
  }
@@ -2,7 +2,7 @@ import { allAdapters, discoverChannels, doctorAgeMs, modelAuthExclusions, probeA
2
2
  import { formatModelAuthLine, contextWindowLints, modelLints, ttyVisual } from "../../adapters/model-lints.js";
3
3
  import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
4
4
  import { parseArgs } from "node:util";
5
- import { collateralLints } from "../../compile/collateral.js";
5
+ import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
6
6
  import { DEFAULT_CONFIG, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
7
7
  import { loadGraph } from "../../graph/graph.js";
8
8
  import { resolveRunMode } from "../../run/daemon.js";
@@ -170,8 +170,8 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters())
170
170
  const windowLints = contextWindowLints(g.tasks, routed, cfg, cwd);
171
171
  if (windowLints.length)
172
172
  lines.push("", "context window lints:", ...windowLints.map((l) => ` ! ${l}`));
173
- // OBS-12/13/14/21: advisory only — never blocks --route-strict (run refuses on routing lints only)
174
- const scopeLints = collateralLints(g.tasks, cwd);
173
+ // OBS-12/13/14/21 + OBS-76: advisory only — never blocks --route-strict (run refuses on routing lints only)
174
+ const scopeLints = [...collateralLints(g.tasks, cwd), ...sourceScopeLints(g.tasks, cwd)];
175
175
  if (scopeLints.length)
176
176
  lines.push("", "scope lints:", ...scopeLints.map((l) => ` ! ${l}`));
177
177
  return stylizePlan(lines.join("\n"));
@@ -94,6 +94,14 @@ const wallClock = (start, end) => {
94
94
  return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
95
95
  };
96
96
  const detail = (value) => typeof value === "string" || typeof value === "number" ? String(value) : EM;
97
+ // v1.53 T5: supersession is derived from the run's OWN journal only — `superseded by` from the
98
+ // appended superseded event (last wins), `supersedes` from the run-start stamp. No cross-run scan.
99
+ const supersession = (events) => {
100
+ const by = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string")?.data.by;
101
+ const start = events.find((e) => e.event === "run-start");
102
+ const supersedes = typeof start?.data.supersedes === "string" ? start.data.supersedes : undefined;
103
+ return { ...(typeof by === "string" ? { supersededBy: by } : {}), ...(supersedes ? { supersedes } : {}) };
104
+ };
97
105
  const taskIds = (events) => {
98
106
  const seen = new Set();
99
107
  const out = [];
@@ -152,10 +160,13 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
152
160
  usageLines.push(...journalUsage(events, new Set(prices.map((row) => `${row.channel}:${row.adapter}:${row.model}`))));
153
161
  if (!usageLines.length)
154
162
  usageLines.push("- **not recorded:** attempts/windows: not measurable; tokens: not measurable; price: not measurable");
163
+ const sup = supersession(events);
155
164
  const lines = [
156
165
  `# tickmarkr engagement`,
157
166
  "",
158
167
  `- **runId:** ${runId}`,
168
+ ...(sup.supersededBy ? [`- **superseded by:** ${sup.supersededBy}`] : []),
169
+ ...(sup.supersedes ? [`- **supersedes:** ${sup.supersedes}`] : []),
159
170
  `- **base ref:** ${baseRef}`,
160
171
  `- **branch:** ${branch}`,
161
172
  `- **done:** ${count("done")}`,
@@ -269,8 +280,11 @@ function textReport(runId, events, rows, cwd) {
269
280
  const escalations = events.filter((e) => e.event === "escalation").length;
270
281
  const consults = events.filter((e) => e.event === "consult-verdict").length;
271
282
  const failovers = events.filter((e) => e.event === "quota-failover").length;
283
+ const sup = supersession(events);
272
284
  return [
273
285
  `tickmarkr engagement — ${runId}`,
286
+ ...(sup.supersededBy ? [`superseded by ${sup.supersededBy}`] : []),
287
+ ...(sup.supersedes ? [`supersedes ${sup.supersedes}`] : []),
274
288
  "",
275
289
  "engagement summary — audit trail:",
276
290
  ...[...groups.entries()].map(([k, g]) => ` ${k.padEnd(30)} tasks ${g.rows.length}, attempts ${g.rows.reduce((s, r) => s + r.attempts, 0)}, done ${g.rows.filter((r) => r.outcome === "done").length}`),
@@ -39,6 +39,7 @@ export async function run(argv, cwd = process.cwd()) {
39
39
  "no-explore": { type: "boolean" },
40
40
  mode: { type: "string" },
41
41
  quality: { type: "boolean" },
42
+ supersedes: { type: "string" },
42
43
  },
43
44
  });
44
45
  if (values.concurrency !== undefined) {
@@ -83,6 +84,7 @@ export async function run(argv, cwd = process.cwd()) {
83
84
  concurrency: values.concurrency ? Number(values.concurrency) : undefined,
84
85
  driver: pickDriver(cfg, values.driver),
85
86
  mode: flagMode,
87
+ supersedes: values.supersedes,
86
88
  narrate: (event) => console.log(narrationLine(event)),
87
89
  });
88
90
  const out = `run ${s.runId} finished — ${formatSummary(s)} (merge to main is a human decision)`;
@@ -120,9 +120,12 @@ const renderFrame = (cwd) => {
120
120
  let events = [];
121
121
  const contexts = new Map();
122
122
  let comparable = false;
123
+ let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
123
124
  if (runId) {
124
125
  const j = Journal.open(cwd, runId);
125
126
  events = j.read();
127
+ const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
128
+ supersededBy = sup?.data.by;
126
129
  // T3 (Sol #2 / Fable F2): the SAME comparator resume uses (engagementComparable) — one decision,
127
130
  // two consumers. graphDefinitionHash (compiled task definitions only) survives status/evidence
128
131
  // mutation but changes when a task definition changes, so a recompiled graph is detected here too.
@@ -164,7 +167,7 @@ const renderFrame = (cwd) => {
164
167
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
165
168
  });
166
169
  const header = runId
167
- ? `tickmarkr status${divider}run ${runId}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
170
+ ? `tickmarkr status${divider}run ${runId}${supersededBy ? `${divider}superseded by ${supersededBy}` : ""}${!comparable ? `${divider}${NOT_COMPARABLE_NOTICE}` : ""}${divider}${liveness(events).replaceAll(" · ", divider)}${divider}${done}/${g.tasks.length} done`
168
171
  : `tickmarkr status${divider}no runs yet${divider}${done}/${g.tasks.length} done`;
169
172
  const legendLine = ` gates: ${GATE_NAMES.map((gate) => `${GATE_KEYS[gate]} ${gate}`).join(divider)}`;
170
173
  return [header, legendLine, ...rows].join("\n");
@@ -186,7 +189,7 @@ const renderFrame = (cwd) => {
186
189
  const tally = `${done}/${g.tasks.length} done`;
187
190
  const header = ` ${title(runId ? `run ${runId}` : "tickmarkr")}${dot}` +
188
191
  (runId
189
- ? `${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}`
192
+ ? `${supersededBy ? `${warn(`superseded by ${supersededBy}`)}${dot}` : ""}${!comparable ? `${warn(NOT_COMPARABLE_NOTICE)}${dot}` : ""}${live}${dot}`
190
193
  : `no runs yet${dot}`) +
191
194
  `${gauge} ${done === g.tasks.length && g.tasks.length > 0 ? ok(tally) : tally}`;
192
195
  const hr = rule(Math.min(width, 100));
@@ -1,6 +1,12 @@
1
- import type { Task } from "../graph/schema.js";
1
+ import { type Task } from "../graph/schema.js";
2
2
  /**
3
3
  * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
4
4
  * Each line names the task id and at least one missing collateral test path.
5
5
  */
6
6
  export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
7
+ /**
8
+ * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
9
+ * criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
10
+ * Advisory plan output only, same contract as collateralLints.
11
+ */
12
+ export declare function sourceScopeLints(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string): string[];
@@ -1,10 +1,11 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { extname, join, relative } from "node:path";
3
3
  import picomatch from "picomatch";
4
- // Advisory plan-time scan only (OBS-12/13/14/21). NEVER expands files[], fails compile, or
5
- // feeds the scope gate a warning the author acts on. Plain-text (no AST), capped + sorted.
6
- /** Max test files walked under tests/ (sorted walk; rest ignored). */
7
- const MAX_TEST_FILES = 400;
4
+ import { renderAcceptanceItem } from "../graph/schema.js";
5
+ // Advisory plan-time scan only (OBS-12/13/14/21, OBS-76). NEVER expands files[], fails compile,
6
+ // or feeds the scope gate a warning the author acts on. Plain-text (no AST), capped + sorted.
7
+ /** Max files walked per root (sorted walk; rest ignored). */
8
+ const MAX_WALK_FILES = 400;
8
9
  /** Max collateral test paths listed per task. */
9
10
  const MAX_HITS_PER_TASK = 20;
10
11
  /** Skip giant fixtures / snapshots. */
@@ -22,23 +23,23 @@ function needlesFor(srcPath) {
22
23
  // repo-relative import forms that appear in tests (ESM often uses .js)
23
24
  return [...new Set([noExt, `${noExt}.js`, `${noExt}.ts`, n])].filter((s) => s.length > 0);
24
25
  }
25
- function walkTests(repoRoot) {
26
- const root = join(repoRoot, "tests");
26
+ function walkCode(repoRoot, subdir) {
27
+ const root = join(repoRoot, subdir);
27
28
  const out = [];
28
29
  const walk = (dir) => {
29
- if (out.length >= MAX_TEST_FILES)
30
+ if (out.length >= MAX_WALK_FILES)
30
31
  return;
31
32
  let entries;
32
33
  try {
33
34
  entries = readdirSync(dir, { withFileTypes: true });
34
35
  }
35
36
  catch {
36
- return; // no tests/ or unreadable — zero lints
37
+ return; // no such root or unreadable — zero lints
37
38
  }
38
39
  // deterministic order
39
40
  entries.sort((a, b) => a.name.localeCompare(b.name));
40
41
  for (const e of entries) {
41
- if (out.length >= MAX_TEST_FILES)
42
+ if (out.length >= MAX_WALK_FILES)
42
43
  return;
43
44
  if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith("."))
44
45
  continue;
@@ -70,17 +71,10 @@ function mentions(content, needles) {
70
71
  }
71
72
  return false;
72
73
  }
73
- /**
74
- * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
75
- * Each line names the task id and at least one missing collateral test path.
76
- */
77
- export function collateralLints(tasks, repoRoot) {
78
- const testFiles = walkTests(repoRoot);
79
- if (!testFiles.length)
80
- return [];
81
- // cache file bodies (one read per test path)
74
+ // cache file bodies (one read per path)
75
+ function makeReader(repoRoot) {
82
76
  const body = new Map();
83
- const read = (rel) => {
77
+ return (rel) => {
84
78
  if (body.has(rel))
85
79
  return body.get(rel);
86
80
  try {
@@ -98,6 +92,16 @@ export function collateralLints(tasks, repoRoot) {
98
92
  return null;
99
93
  }
100
94
  };
95
+ }
96
+ /**
97
+ * Return human-readable scope-lint lines for plan output (no `!` prefix — plan owns that).
98
+ * Each line names the task id and at least one missing collateral test path.
99
+ */
100
+ export function collateralLints(tasks, repoRoot) {
101
+ const testFiles = walkCode(repoRoot, "tests");
102
+ if (!testFiles.length)
103
+ return [];
104
+ const read = makeReader(repoRoot);
101
105
  const lines = [];
102
106
  for (const t of tasks) {
103
107
  // OBS-22: scopeGate accepts picomatch globs; advisory collateral warnings must agree.
@@ -128,3 +132,56 @@ export function collateralLints(tasks, repoRoot) {
128
132
  }
129
133
  return lines;
130
134
  }
135
+ // v1.53 T4 (OBS-76): needles are code-shaped tokens only (camelCase / snake_case) — plain prose
136
+ // words never match, so prose-only criteria yield zero needles instead of alarm-fatigue noise.
137
+ // ponytail: token heuristic, not AST symbol resolution — promote after a version of precision data.
138
+ function criteriaSymbols(acceptance) {
139
+ const out = new Set();
140
+ for (const item of acceptance) {
141
+ for (const tok of renderAcceptanceItem(item).match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) ?? []) {
142
+ if (tok.includes("_") || /[a-z][A-Z]/.test(tok))
143
+ out.add(tok);
144
+ }
145
+ }
146
+ return [...out].sort();
147
+ }
148
+ /**
149
+ * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
150
+ * criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
151
+ * Advisory plan output only, same contract as collateralLints.
152
+ */
153
+ export function sourceScopeLints(tasks, repoRoot) {
154
+ const perTask = tasks
155
+ .map((t) => ({ t, needles: criteriaSymbols(t.acceptance) }))
156
+ .filter((x) => x.needles.length);
157
+ if (!perTask.length)
158
+ return [];
159
+ const srcFiles = walkCode(repoRoot, "src");
160
+ if (!srcFiles.length)
161
+ return [];
162
+ const read = makeReader(repoRoot);
163
+ const lines = [];
164
+ for (const { t, needles } of perTask) {
165
+ // OBS-22: scopeGate accepts picomatch globs; advisory warnings must agree.
166
+ const scoped = picomatch(t.files.map((f) => f.replace(/^\.\//, "")), { dot: true });
167
+ // needles are word-chars by construction — no regex escaping needed; whole-word match only
168
+ const res = needles.map((n) => new RegExp(`\\b${n}\\b`));
169
+ const hits = [];
170
+ for (const sf of srcFiles) {
171
+ if (scoped(sf))
172
+ continue;
173
+ const text = read(sf);
174
+ if (text === null)
175
+ continue;
176
+ if (res.some((re) => re.test(text)))
177
+ hits.push(sf);
178
+ }
179
+ if (!hits.length)
180
+ continue;
181
+ hits.sort();
182
+ const listed = hits.slice(0, MAX_HITS_PER_TASK).join(", ");
183
+ const tail = hits.length > MAX_HITS_PER_TASK ? " (capped)" : "";
184
+ lines.push(`${t.id}: criteria implicate out-of-scope source not in files[]: ${listed}${tail}`);
185
+ }
186
+ return lines;
187
+ }
@@ -168,6 +168,7 @@ export declare const TickmarkrConfigSchema: z.ZodObject<{
168
168
  review: z.ZodObject<{
169
169
  complexityThreshold: z.ZodNumber;
170
170
  required: z.ZodBoolean;
171
+ prefer: z.ZodOptional<z.ZodArray<z.ZodString>>;
171
172
  }, z.core.$strip>;
172
173
  consult: z.ZodObject<{
173
174
  adapter: z.ZodString;
@@ -152,7 +152,9 @@ export const TickmarkrConfigSchema = z.object({
152
152
  allowDeviations: z.array(z.string()),
153
153
  }).partial().optional(),
154
154
  judge: z.object({ adapter: z.string(), model: z.string() }),
155
- review: z.object({ complexityThreshold: z.number(), required: z.boolean() }),
155
+ // v1.53 T2: prefer — ordered reviewer preference (entry grammar: adapter | adapter:model, same as
156
+ // routing.map.prefer). Reorders diversity-eligible channels only; never widens or narrows eligibility.
157
+ review: z.object({ complexityThreshold: z.number(), required: z.boolean(), prefer: z.array(z.string()).optional() }),
156
158
  consult: z.object({ adapter: z.string(), model: z.string(), stallMinutes: z.number().positive() }),
157
159
  visibility: z.object({
158
160
  llm: z.enum(["pane", "headless"]),
@@ -474,7 +476,9 @@ export function configTemplate(overlay) {
474
476
  # test: npm test
475
477
  # byShape:
476
478
  # docs: { acceptance: false, review: false } # baseline, evidence, and scope are mandatory
477
- # review: { complexityThreshold: 7, required: true }
479
+ # review: { complexityThreshold: 7, required: true, prefer: [codex:gpt-5.6-sol, kimi] }
480
+ # # prefer: ordered reviewer seat preference (adapter | adapter:model); ranks
481
+ # # diversity-eligible channels only — never admits a same-vendor/same-model reviewer
478
482
  # consult: { adapter: claude-code, model: fable, stallMinutes: 15 }
479
483
  # cost: # v1.20 REC-02 — OPTIONAL price table for the usage/cost report. Absent ⇒ every
480
484
  # # channel reports "not measurable" (never a crash or a fake $0). Two economics,
@@ -15,5 +15,6 @@ export declare function checkDiffCap(gate: string, measured: number, cap: number
15
15
  export declare function isDiffCapPark(result: GateResult): boolean;
16
16
  export declare function diffCapParkReason(results: GateResult[]): string | null;
17
17
  export declare function modelId(model: string): string;
18
- export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[]): BillingChannel | null;
18
+ export declare function pickReviewer(author: Assignment, channels: BillingChannel[], exclude?: string[], // v1.1 failover: reviewer channels that already produced garbage for this task
19
+ prefer?: string[]): BillingChannel | null;
19
20
  export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[]): Promise<GateResult>;
@@ -39,7 +39,15 @@ export function diffCapParkReason(results) {
39
39
  export function modelId(model) {
40
40
  return model.slice(model.lastIndexOf("/") + 1);
41
41
  }
42
- export function pickReviewer(author, channels, exclude = []) {
42
+ // v1.53 T2: same entry grammar as routing.map.prefer (router.ts preferIndex router is out of this
43
+ // module's dependency direction for a private fn, so the 3 lines live here too): `adapter` matches
44
+ // every channel of that adapter, `adapter:model` exactly one; unmatched channels sort after all entries.
45
+ function reviewPreferIndex(c, prefer) {
46
+ const i = prefer.findIndex((p) => p === c.adapter || p === channelKey(c));
47
+ return i === -1 ? prefer.length : i;
48
+ }
49
+ export function pickReviewer(author, channels, exclude = [], // v1.1 failover: reviewer channels that already produced garbage for this task
50
+ prefer = []) {
43
51
  // FLEET-05 success criterion 2: an author not resolvable in the channel list yields NO reviewer.
44
52
  // The old `?? author.adapter` fallback compared an adapter id to vendor names, matched nothing, and
45
53
  // admitted every reviewer — including the author's own channel (fail-OPEN). null lands on reviewGate's
@@ -49,15 +57,16 @@ export function pickReviewer(author, channels, exclude = []) {
49
57
  return null;
50
58
  return (channels
51
59
  // two independent axes: different vendor AND different base-model identity (ADDED TO the vendor
52
- // rule, never replacing it — a future edit can't silently drop either).
60
+ // rule, never replacing it — a future edit can't silently drop either). The diversity filter runs
61
+ // BEFORE preference ranking: prefer sorts survivors only, so no entry can resurrect an excluded channel.
53
62
  .filter((c) => c.vendor !== authorChannel.vendor && modelId(c.model) !== modelId(author.model) && !exclude.includes(channelKey(c)))
54
- .sort((a, b) => TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b))[0] ?? null);
63
+ .sort((a, b) => reviewPreferIndex(a, prefer) - reviewPreferIndex(b, prefer) || TIER_RANK[b.tier] - TIER_RANK[a.tier] || marginalCostRank(a) - marginalCostRank(b))[0] ?? null);
55
64
  }
56
65
  export async function reviewGate(task, worktree, baseRef, author, channels, adapters, cfg, via, excludeReviewers) {
57
66
  if (task.complexity < cfg.review.complexityThreshold) {
58
67
  return { gate: "review", pass: true, details: `skipped — complexity ${task.complexity} < threshold ${cfg.review.complexityThreshold}`, meta: { skipped: true } };
59
68
  }
60
- const reviewer = pickReviewer(author, channels, excludeReviewers ?? []);
69
+ const reviewer = pickReviewer(author, channels, excludeReviewers ?? [], cfg.review.prefer ?? []);
61
70
  if (!reviewer) {
62
71
  return cfg.review.required
63
72
  ? { gate: "review", pass: false, details: "no cross-vendor reviewer available (diversity rule); set review.required:false to waive" }
@@ -5,6 +5,7 @@ import { type JournalEvent } from "./journal.js";
5
5
  export interface RunOptions {
6
6
  runId?: string;
7
7
  resume?: boolean;
8
+ supersedes?: string;
8
9
  graphChanged?: boolean;
9
10
  concurrency?: number;
10
11
  driver?: ExecutorDriver;
@@ -98,6 +98,10 @@ export async function runDaemon(repoRoot, opts = {}) {
98
98
  // HARD-01/02: hold the run lock across the whole read-modify-write of graph.json. Acquire
99
99
  // BEFORE loadGraph; release in the finally below (every exit path, incl. throws).
100
100
  const runId = opts.runId ?? newRunId();
101
+ // v1.53 T5: an unknown --supersedes id must fail BEFORE any run starts — Journal.open throws and
102
+ // no lock, journal, or baseline for the new run has been created yet. Opened without a narrate
103
+ // sink: the prior journal append below is silent bookkeeping, not this run's narration.
104
+ const prior = opts.supersedes !== undefined ? Journal.open(repoRoot, opts.supersedes) : undefined;
101
105
  // T6 narrator: one live status surface per run (herdr only — driver.narrator is undefined on
102
106
  // subprocess, so the optional-chain open below is a no-op there). Cosmetic-only: any failure is
103
107
  // swallowed (never affects the run); the operator closes a surviving watch pane.
@@ -140,6 +144,11 @@ export async function runDaemon(repoRoot, opts = {}) {
140
144
  // equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
141
145
  const resume = opts.resume ? journal.replayResumeState() : new Map();
142
146
  if (opts.resume) {
147
+ // v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
148
+ // two-concurrent-runs hazard supersession exists to prevent. Fail closed, naming the successor.
149
+ const superseded = [...journal.read()].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
150
+ if (superseded)
151
+ throw new Error(`refusing to resume ${runId}: superseded by ${superseded.data.by}`);
143
152
  const start = journal.read().find((e) => e.event === "run-start");
144
153
  if (!start)
145
154
  throw new Error(`journal for ${runId} has no run-start event`);
@@ -175,7 +184,10 @@ export async function runDaemon(repoRoot, opts = {}) {
175
184
  baseRef = await gitHead(repoRoot);
176
185
  baseline = await captureBaseline(repoRoot, commands);
177
186
  writeFileSync(join(journal.dir, "baseline.json"), JSON.stringify(baseline, null, 2));
178
- journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2
187
+ journal.append("run-start", undefined, { pid: process.pid, baseRef, commands, channels: channels.map(channelKey), branch, graphDefinitionHash: graphDefinitionHash(graph), mode: rm.mode.mode, modeSource: rm.source, ...(prior ? { supersedes: prior.runId } : {}) }); // graphDefinitionHash: T3 engagement identity (status+resume share it); pid: v1.13 (VIS-11) liveness; mode/modeSource: v1.51 T2; supersedes: v1.53 T5
188
+ // v1.53 T5: mark the prior run AFTER this run's run-start exists, so the prior journal never
189
+ // names a successor that has no journal. Append-only — the prior journal is never rewritten.
190
+ prior?.append("superseded", undefined, { by: runId });
179
191
  }
180
192
  // T6: open the narrator AFTER run-start/run-resume is journaled so the watch surface has a run to
181
193
  // show. driver.narrator is undefined on subprocess → no-op (subprocess spawns nothing). Swallowed:
@@ -412,13 +424,18 @@ export async function runDaemon(repoRoot, opts = {}) {
412
424
  }
413
425
  // v1.29: consume the prior gate-failed session once. Same channel + known under-threshold context
414
426
  // + adapter capability resumes; every other path is today's fresh dispatch.
427
+ // v1.53 T3: an adapter with no context surface at all (kimi, KIMI-03) may declare
428
+ // resumeUnknownContext to loosen ONLY the contextTokens-known requirement — a KNOWN
429
+ // over-threshold context still forces fresh, and the escalation ladder bounds the chain.
415
430
  const priorSession = retrySession;
416
431
  retrySession = undefined;
432
+ const retryAdapter = adapters.find((a) => a.id === assignment.adapter);
417
433
  retryMode = priorSession
418
434
  && priorSession.channel === channelKey(assignment)
419
- && priorSession.contextTokens !== undefined
420
- && priorSession.contextTokens < cfg.contextWarnTokens
421
- && adapters.find((a) => a.id === assignment.adapter)?.resumeCommand
435
+ && (priorSession.contextTokens !== undefined
436
+ ? priorSession.contextTokens < cfg.contextWarnTokens
437
+ : retryAdapter?.resumeUnknownContext === true)
438
+ && retryAdapter?.resumeCommand
422
439
  ? "resume"
423
440
  : "fresh";
424
441
  // v1.23 T3: over-threshold context still forces fresh at the retry boundary; never interrupt a
@@ -854,7 +871,9 @@ export async function runDaemon(repoRoot, opts = {}) {
854
871
  break gateLoop;
855
872
  }
856
873
  gateFails++; // this attempt's gates failed — the one place quality degradation is verified (never inferred from attempts)
857
- retrySession = { channel: channelKey(assignment), id: sessionId, contextTokens };
874
+ // v1.53 T3: prefer the CLI's own session id captured from this attempt's output (kimi's resume
875
+ // trailer) over the harness slot name; absent hook or no capture keeps today's slot-name id.
876
+ retrySession = { channel: channelKey(assignment), id: adapter.sessionIdFrom?.(output) ?? sessionId, contextTokens };
858
877
  feedback = results.filter((g) => !g.pass).map((g) => `${g.gate}: ${g.details}`).join("\n\n");
859
878
  const step = r.ladder[Math.min(ladderIdx++, r.ladder.length - 1)];
860
879
  journal.append("escalation", t.id, { step, attempt: attempt + 1 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.52.0",
3
+ "version": "1.53.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",