tickmarkr 2.8.3 → 2.8.4

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.
@@ -238,6 +238,9 @@ export const claudeCode = {
238
238
  // The empty -p argument selects print mode while stdin carries the prompt, keeping its nonce out
239
239
  // of process argv. The redirect path is shell-quoted independently from the model.
240
240
  headlessCommand: (promptFile, model, effort) => `claude -p '' --model ${shq(model)}${claudeEffortFlag(effort)} --permission-mode bypassPermissions --strict-mcp-config --mcp-config '{"mcpServers":{}}' --output-format text < ${shq(promptFile)}`,
241
+ // Queue row 184: `--output-format text` prints nothing until the turn ends, so a working pane reviewer reads zero seat bytes at
242
+ // the first-liveness beat; the beat must not re-route it (2.8.3 killed every claude pane reviewer at 30 s).
243
+ paneReviewBuffersOutput: true,
241
244
  // HYG-03 / OBS-137: the residual first-entry dialog is workspace trust, not MCP config loading.
242
245
  // Claude's only store is global last-writer-wins ~/.claude.json, so tickmarkr still does not seed it;
243
246
  // the daemon safely answers only the exact adapter-declared dialog once per slot.
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
@@ -171,7 +172,32 @@ export function confirmKimiSeedBanner(banner, assignedModel) {
171
172
  }
172
173
  return { ok: true, status: "confirmed", ...(sessionId === undefined ? {} : { sessionId }) };
173
174
  }
174
- const kimiTuiLaunch = (model) => `kimi -y -m ${shq(model)}`;
175
+ const KIMI_HELP_TIMEOUT_MS = 10_000;
176
+ function kimiHasNeverAskOption(help) {
177
+ let inOptions = false;
178
+ for (const line of help.split(/\r?\n/)) {
179
+ if (line === "Options:") {
180
+ inOptions = true;
181
+ continue;
182
+ }
183
+ if (inOptions && /^[^\s].*:$/.test(line))
184
+ break;
185
+ if (inOptions && /^ --auto[ \t]{2,}Start in Never Ask mode\b/.test(line))
186
+ return true;
187
+ }
188
+ return false;
189
+ }
190
+ const kimiTuiLaunch = (model) => {
191
+ let neverAsk = false;
192
+ try {
193
+ const help = spawnSync("kimi", ["--help"], {
194
+ encoding: "utf8", timeout: KIMI_HELP_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"],
195
+ });
196
+ neverAsk = !help.error && help.status === 0 && kimiHasNeverAskOption(help.stdout);
197
+ }
198
+ catch { /* unreadable help keeps the existing launch form */ }
199
+ return `kimi ${neverAsk ? "--auto" : "-y"} -m ${shq(model)}`;
200
+ };
175
201
  const KIMI_ANSI_SGR_RE = /\u001B\[[0-9;]*m/g;
176
202
  const KIMI_EDITOR_TOP_RE = /^╭─+╮$/;
177
203
  const KIMI_EDITOR_INPUT_RE = /^│ > .*│$/;
@@ -215,7 +241,10 @@ export const KIMI_INPUT_BOX = declareInputBox("kimi", {
215
241
  fingerprint: "│ > ",
216
242
  match: (paneText) => matchesKimiEditorBox(paneText, false),
217
243
  emptyMatch: (paneText) => matchesKimiEditorBox(paneText, true),
218
- launchCommand: (command) => command.startsWith("kimi -y -m "),
244
+ launchCommand: (command) => {
245
+ const match = /^kimi (?:--auto|-y) -m '([^']+)'$/.exec(command);
246
+ return match !== null && MODEL_ID_RE.test(match[1]);
247
+ },
219
248
  readinessTimeoutMs: 15_000,
220
249
  });
221
250
  // Shared launch-then-seed surface (T6) + banner confirm (T7/T2). One definition so the adapter
