tickmarkr 2.1.9 → 2.2.1

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 (40) hide show
  1. package/dist/adapters/model-lints.js +9 -0
  2. package/dist/adapters/pi.d.ts +1 -0
  3. package/dist/adapters/pi.js +15 -1
  4. package/dist/adapters/prompt.js +11 -3
  5. package/dist/adapters/registry.js +13 -4
  6. package/dist/adapters/types.d.ts +3 -0
  7. package/dist/adapters/types.js +1 -0
  8. package/dist/cli/commands/approve.d.ts +20 -0
  9. package/dist/cli/commands/approve.js +127 -35
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +14 -4
  12. package/dist/cli/commands/plan.js +4 -1
  13. package/dist/cli/commands/verify.js +2 -0
  14. package/dist/cli/commands/version.d.ts +2 -1
  15. package/dist/cli/commands/version.js +25 -4
  16. package/dist/compile/collateral.js +149 -4
  17. package/dist/compile/native.js +2 -0
  18. package/dist/drivers/index.d.ts +1 -0
  19. package/dist/drivers/index.js +25 -10
  20. package/dist/drivers/orca.d.ts +19 -0
  21. package/dist/drivers/orca.js +45 -9
  22. package/dist/gates/baseline.d.ts +2 -0
  23. package/dist/gates/baseline.js +49 -14
  24. package/dist/gates/llm.d.ts +1 -1
  25. package/dist/gates/llm.js +41 -26
  26. package/dist/gates/review.js +2 -0
  27. package/dist/gates/run-gates.d.ts +6 -0
  28. package/dist/gates/run-gates.js +9 -1
  29. package/dist/run/consult.d.ts +7 -0
  30. package/dist/run/consult.js +16 -2
  31. package/dist/run/daemon.d.ts +12 -6
  32. package/dist/run/daemon.js +306 -24
  33. package/dist/run/journal.d.ts +16 -0
  34. package/dist/run/journal.js +51 -6
  35. package/dist/tui/cockpit/setup-cockpit.d.ts +8 -10
  36. package/dist/tui/cockpit/setup-cockpit.js +92 -53
  37. package/package.json +1 -1
  38. package/skills/tickmarkr-loop/SKILL.md +1 -0
  39. package/skills/tickmarkr-overseer/SKILL.md +17 -0
  40. package/skills/tickmarkr-overseer/scripts/watch-context.sh +2 -2
@@ -262,6 +262,148 @@ export function criterionReadsOnly(text, target) {
262
262
  });
263
263
  }
264
264
  const ARCH_PAGES = ["docs/codebase/ARCHITECTURE.md", "docs/codebase/STRUCTURE.md"];
265
+ // files[] is deliberately small, but a brace range can still be broad. If a pattern exceeds
266
+ // this advisory budget, fail open instead of printing an unexpanded brace fragment as a directory.
267
+ const MAX_BRACE_EXPANSIONS = 256;
268
+ const RANGE_CHAR_START = 32;
269
+ const RANGE_CHAR_END = 126;
270
+ function isEscaped(input, index) {
271
+ let backslashes = 0;
272
+ for (let i = index - 1; i >= 0 && input[i] === "\\"; i--)
273
+ backslashes++;
274
+ return backslashes % 2 === 1;
275
+ }
276
+ function unescapeGlobLiteral(input) {
277
+ return input.replace(/\\(.)/g, "$1");
278
+ }
279
+ function rangeParts(body) {
280
+ const parts = [];
281
+ let depth = 0;
282
+ let start = 0;
283
+ let sawRange = false;
284
+ for (let i = 0; i < body.length - 1; i++) {
285
+ if (isEscaped(body, i))
286
+ continue;
287
+ const ch = body[i];
288
+ if (ch === "{") {
289
+ depth++;
290
+ continue;
291
+ }
292
+ if (ch === "}") {
293
+ depth = Math.max(0, depth - 1);
294
+ continue;
295
+ }
296
+ if (ch !== "." || body[i + 1] !== "." || isEscaped(body, i + 1) || depth !== 0)
297
+ continue;
298
+ parts.push(body.slice(start, i));
299
+ i++;
300
+ start = i + 1;
301
+ sawRange = true;
302
+ }
303
+ if (!sawRange)
304
+ return undefined;
305
+ parts.push(body.slice(start));
306
+ return parts;
307
+ }
308
+ function rangeAlternatives(body) {
309
+ const parts = rangeParts(body);
310
+ if (!parts)
311
+ return undefined;
312
+ const source = `[${parts.map(unescapeGlobLiteral).sort().join("-")}]`;
313
+ let re;
314
+ try {
315
+ re = new RegExp(`^${source}$`);
316
+ }
317
+ catch {
318
+ return [];
319
+ }
320
+ const out = [];
321
+ for (let code = RANGE_CHAR_START; code <= RANGE_CHAR_END; code++) {
322
+ const ch = String.fromCharCode(code);
323
+ if (re.test(ch))
324
+ out.push(ch);
325
+ if (out.length > MAX_BRACE_EXPANSIONS)
326
+ return [];
327
+ }
328
+ return out;
329
+ }
330
+ function braceAlternatives(body) {
331
+ const alternatives = [];
332
+ let depth = 0;
333
+ let start = 0;
334
+ let hasComma = false;
335
+ for (let i = 0; i < body.length; i++) {
336
+ if (isEscaped(body, i))
337
+ continue;
338
+ const ch = body[i];
339
+ if (ch === "{") {
340
+ depth++;
341
+ continue;
342
+ }
343
+ if (ch === "}") {
344
+ depth = Math.max(0, depth - 1);
345
+ continue;
346
+ }
347
+ if (ch !== "," || depth !== 0)
348
+ continue;
349
+ alternatives.push(body.slice(start, i));
350
+ start = i + 1;
351
+ hasComma = true;
352
+ }
353
+ if (hasComma) {
354
+ alternatives.push(body.slice(start));
355
+ return alternatives;
356
+ }
357
+ return rangeAlternatives(body);
358
+ }
359
+ function expandableBrace(pattern) {
360
+ const opens = [];
361
+ let unsupported;
362
+ for (let i = 0; i < pattern.length; i++) {
363
+ if (isEscaped(pattern, i))
364
+ continue;
365
+ if (pattern[i] === "{") {
366
+ opens.push(i);
367
+ continue;
368
+ }
369
+ if (pattern[i] !== "}" || !opens.length)
370
+ continue;
371
+ const start = opens.pop();
372
+ const alternatives = braceAlternatives(pattern.slice(start + 1, i));
373
+ if (alternatives === undefined)
374
+ continue;
375
+ if (alternatives.length === 0) {
376
+ unsupported ??= { start, end: i, alternatives };
377
+ continue;
378
+ }
379
+ return { start, end: i, alternatives };
380
+ }
381
+ return unsupported;
382
+ }
383
+ function expandFilesPattern(pattern) {
384
+ const pending = [pattern];
385
+ const expanded = new Set();
386
+ let visited = 0;
387
+ while (pending.length) {
388
+ const candidate = pending.pop();
389
+ if (++visited > MAX_BRACE_EXPANSIONS)
390
+ return [];
391
+ const brace = expandableBrace(candidate);
392
+ if (!brace) {
393
+ expanded.add(unescapeGlobLiteral(candidate));
394
+ continue;
395
+ }
396
+ if (brace.alternatives.length === 0)
397
+ continue;
398
+ if (pending.length + brace.alternatives.length > MAX_BRACE_EXPANSIONS)
399
+ return [];
400
+ for (const alternative of brace.alternatives) {
401
+ pending.push(candidate.slice(0, brace.start) + alternative + candidate.slice(brace.end + 1));
402
+ }
403
+ }
404
+ const scoped = filesGlob(pattern);
405
+ return [...expanded].filter((candidate) => scoped(candidate));
406
+ }
265
407
  function topLevelSrcDir(file) {
266
408
  const parts = file.replace(/^\.\//, "").replace(/\/+$/, "").split("/");
267
409
  if (parts[0] !== "src" || parts.length < 2 || !parts[1])
@@ -293,10 +435,13 @@ export function newDirectoryLints(tasks, repoRoot) {
293
435
  const scoped = filesGlob(t.files.map((f) => f.replace(/^\.\//, "")));
294
436
  const newDirs = new Set();
295
437
  for (const f of t.files) {
296
- const dir = topLevelSrcDir(f);
297
- if (!dir || existing.has(dir))
298
- continue;
299
- newDirs.add(`src/${dir}/`);
438
+ const normalized = f.replace(/^\.\//, "");
439
+ for (const expanded of expandFilesPattern(normalized)) {
440
+ const dir = topLevelSrcDir(expanded);
441
+ if (!dir || existing.has(dir))
442
+ continue;
443
+ newDirs.add(`src/${dir}/`);
444
+ }
300
445
  }
301
446
  if (!newDirs.size)
302
447
  continue;
@@ -724,6 +724,7 @@ acceptance is required on every task (a nested list of observable outcomes).
724
724
  - command: <shell> (oracle: command — exit code)
725
725
  - test: <name> (oracle: test — named test)
726
726
  - judge: <rubric> (oracle: judge — LLM-judged, free text)
727
+ A judge criterion carries ONE claim; a semicolon-joined criterion warns — split its clauses.
727
728
  - <plain text> (compat: compiles as judge oracle, warns)
728
729
 
729
730
  HARD BOUNDS — these FAIL the compile, they do not warn:
@@ -839,6 +840,7 @@ acceptance is required on every task (a nested list of observable outcomes).
839
840
  ORDERING AND OWNERSHIP:
840
841
  - Every path has exactly ONE owning task. Two tasks writing one file must be ORDERED by deps, or the
841
842
  loser's work is silently dropped when the integration tip advances.
843
+ - A task changing what the daemon DOES must own every surface that TELLS the operator what the daemon does.
842
844
  - A file one task CREATES cannot be "context:" for another — only deps: carries it, and that extends to
843
845
  the task that PRODUCES a value, not just the file's existence.
844
846
  - Deleting or renaming a symbol is a cross-task contract. Sweep for consumers by symbol AND by what the
@@ -4,4 +4,5 @@ export declare const DRIVER_CHOICES: readonly ["auto", "herdr", "subprocess", "o
4
4
  export type DriverChoice = (typeof DRIVER_CHOICES)[number];
5
5
  /** Validate argv at the CLI boundary rather than casting an arbitrary string into a driver choice. */
6
6
  export declare function parseDriverOverride(override?: string): DriverChoice | undefined;
7
+ export declare function driverEvidence(cfg: TickmarkrConfig, driver: ExecutorDriver, override?: string): string;
7
8
  export declare function pickDriver(cfg: TickmarkrConfig, override?: string): ExecutorDriver;
@@ -2,6 +2,7 @@ import { HerdrDriver } from "./herdr.js";
2
2
  import { OrcaDriver } from "./orca.js";
3
3
  import { SubprocessDriver } from "./subprocess.js";
4
4
  export const DRIVER_CHOICES = ["auto", "herdr", "subprocess", "orca"];
5
+ const overrideByDriver = new WeakMap();
5
6
  /** Validate argv at the CLI boundary rather than casting an arbitrary string into a driver choice. */
6
7
  export function parseDriverOverride(override) {
7
8
  if (override === undefined)
@@ -11,18 +12,32 @@ export function parseDriverOverride(override) {
11
12
  return choice;
12
13
  throw new Error(`usage: --driver must be one of ${DRIVER_CHOICES.join(" | ")} (got ${override})`);
13
14
  }
15
+ export function driverEvidence(cfg, driver, override) {
16
+ const selectedOverride = override ?? overrideByDriver.get(driver);
17
+ const want = parseDriverOverride(selectedOverride) ?? cfg.driver;
18
+ if (selectedOverride !== undefined)
19
+ return `${driver.id} (--driver)`;
20
+ if (want !== "auto")
21
+ return `${driver.id} (config)`;
22
+ const herdrAvailable = process.env.HERDR_ENV === "1";
23
+ if (driver.id === (herdrAvailable ? "herdr" : "subprocess")) {
24
+ return `auto → ${driver.id} (${herdrAvailable ? "HERDR_ENV=1" : "HERDR_ENV unset"})`;
25
+ }
26
+ return `auto → ${driver.id} (runtime)`;
27
+ }
14
28
  export function pickDriver(cfg, override) {
15
- const want = parseDriverOverride(override) ?? cfg.driver;
29
+ const selectedOverride = parseDriverOverride(override);
30
+ const want = selectedOverride ?? cfg.driver;
16
31
  // VIS-09 item 2: plumb the per-tab cap into the HerdrDriver — the driver takes it as a constructor
17
32
  // param and never imports config (cfg is the only seam). Guaranteed present: DEFAULT_CONFIG seeds
18
33
  // workersPerTab:3 and deepMerge overlays on top, so a missing overlay key still resolves.
19
- if (want === "herdr")
20
- return new HerdrDriver("herdr", cfg.visibility.workersPerTab);
21
- if (want === "subprocess")
22
- return new SubprocessDriver();
23
- // Orca is an operator-selected execution surface. Its runtime failure stays on Orca; selection
24
- // must never substitute a hidden subprocess worker after this explicit choice.
25
- if (want === "orca")
26
- return new OrcaDriver();
27
- return HerdrDriver.available() ? new HerdrDriver("herdr", cfg.visibility.workersPerTab) : new SubprocessDriver();
34
+ const driver = want === "herdr" ? new HerdrDriver("herdr", cfg.visibility.workersPerTab)
35
+ : want === "subprocess" ? new SubprocessDriver()
36
+ // Orca is an operator-selected execution surface. Its runtime failure stays on Orca; selection
37
+ // must never substitute a hidden subprocess worker after this explicit choice.
38
+ : want === "orca" ? new OrcaDriver()
39
+ : HerdrDriver.available() ? new HerdrDriver("herdr", cfg.visibility.workersPerTab) : new SubprocessDriver();
40
+ if (selectedOverride !== undefined)
41
+ overrideByDriver.set(driver, selectedOverride);
42
+ return driver;
28
43
  }
@@ -3,7 +3,12 @@ import { type ExecutorDriver, type NotifyOpts, type Slot, type SlotOpts } from "
3
3
  /** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
4
4
  export declare const ORCA_RESPONSE_FAMILIES: readonly ["status", "create", "list", "read", "send", "wait", "show", "close"];
5
5
  export type OrcaFamily = (typeof ORCA_RESPONSE_FAMILIES)[number];
6
+ export declare const ORCA_FIXTURE_VERSION = "1.4.195";
7
+ export declare const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
6
8
  export declare const STALE_HANDLE_CODE = "terminal_handle_stale";
9
+ export declare const TERMINAL_GONE_CODE = "terminal_gone";
10
+ export declare const STALE_HANDLE_CODES: Set<string>;
11
+ export declare const WAIT_TIMEOUT_CODE = "timeout";
7
12
  export declare const NOT_WRITABLE_CODE = "terminal_not_writable";
8
13
  /** The ONLY terminal status that licenses reading a terminal's bytes or its agent state. */
9
14
  export declare const RUNNING_STATUS = "running";
@@ -38,6 +43,18 @@ export interface OrcaEnvelope {
38
43
  runtimeId: string;
39
44
  raw: string;
40
45
  }
46
+ export interface OrcaBinaryResolverOpts {
47
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
48
+ platform?: NodeJS.Platform;
49
+ resolve?: (bin: string, cwd: string) => {
50
+ resolved?: string;
51
+ };
52
+ }
53
+ /**
54
+ * One Orca CLI name decision for both the driver and doctor. Linux desktop systems can have
55
+ * GNOME's screen-reader `orca` on PATH, so outside an Orca terminal the app CLI is `orca-ide`.
56
+ */
57
+ export declare function resolveOrcaCliBinary(cwd?: string, opts?: OrcaBinaryResolverOpts): string | undefined;
41
58
  /**
42
59
  * The one JSON seam. Fails CLOSED on every degenerate response — empty, unparseable (a truncated
43
60
  * body lands here), non-object, no boolean `ok`, `ok:false`, `ok:true` with no result object, or a
@@ -76,6 +93,8 @@ export declare function mapAgentState(term: Record<string, unknown>, tuiIdle: bo
76
93
  export declare function joinWrapped(raw: string): string;
77
94
  export interface OrcaDriverOpts {
78
95
  bin?: string;
96
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
97
+ platform?: NodeJS.Platform;
79
98
  exec?: OrcaExec;
80
99
  time?: OrcaTimeSource;
81
100
  pageLines?: number;
@@ -8,7 +8,7 @@ import { formatOwnedName, panesToClose } from "./types.js";
8
8
  // worktrees, routing, gates, journal and merges; orca supplies visible terminals only. Everything
9
9
  // here is bound by the 1.4.186 conformance spike
10
10
  // (.planning/assessments/2026-08-21-orca-driver-conformance.md, CONFORMANCE-END) and the
11
- // recorded refusal transport (process rc 1 + ok:false on stdout):
11
+ // 1.4.195 drift capture (.planning/assessments/2026-09-02-orca-1.4.195-capture/):
12
12
  //
13
13
  // - reads arrive as `result.terminal.tail` line arrays with line-indexed cursors and a `status`
14
14
  // field on the same object (C2); a CLOSED terminal answers ok:true with its own dead record
@@ -32,7 +32,12 @@ import { formatOwnedName, panesToClose } from "./types.js";
32
32
  // an older run's leftover sits in a checkout this run never knew (T2).
33
33
  /** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
34
34
  export const ORCA_RESPONSE_FAMILIES = ["status", "create", "list", "read", "send", "wait", "show", "close"];
35
+ export const ORCA_FIXTURE_VERSION = "1.4.195";
36
+ export const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
35
37
  export const STALE_HANDLE_CODE = "terminal_handle_stale";
38
+ export const TERMINAL_GONE_CODE = "terminal_gone";
39
+ export const STALE_HANDLE_CODES = new Set([STALE_HANDLE_CODE, TERMINAL_GONE_CODE]);
40
+ export const WAIT_TIMEOUT_CODE = "timeout";
36
41
  export const NOT_WRITABLE_CODE = "terminal_not_writable";
37
42
  /** The ONLY terminal status that licenses reading a terminal's bytes or its agent state. */
38
43
  export const RUNNING_STATUS = "running";
@@ -79,6 +84,19 @@ export class OrcaUnavailableError extends OrcaError {
79
84
  function str(v) {
80
85
  return typeof v === "string" && v ? v : undefined;
81
86
  }
87
+ /**
88
+ * One Orca CLI name decision for both the driver and doctor. Linux desktop systems can have
89
+ * GNOME's screen-reader `orca` on PATH, so outside an Orca terminal the app CLI is `orca-ide`.
90
+ */
91
+ export function resolveOrcaCliBinary(cwd = process.cwd(), opts = {}) {
92
+ const env = opts.env ?? process.env;
93
+ const explicit = env[ORCA_CLI_COMMAND_ENV]?.trim();
94
+ if (explicit)
95
+ return explicit;
96
+ const platform = opts.platform ?? process.platform;
97
+ const selected = platform === "linux" && env.TERM_PROGRAM !== "Orca" ? "orca-ide" : "orca";
98
+ return opts.resolve ? opts.resolve(selected, cwd).resolved : selected;
99
+ }
82
100
  /**
83
101
  * The one JSON seam. Fails CLOSED on every degenerate response — empty, unparseable (a truncated
84
102
  * body lands here), non-object, no boolean `ok`, `ok:false`, `ok:true` with no result object, or a
@@ -235,7 +253,7 @@ export class OrcaDriver {
235
253
  pollMs;
236
254
  probeStalenessMs;
237
255
  constructor(opts = {}) {
238
- this.bin = opts.bin ?? "orca";
256
+ this.bin = opts.bin ?? resolveOrcaCliBinary(process.cwd(), { env: opts.env, platform: opts.platform }) ?? "orca";
239
257
  // Config values flow into a shell here: every argv element is quoted, always.
240
258
  this.exec = opts.exec ?? ((args, cwd, timeoutMs) => {
241
259
  return sh([this.bin, ...args].map(shq).join(" "), cwd, timeoutMs);
@@ -257,14 +275,13 @@ export class OrcaDriver {
257
275
  // diagnostic is never discarded before the one shared parser reports it.
258
276
  const raw = [r.stdout, r.stderr ? `STDERR: ${r.stderr}` : ""].filter(Boolean).join("\n");
259
277
  if (r.code !== 0) {
260
- // Recorded 1.4.186 refusal transport: the process exits rc 1 with the STRUCTURED ok:false
278
+ // Recorded refusal transport: the process exits rc 1 with the STRUCTURED ok:false
261
279
  // body on stdout. Parse it so the refusal CODE survives (terminal_handle_stale,
262
280
  // terminal_not_writable) — everything downstream that recovers on a code depends on this
263
- // branch. The one documented exception is an elapsed `terminal wait`: it exits 1 but carries
264
- // an ok:true, `wait.satisfied:false` receipt. It remains the shared parser's success path;
265
- // waitCondition() validates its identity, handle, condition and running status before it
266
- // becomes the normal `false` result. Any other ok:true body on a nonzero exit stays a
267
- // transport failure (a shell/runtime crash can leave stale stdout behind).
281
+ // branch. Elapsed `terminal wait` has two recorded transports: 1.4.186's ok:true,
282
+ // `wait.satisfied:false` receipt and 1.4.195's ok:false/code:timeout refusal. Each remains
283
+ // scoped to wait only; any other ok:true body on a nonzero exit stays a transport failure (a
284
+ // shell/runtime crash can leave stale stdout behind).
268
285
  try {
269
286
  const env = parseEnvelope(family, r.stdout, raw);
270
287
  const wait = env.result.wait;
@@ -279,6 +296,21 @@ export class OrcaDriver {
279
296
  }
280
297
  }
281
298
  catch (e) {
299
+ if (family === "wait"
300
+ && r.code === 1
301
+ && !r.timedOut
302
+ && e instanceof OrcaError
303
+ && e.code === WAIT_TIMEOUT_CODE
304
+ && e.runtimeId
305
+ && e.runtimeId !== "none") {
306
+ const handle = args[args.indexOf("--terminal") + 1];
307
+ const condition = args[args.indexOf("--for") + 1];
308
+ return {
309
+ result: { wait: { handle, condition, satisfied: false, status: RUNNING_STATUS } },
310
+ runtimeId: e.runtimeId,
311
+ raw,
312
+ };
313
+ }
282
314
  // The refusal code lives in stdout alone, but the raw bytes propagated to the caller must
283
315
  // still be the COMBINED stream — stderr can carry the diagnostic that explains the refusal.
284
316
  if (e instanceof OrcaError && !(e instanceof OrcaUnavailableError) && e.code !== undefined) {
@@ -392,6 +424,10 @@ export class OrcaDriver {
392
424
  st.handle = handle;
393
425
  // The handle is bound to the runtime identity that ANSWERED its create.
394
426
  st.runtimeId = env.runtimeId;
427
+ const surface = str(term.surface);
428
+ if (surface !== undefined && surface !== "visible") {
429
+ await this.notify(`tickmarkr orca terminal created on ${surface} surface`, { tier: "attention" });
430
+ }
395
431
  }
396
432
  // ---- handle identity and restart recovery ----------------------------------------------------
397
433
  /**
@@ -450,7 +486,7 @@ export class OrcaDriver {
450
486
  await relist(e.runtimeId, e.raw);
451
487
  continue;
452
488
  }
453
- if (e.code === STALE_HANDLE_CODE) {
489
+ if (STALE_HANDLE_CODES.has(e.code)) {
454
490
  await relist(e.runtimeId, e.raw);
455
491
  continue;
456
492
  }
@@ -45,6 +45,8 @@ export interface BaselineCommand {
45
45
  * verdict: no exit code, no fingerprints, nothing forgivable.
46
46
  */
47
47
  infra?: true;
48
+ /** Runner-level exhaustion lines that invalidated a completed capture. */
49
+ invalidatingLines?: string[];
48
50
  }
49
51
  export interface BaselineFileDuration {
50
52
  file: string;
@@ -85,7 +85,7 @@ const TURBO_FAIL_RE = /^\s*(?:[\w@./-]+:\s*)*ELIFECYCLE\s+Command failed\b|^\s*F
85
85
  // at least TWO colon-joined segments (`<pkg>:<task>:`, tasks may nest — `pkg:test:unit:`), every
86
86
  // segment after the first starting with a letter — so `Error: boom` (one segment), `src/x.ts:12:`
87
87
  // (digit segment) and `12:34 error` (eslint stylish) are never stripped.
88
- const TURBO_PREFIX_RE = /^\s*[\w@./-]+(?::[A-Za-z_][\w.-]*)+:\s+/;
88
+ const TURBO_PREFIX_RE = /^\s*[\w@./-]+(?::[A-Za-z_][\w.-]*)+:(?:\s+|$)/;
89
89
  const stripTurboPrefix = (l) => {
90
90
  const m = TURBO_PREFIX_RE.exec(l);
91
91
  return m ? l.slice(m[0].length) : undefined;
@@ -161,11 +161,36 @@ export function classifyFailureOutput(output) {
161
161
  * no failure in that incomplete environment can safely become pre-existing forgiveness. Keep this
162
162
  * separate from `classifyFailureOutput` so changing capture policy cannot move gate verdicts.
163
163
  */
164
- const captureHasInvalidatingInfra = (output) => output
165
- .split("\n")
166
- .map((l) => l.replace(ANSI_RE, ""))
167
- .filter((l) => !PASS_LINE_RE.test(l))
168
- .some((l) => CAPTURE_EXHAUSTION_RE.test(l));
164
+ const VITEST_ECHO_BLOCK_RE = /^\s*std(?:out|err)\s+\|\s+\S+\.(?:test|spec)\.[cm]?[jt]sx?\s+>\s+\S/;
165
+ /** Keep runner output while omitting Vitest's echoed test-owned stdout/stderr diagnostic blocks. */
166
+ const withoutVitestEchoBlocks = (output) => {
167
+ const outside = [];
168
+ let inVitestEchoBlock = false;
169
+ for (const line of output.split("\n")) {
170
+ const clean = line.replace(ANSI_RE, "");
171
+ const runnerLine = stripTurboPrefix(clean) ?? clean;
172
+ if (inVitestEchoBlock) {
173
+ if (runnerLine.trim() === "")
174
+ inVitestEchoBlock = false;
175
+ continue;
176
+ }
177
+ if (VITEST_ECHO_BLOCK_RE.test(runnerLine)) {
178
+ inVitestEchoBlock = true;
179
+ continue;
180
+ }
181
+ outside.push(line);
182
+ }
183
+ return outside;
184
+ };
185
+ const captureInvalidatingLines = (output) => {
186
+ const invalidating = [];
187
+ for (const line of withoutVitestEchoBlocks(output)) {
188
+ const clean = line.replace(ANSI_RE, "");
189
+ if (!PASS_LINE_RE.test(clean) && CAPTURE_EXHAUSTION_RE.test(clean))
190
+ invalidating.push(line);
191
+ }
192
+ return invalidating;
193
+ };
169
194
  const normalizeLine = (l) => l.replace(/\d+/g, "#").replace(/\s+/g, " ").trim();
170
195
  // Vitest's default reporter names file durations as
171
196
  // `✓ |project| tests/example.test.ts (12 tests) 1.23s` (❯ for a red file). The runner may be
@@ -203,8 +228,7 @@ export const UNRECOGNIZED_FAILURE = "<unrecognized failure output>";
203
228
  // renders "zone +3 · run attempt 2 · 1 failed" (what T9 is chartered to draw) contributes nothing, with
204
229
  // or without box glyphs, so it cannot manufacture a fresh-fingerprint rejection on any future attempt.
205
230
  export function fingerprint(output) {
206
- const lines = output
207
- .split("\n")
231
+ const lines = withoutVitestEchoBlocks(output)
208
232
  .map((l) => l.replace(ANSI_RE, ""))
209
233
  .filter((l) => !PASS_LINE_RE.test(l));
210
234
  // GATE-FIX-4 DEFECT 4: every line is read twice — as printed, and with a turbo `<pkg>:<task>:`
@@ -403,7 +427,7 @@ export function ceilingKillResult(gate, r, ceilingMs) {
403
427
  * shell asks it to finish faster than the thing it is measuring.
404
428
  */
405
429
  export const CAPTURE_CEILING_MS = 1_800_000;
406
- const invalidCaptureEntry = (durationMs) => ({
430
+ const invalidCaptureEntry = (durationMs, invalidatingLines = []) => ({
407
431
  infra: true,
408
432
  fingerprints: [],
409
433
  durationMs,
@@ -411,6 +435,7 @@ const invalidCaptureEntry = (durationMs) => ({
411
435
  impliedParallelism: null,
412
436
  longestFile: null,
413
437
  ceilingMs: effectiveCeilingMs({ durationMs }),
438
+ ...(invalidatingLines.length ? { invalidatingLines } : {}),
414
439
  });
415
440
  export async function captureBaseline(cwd, commands) {
416
441
  const base = { commands: {} };
@@ -441,17 +466,20 @@ export async function captureBaseline(cwd, commands) {
441
466
  base.commands[name] = invalidCaptureEntry(durationMs);
442
467
  continue;
443
468
  }
444
- const raw = (r.stdout + "\n" + r.stderr).split(cwd).join("");
469
+ const combinedOutput = r.stdout + "\n" + r.stderr;
470
+ const raw = combinedOutput.split(cwd).join("");
445
471
  // Run 2137: the child exited and printed ordinary FAIL/AssertionError lines, so the gate-side
446
472
  // discriminator correctly called the mixed output a regression. But the same output also said
447
473
  // `spawn EAGAIN`: the machine had run out of processes while the pristine-tree measurement was
448
474
  // being taken. A capture cannot know which red lines predated that shortage and which it caused,
449
475
  // so none may become a fingerprint every later task gets to forgive.
450
- if (captureHasInvalidatingInfra(raw)) {
476
+ const invalidatingLines = captureInvalidatingLines(combinedOutput);
477
+ if (invalidatingLines.length) {
451
478
  console.error(`tickmarkr: baseline capture for "${name}" completed with process/resource-exhaustion evidence — `
452
479
  + `it recorded NO exit-code verdict and NO fingerprints, so nothing is forgiven for this command; `
453
- + `the measurement cannot distinguish a pre-existing failure from one caused by exhaustion.`);
454
- base.commands[name] = invalidCaptureEntry(durationMs);
480
+ + `the measurement cannot distinguish a pre-existing failure from one caused by exhaustion. `
481
+ + `First invalidating line: ${invalidatingLines[0]}`);
482
+ base.commands[name] = invalidCaptureEntry(durationMs, invalidatingLines);
455
483
  continue;
456
484
  }
457
485
  base.commands[name] = {
@@ -530,7 +558,14 @@ export async function compareToBaseline(cwd, commands, baseline, enabled) {
530
558
  if (!cmd) {
531
559
  // nothing detected for this gate in the target repo — journal an explicit skip instead of
532
560
  // vanishing silently (a lint gate with no lint script rendered as forever-open in status)
533
- results.push({ gate: name, pass: true, details: `no ${name} command detected — skipped`, meta: { skipped: true } });
561
+ const reason = `no ${name} command detected`;
562
+ const outcome = { kind: "skipped", reason };
563
+ results.push({
564
+ gate: name,
565
+ pass: true,
566
+ details: `${reason} — skipped`,
567
+ meta: { skipped: true, outcome },
568
+ });
534
569
  continue;
535
570
  }
536
571
  const entry = baseline.commands[name];
@@ -1,4 +1,4 @@
1
- import type { WorkerAdapter } from "../adapters/types.js";
1
+ import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import { type ExecutorDriver, type Slot } from "../drivers/types.js";
3
3
  export declare const GATE_PANE_SEP = " \u00B7 ";
4
4
  export declare const COMPLETION_FAKING_CHECKLIST = "## Completion-faking checklist\nHunt for these concrete completion-faking shortcuts before ruling on any criterion:\n- hardcoded-result: output or fixture hardcoded to satisfy the stated criterion instead of real logic\n- test-weakening: tests skipped, deleted, or assertions loosened until failing behavior looks green\n- vacuous-assertion: a test that cannot fail (asserts a constant, asserts its own setup, no assertion)\n- fixture-overfit: implementation narrowed to the exact test inputs rather than the described behavior\n- echo-not-implement: criterion text echoed in names, comments, or strings without the behavior itself\n- stub-left-behind: TODO, throw, or no-op stub where the real implementation should be\n- error-swallowing: catch or fallback that hides failures instead of handling them\n- self-mocking: the code under test mocked or faked so the test exercises the mock\n- check-bypass: lint, type, or CI checks disabled, relaxed, or excluded to get green\n- rename-as-work: code moved or renamed and presented as the requested change\n- scope-padding: unrelated edits padding the diff while the criterion's behavior is untouched\nWhen a criterion fails, the verdict MUST name which shortcut above it matches, or state that none does.";
package/dist/gates/llm.js CHANGED
@@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { matchesTrustDialog } from "../adapters/types.js";
6
7
  import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
7
8
  import { bannerShell, paneDispatchCommand } from "../brand.js";
8
9
  import { sh } from "../run/git.js";
@@ -161,6 +162,16 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
161
162
  slot = await via.driver.slot(cwd, rolePaneNameFromPrompt(prompt, via.name), via.label ? { label: via.label } : undefined);
162
163
  via.onSlot?.(slot);
163
164
  await via.driver.run(slot, paneDispatchCommand(scriptPath));
165
+ if (via.driver.sendKey) {
166
+ try {
167
+ if (matchesTrustDialog(await via.driver.read(slot, 400), adapter.trustDialog)) {
168
+ await via.driver.sendKey(slot, adapter.trustDialog.key);
169
+ }
170
+ }
171
+ catch {
172
+ /* a failed pre-wait read must not replace the verdict wait */
173
+ }
174
+ }
164
175
  // nonce-suffixed exit only: a displayed bare "TICKMARKR_EXIT:" or another call's marker must not
165
176
  // false-complete — same guard the worker path uses (daemon.ts:330-331).
166
177
  const exitPattern = `TICKMARKR_EXIT_${nonce}:\\d`;
@@ -369,37 +380,41 @@ export function extractVerdictJson(raw, nonce) {
369
380
  /* fall through */
370
381
  }
371
382
  }
372
- let pos = raw.length - 1;
373
- while (pos >= 0) {
374
- const end = raw.lastIndexOf("}", pos);
375
- if (end === -1)
376
- return null;
377
- let depth = 1;
378
- let stepped = false;
379
- for (let i = end - 1; i >= 0; i--) {
380
- if (raw[i] === "}")
383
+ for (let start = raw.lastIndexOf("{"); start >= 0;) {
384
+ const nextStart = start === 0 ? -1 : raw.lastIndexOf("{", start - 1);
385
+ let depth = 0;
386
+ let quoted = false;
387
+ let escaped = false;
388
+ for (let i = start; i < raw.length; i++) {
389
+ const char = raw[i];
390
+ if (quoted) {
391
+ if (escaped)
392
+ escaped = false;
393
+ else if (char === "\\")
394
+ escaped = true;
395
+ else if (char === '"')
396
+ quoted = false;
397
+ continue;
398
+ }
399
+ if (char === '"')
400
+ quoted = true;
401
+ else if (char === "{")
381
402
  depth++;
382
- else if (raw[i] === "{") {
383
- depth--;
384
- if (depth === 0) {
385
- stepped = true;
386
- try {
387
- const v = JSON.parse(raw.slice(i, end + 1));
388
- if (v && typeof v === "object" && v.nonce === nonce) {
389
- const { nonce: _n, ...rest } = v;
390
- return rest;
391
- }
392
- }
393
- catch {
394
- /* keep scanning */
403
+ else if (char === "}" && --depth === 0) {
404
+ try {
405
+ const v = JSON.parse(raw.slice(start, i + 1));
406
+ if (v && typeof v === "object" && v.nonce === nonce) {
407
+ const { nonce: _n, ...rest } = v;
408
+ return rest;
395
409
  }
396
- pos = i - 1;
397
- break;
398
410
  }
411
+ catch {
412
+ /* keep scanning */
413
+ }
414
+ break;
399
415
  }
400
416
  }
401
- if (!stepped)
402
- return null;
417
+ start = nextStart;
403
418
  }
404
419
  return null;
405
420
  }
@@ -348,6 +348,7 @@ The top-level comments array is optional. Use it only for actionable line-anchor
348
348
  // OBS-196: name the cause and persist the raw bytes — a ruled-on "unparseable" without its
349
349
  // evidence cannot be audited, and a cutoff must never be indistinguishable from a parse defect.
350
350
  const cause = classifyVerdictCause(raw, nonce, "approve");
351
+ const bytes = Buffer.byteLength(raw, "utf8");
351
352
  let saved;
352
353
  if (artifactDir) {
353
354
  try {
@@ -372,6 +373,7 @@ The top-level comments array is optional. Use it only for actionable line-anchor
372
373
  reviewer: channelKey(reviewer),
373
374
  unparseable: true,
374
375
  cause,
376
+ ...(cause === "empty-output" ? { bytes } : {}),
375
377
  ...(concludedOnInactivity ? { classification: "infra", infra: true } : {}),
376
378
  },
377
379
  };
@@ -31,6 +31,12 @@ export type GateEvent = {
31
31
  phase: "end";
32
32
  gate: GateName;
33
33
  result: GateResult;
34
+ } | {
35
+ phase: "note";
36
+ gate: GateName;
37
+ name: string;
38
+ payload: Record<string, unknown>;
39
+ result: GateResult;
34
40
  };
35
41
  export interface GateContext {
36
42
  worktree: string;