@@ -195,6 +195,7 @@ export interface WorkerAdapter {
195
195
  channels(cfg: TickmarkrConfig): BillingChannel[];
196
196
  headlessCommand(promptFile: string, model: string, effort?: Effort): string;
197
197
  harnessBannerRows?: readonly string[];
198
+ paneReviewBuffersOutput?: boolean;
198
199
  interactiveCommand(promptFile: string, model: string, effort?: Effort): string | null;
199
200
  interactiveSeed?: InteractiveSeed;
200
201
  resumeCommand?(sessionId: string, promptFile: string, model: string, effort?: Effort): string;
@@ -1,11 +1,13 @@
1
- import { readdirSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { randomBytes } from "node:crypto";
2
+ import { lstatSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
5
  import { z } from "zod";
4
6
  import { channelKey, shq } from "../adapters/types.js";
5
7
  import { compileNative } from "../compile/native.js";
6
8
  import { DEFAULT_DIFF_CAP } from "../config/config.js";
7
9
  import { renderAcceptanceItem } from "../graph/schema.js";
8
- import { sh } from "../run/git.js";
10
+ import { DEFAULT_SHELL_TIMEOUT_MS, sh, shell } from "../run/git.js";
9
11
  import { checkTaskDiffCaps, fetchTaskDiff, isProtectedEvidence, setAsideReceiptPath } from "./review.js";
10
12
  import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, SeatLaunchError, verdictNonceLine } from "./llm.js";
11
13
  import { classifyVerdictCause } from "./verdict-cause.js";
@@ -13,6 +15,8 @@ import { reviewableLogicDiff } from "./artifact-manifest.js";
13
15
  import { diffSidePath } from "./git-paths.js";
14
16
  import { classifyFailureOutput } from "./baseline.js";
15
17
  import { operationFactsAtHead, readOperationFacts } from "./operation-facts.js";
18
+ import { isVitestTestCommand, manifestChildEnv, readTestReport, singleForkRetryCommand, toManifestPath } from "./test-manifest.js";
19
+ import { TEST_REPORTER_SOURCE } from "./test-reporter.js";
16
20
  // Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
17
21
  const JUDGE_TIMEOUT_MS = 900_000;
18
22
  const CitationSchema = z.object({ path: z.string(), line: z.number().int() });
@@ -98,6 +102,26 @@ export function testFiltered(testCmd, name) {
98
102
  const fwd = wrapped ? "-- " : "";
99
103
  return `${testCmd} ${fwd}-t ${shq(pattern)}`;
100
104
  }
105
+ // Queue row 179: the criterion's suite file as the runner's file argument AHEAD of the title filter, appended to a
106
+ // base that already carries its forwarding separator (npmForwarded, under either runner). testFiltered keeps its
107
+ // two-argument form: a criterion with no suite file, and the eval harness.
108
+ const fileFiltered = (base, file, name) => `${base} ${shq(file)} -t ${shq(testFilterPattern(name))}`;
109
+ // The forwarding separator, decided on shell WORDS — the test gate's tokenizer (test-manifest.ts shellTokens/words,
110
+ // unexported) beside its unquoting — never on the raw text: behind any `NAME=value` assignments (whose value, even
111
+ // ` -- `, is not a word of the command), an npm manager word, bare, quoted or escaped, gets ONE `--` unless a later
112
+ // word already is `--` (bare, quoted or escaped). npx, pnpm, yarn and a direct runner forward their arguments as they
113
+ // are. Both runners' branches append through it; the Vitest branch asks the gate's parser only whether the reporter
114
+ // can be forwarded at all (V5), since that parser's own separator reads the raw text.
115
+ const SHELL_TOKEN = /(?:[^\s'"\\&|;<>]+|'[^']*'|"(?:\\.|[^"\\])*"|\\.)+|[&|;<>]+/g;
116
+ const unquoted = (token) => token.replace(/'([^']*)'|"((?:\\.|[^"\\])*)"|\\(.)/g, (_all, single, double, escaped) => single ?? (double !== undefined ? double.replace(/\\(["\\$`])/g, "$1") : escaped));
117
+ function npmForwarded(testCmd) {
118
+ const raw = testCmd.match(SHELL_TOKEN) ?? [];
119
+ let manager = 0;
120
+ while (/^[A-Za-z_]\w*=/.test(raw[manager] ?? ""))
121
+ manager++;
122
+ const argv = raw.slice(manager).map(unquoted);
123
+ return argv[0] === "npm" && !argv.includes("--") ? `${testCmd} --` : testCmd;
124
+ }
101
125
  const VitestListedTestsSchema = z.array(z.object({
102
126
  name: z.string(),
103
127
  file: z.string(),
@@ -238,6 +262,99 @@ function oracleExecutionFailure(label, code, stdout, stderr) {
238
262
  details: `oracle failed: ${label} (exit ${code})${tail(stderr || stdout)}`,
239
263
  };
240
264
  }
265
+ // today's rule for a test oracle's run: exit 0 AND a positive printed run count (OBS-55: a zero-match run is vacuous).
266
+ function countedTestRun(label, filter, r) {
267
+ if (r.code !== 0)
268
+ return oracleExecutionFailure(label, r.code, r.stdout, r.stderr);
269
+ const out = (r.stderr || "") + "\n" + (r.stdout || "");
270
+ const ran = testsRan(out);
271
+ if (ran === null || ran < 1) {
272
+ return { gate: "acceptance", pass: false,
273
+ details: `oracle failed: ${label} — name filter matched zero tests (filter: ${filter})${tail(out)}` };
274
+ }
275
+ return undefined;
276
+ }
277
+ /**
278
+ * Queue row 179 (D-2018, D-2019): a test criterion that declares its suite file L is satisfied by L alone — run
279
+ * 1248's oracles named one file and a same-titled test in another satisfied them. Closed table:
280
+ * L1 L absent or not a regular file (a directory included) in the worktree: a work verdict naming L, under either
281
+ * runner, and the test command never starts.
282
+ * Vitest: L alone, file argument then title filter, under the test gate's nonce-bound per-file reporter, because
283
+ * Vitest's file filter selects EVERY path containing L. PASS only when that report, of this nonce and ending in
284
+ * its run-end certificate with exit 0, shows a passed body in module L and none run in any other module.
285
+ * V2 L without the title (another file holding it): no passed body in L. V3 a deeper path ending with L holding
286
+ * it, whether or not L does: a body run in another module. V4 report missing, another nonce or no certificate,
287
+ * and V5 a script body the reporter cannot be forwarded to: fail closed, unverified.
288
+ * Other runner: L as its file argument before the title filter, today's run-count rule. Declared residual: such a
289
+ * runner gives no per-file evidence, so a test of the title in a path containing L can still satisfy it there.
290
+ */
291
+ async function landedTestOracle(testCmd, title, landing, worktree) {
292
+ const label = `test "${title}" in ${landing}`;
293
+ const passed = { gate: "acceptance", pass: true, details: `✓ test: ${title} (exit 0) in ${landing}` };
294
+ const work = (why) => ({ gate: "acceptance", pass: false, details: `oracle failed: ${label} — ${why}` });
295
+ const unverified = (why) => ({ gate: "acceptance", pass: false,
296
+ details: `oracle failed: ${label} — ${why}; unverified, failing closed`,
297
+ meta: { cause: "oracle-execution", classification: "infra", infra: true, retryable: false } });
298
+ const path = resolve(worktree, landing);
299
+ const within = relative(worktree, path);
300
+ let regular = false;
301
+ try {
302
+ regular = !within.startsWith("..") && !isAbsolute(within) && lstatSync(path).isFile();
303
+ }
304
+ catch { /* absent */ }
305
+ if (!regular)
306
+ return work(`suite file ${landing} is absent from the worktree or is not a regular file; the test command never started`);
307
+ if (!isVitestTestCommand(testCmd, worktree)) {
308
+ return countedTestRun(label, title, await sh(fileFiltered(npmForwarded(testCmd), landing, title), worktree)) ?? passed;
309
+ }
310
+ // V5: the test gate's own parser, through its one pure export, throws on a compound or non-direct script body,
311
+ // where an appended --reporter would reach some other command. Only its refusal is read: its separator reads the
312
+ // raw text, so a quoted `"--"` or an assignment's ` -- ` value would misplace the file, filter and reporter.
313
+ try {
314
+ singleForkRetryCommand(testCmd, worktree, [], []);
315
+ }
316
+ catch (error) {
317
+ return unverified(`the per-file reporter cannot be forwarded to this test command (${error instanceof Error ? error.message : String(error)})`);
318
+ }
319
+ const cmd = fileFiltered(npmForwarded(testCmd), landing, title);
320
+ const dir = mkdtempSync(join(tmpdir(), "tickmarkr-oracle-"));
321
+ try {
322
+ const nonce = randomBytes(16).toString("hex");
323
+ const reporterPath = join(dir, `test-reporter-${nonce}.mjs`);
324
+ const reportPath = join(dir, `test-report-${nonce}.json`);
325
+ writeFileSync(reporterPath, TEST_REPORTER_SOURCE);
326
+ const r = await shell(`${cmd} --reporter=${shq(reporterPath)} --outputFile=${shq(reportPath)}`, worktree, DEFAULT_SHELL_TIMEOUT_MS, false, { env: { ...manifestChildEnv(worktree), TICKMARKR_TEST_REPORT: reportPath, TICKMARKR_TEST_NONCE: nonce } });
327
+ const raw = readTestReport(reportPath);
328
+ const report = raw?.nonce === nonce ? raw : undefined;
329
+ if (r.code !== 0) {
330
+ // the reporter replaces the runner's own output: its failure identities are the evidence of a red.
331
+ const named = report ? [...Object.values(report.completed).flatMap((c) => c.failures ?? []), ...(report.certificate?.diagnostics ?? [])] : [];
332
+ return oracleExecutionFailure(label, r.code, r.stdout, [r.stderr, ...named].filter(Boolean).join("\n"));
333
+ }
334
+ if (!raw)
335
+ return unverified("the runner exited 0 but wrote no per-file report");
336
+ if (!report)
337
+ return unverified(`the per-file report carries nonce "${raw.nonce}", not this invocation's`);
338
+ if (report.certificate?.exitCode !== 0)
339
+ return unverified("the per-file report never reached its run-end certificate with exit 0");
340
+ const key = toManifestPath(within, worktree);
341
+ // a module completed more than once (one path in several projects) keeps only its LAST counters, so its record
342
+ // cannot show that no earlier completion ran a body: it counts as run.
343
+ const repeated = new Set(report.duplicateCompletions ?? []);
344
+ const elsewhere = [...new Set([...Object.keys(report.started), ...Object.keys(report.completed)])].filter((file) => {
345
+ const t = report.completed[file]?.tests;
346
+ return file !== key && (repeated.has(file) || !t || t.passed + t.failed > 0);
347
+ }).sort();
348
+ if (elsewhere.length)
349
+ return work(`the runner's per-file report shows a test body run outside ${landing}, in ${elsewhere.join(", ")} — only ${landing} can satisfy this criterion`);
350
+ if (!((report.completed[key]?.tests?.passed ?? 0) > 0))
351
+ return work(`the runner's per-file report shows no passing test of that title in ${landing}`);
352
+ return passed;
353
+ }
354
+ finally {
355
+ rmSync(dir, { recursive: true, force: true });
356
+ }
357
+ }
241
358
  // v1.70 / OBS-129: the new-file line numbers each changed HUNK spans, per path — parsed from the
242
359
  // unified-diff so a citation is validated against real changed regions, not a substring of the whole
243
360
  // diff text (which also matches the diff's own +++/@@ headers). A hunk's span is every new-file line it
@@ -355,17 +472,18 @@ judge, via, opts = {}) {
355
472
  return { gate: "acceptance", pass: false,
356
473
  details: `oracle failed: test "${a.test}" — no test command configured to run it (failing closed)` };
357
474
  }
358
- const r = await sh(testFiltered(opts.testCmd, a.test), worktree);
359
- const out = (r.stderr || "") + "\n" + (r.stdout || "");
360
- if (r.code !== 0) {
361
- return oracleExecutionFailure(`test "${a.test}"`, r.code, r.stdout, r.stderr);
475
+ // queue row 179: a declared suite file is the only file that can satisfy its criterion.
476
+ if (a.landing !== undefined) {
477
+ const landed = await landedTestOracle(opts.testCmd, a.test, a.landing, worktree);
478
+ if (!landed.pass)
479
+ return landed;
480
+ passedDet.push(landed.details);
481
+ continue;
362
482
  }
363
483
  // OBS-55: exit 0 alone is vacuous when the name filter matched zero tests — fail closed.
364
- const ran = testsRan(out);
365
- if (ran === null || ran < 1) {
366
- return { gate: "acceptance", pass: false,
367
- details: `oracle failed: test "${a.test}" — name filter matched zero tests (filter: ${a.test})${tail(out)}` };
368
- }
484
+ const red = countedTestRun(`test "${a.test}"`, a.test, await sh(testFiltered(opts.testCmd, a.test), worktree));
485
+ if (red)
486
+ return red;
369
487
  passedDet.push(`✓ test: ${a.test} (exit 0)`);
370
488
  }
371
489
  }
package/dist/gates/llm.js CHANGED
@@ -494,15 +494,20 @@ async function runViaDriverDetailed(adapter, model, prompt, cwd, via, timeoutMs
494
494
  // OBS-1177: the beat is armed for an INTERACTIVE (pane) driver only. On the subprocess driver
495
495
  // a headless `claude -p` buffers every byte until it exits, so 0 seat bytes at 30 s is not a
496
496
  // dead launch; that seat keeps its full ceiling, which still bounds it (fail-closed by timeout).
497
- if (reviewing && via.driver.interactive && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
497
+ // Queue row 184: an adapter whose pane review command buffers its whole output until exit
498
+ // (claude-code's `claude -p`) has written nothing of its own until its verdict, working or
499
+ // dead, so the beat cannot tell the two apart and does not arm for it; its ceiling bounds it.
500
+ if (reviewing && via.driver.interactive && adapter.paneReviewBuffersOutput !== true && !firstLivenessObserved && now - startedAt >= REVIEW_FIRST_LIVENESS_MS) {
498
501
  firstLivenessObserved = true;
499
502
  // RS-2: the beat reads seat-authored bytes ALONE. CPU evidence never holds a preamble-only
500
503
  // capture open to the ceiling; a seat that has not written one byte of its own is re-routed.
501
- // OBS-944 is why a buffering runner earns no exemption here: the claude-code seat's 901 s
502
- // capture was byte-identical across two legs and ended at the pane-identity line — that
503
- // seat never started, it was not quietly working. RULING-229-06 puts the beat on the PANE
504
- // path only; a headless `-p` runner (runHeadlessDetailed, no pane, no beat) buffers every
505
- // byte until completion and keeps its full ceiling.
504
+ // OBS-944: the claude-code seat's 901 s capture was byte-identical across two legs and ended
505
+ // at the pane-identity line — that seat never started. Until 2.8.3 the orca checkout frame
506
+ // counted as seat bytes and kept every claude pane seat past this beat; once it stopped
507
+ // counting (row 135(a)) the beat killed working claude reviewers too, hence the declared
508
+ // exemption above (row 184), which restores that pre-2.8.3 bound for buffering adapters only.
509
+ // RULING-229-06 puts the beat on the PANE path only; a headless `-p` runner
510
+ // (runHeadlessDetailed, no pane, no beat) buffers every byte until completion and keeps its full ceiling.
506
511
  if (seatAuthoredBytes === 0) {
507
512
  launchNeverStarted = true;
508
513
  forceClose = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.8.3",
3
+ "version": "2.8.4",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",