tickmarkr 2.8.2 → 2.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { type ClassifiedWorkerResult } from "./prompt.js";
2
2
  import { type WorkerAdapter } from "./types.js";
3
3
  export declare const QWEN_VERSION_IDENTITY: RegExp;
4
- export declare const QWEN_HARNESS_BANNER_ROWS: readonly ["⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.", "Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All tool calls (shell, write, edit) auto-execute at this process's privilege level. Enable a sandbox via --sandbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this notice."];
4
+ export declare const QWEN_HARNESS_BANNER_ROWS: readonly ["⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.", "Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All tool calls (shell, write, edit) auto-execute at this process's privilege level. Enable a sandbox via --sandbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this notice.", "Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All tool calls (shell, write, edit) auto-execute at this process's privilege level. Configure tools.executionSandbox on Linux or a supported legacy sandbox via --sandbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this notice."];
5
5
  export declare function parseQwenResult(raw: string, nonce: string): ClassifiedWorkerResult;
6
6
  export declare const qwen: WorkerAdapter;
@@ -3,12 +3,14 @@ import { parseWorkerResult } from "./prompt.js";
3
3
  import { channelsFromConfig, shq, } from "./types.js";
4
4
  export const QWEN_VERSION_IDENTITY = /^\d+\.\d+\.\d+/;
5
5
  const QWEN_SKIP_UPDATE = "QWEN_CODE_SKIP_UPDATE_CHECK_ONCE=true";
6
- // Verbatim stderr from tests/fixtures/qwen/safe-mode.stderr (qwen 0.21.15). These bytes are launch
7
- // harness, not evidence that a reviewer took a turn. Keep the declaration closed and row-shaped so
8
- // llm.ts can sweep partial terminal paints without a broad SAFE MODE/warning regex.
6
+ // Verbatim stderr rows, in render order: the SAFE MODE row both recorded versions print, then the yolo
7
+ // row of tests/fixtures/qwen/safe-mode.stderr (qwen 0.21.15) and of safe-mode-0.25.1.stderr (qwen 0.25.1).
8
+ // These bytes are launch harness, not evidence that a reviewer took a turn. Keep the declaration closed
9
+ // and row-shaped so llm.ts can sweep partial terminal paints without a broad SAFE MODE/warning regex.
9
10
  export const QWEN_HARNESS_BANNER_ROWS = [
10
11
  "⚠ SAFE MODE — all customizations disabled (hooks, extensions, skills, MCP servers, QWEN.md). Restart without --safe-mode to resume normal operation.",
11
12
  "Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All tool calls (shell, write, edit) auto-execute at this process's privilege level. Enable a sandbox via --sandbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this notice.",
13
+ "Warning: running headless with --yolo / approval-mode=yolo and no sandbox. All tool calls (shell, write, edit) auto-execute at this process's privilege level. Configure tools.executionSandbox on Linux or a supported legacy sandbox via --sandbox / QWEN_SANDBOX, or set QWEN_CODE_SUPPRESS_YOLO_WARNING=1 to silence this notice.",
12
14
  ];
13
15
  function decodeQwenEvents(events) {
14
16
  const text = [];
@@ -1,4 +1,5 @@
1
1
  import { graphDefinitionHash, loadGraph, saveGraph, taskDefinitionFingerprint } from "../../graph/graph.js";
2
+ import { filesGlob } from "../../graph/files-glob.js";
2
3
  import { execFileSync } from "node:child_process";
3
4
  import { userInfo } from "node:os";
4
5
  import { allAdapters } from "../../adapters/registry.js";
@@ -242,7 +243,7 @@ export async function approve(argv, cwd = process.cwd()) {
242
243
  if (amendedFiles.length === task.files.length)
243
244
  throw new Error("--files must extend the parked files[] revision");
244
245
  const amended = { ...graph, tasks: graph.tasks.map((t) => t.id === taskId ? { ...t, files: amendedFiles } : t) };
245
- const conflicts = separabilityErrors(amended.tasks);
246
+ const conflicts = scopeGrantConflicts(cwd, journal, runId, taskId, amended.tasks, events);
246
247
  if (conflicts.length)
247
248
  throw new Error(`refusing scope-request approval for ${taskId}: ${conflicts.join("\n")}`);
248
249
  // C4: a live grant may not take a task past the bounds that fail every compile. The exception
@@ -394,6 +395,110 @@ function integrationBase(events, byName) {
394
395
  const branch = String(events.find((e) => e.event === "run-start").data.branch);
395
396
  return "anchor" in anchor ? { branch, base: anchor.anchor } : { branch, base: "unknown", unreadable: anchor.unreadable };
396
397
  }
398
+ /**
399
+ * Queue 169 (D-1982/D-1983/D-1986): the in-run OBS-212 check a scope-request grant runs. Only the
400
+ * pairs this amendment changes are judged — the requester against each unordered sibling; a pair
401
+ * another amendment created was judged by that grant and is never re-judged here. A sibling is
402
+ * exempt only when this run already merged it into the requester's branch: its replayed status is
403
+ * done, its NEWEST journaled merge row names a full lowercase object id, and that commit is an
404
+ * ancestor of the requester's task branch (the run's integration branch plus --taskId, read exactly
405
+ * as the failed-task recheck reads it — a marked run's branch comes from its run-start record, never
406
+ * the current config). Every other overlapping pair keeps the compile-time refusal, an unreadable
407
+ * git answer included. The surviving errors are separabilityErrors' own rows, so the refusal text
408
+ * and its pair order are unchanged.
409
+ */
410
+ function scopeGrantConflicts(cwd, journal, runId, requesterId, tasks, events) {
411
+ const errors = separabilityErrors(tasks);
412
+ if (!errors.length)
413
+ return errors;
414
+ const pairs = separabilityPairs(tasks);
415
+ if (pairs.length !== errors.length)
416
+ return errors; // a misaligned local fold refuses everything
417
+ const statuses = journal.replayStatuses();
418
+ let taskRef;
419
+ const requesterRef = () => (taskRef ??= `${integrationBase(events, () => integrationBranch(loadConfig(cwd), runId)).branch}--${requesterId}`);
420
+ const kept = [];
421
+ for (let i = 0; i < errors.length; i++) {
422
+ const { a, b } = pairs[i];
423
+ const sibling = a === requesterId ? b : b === requesterId ? a : undefined;
424
+ if (sibling === undefined)
425
+ continue;
426
+ if (mergedSiblingExempt(cwd, events, statuses.get(sibling), sibling, requesterRef))
427
+ continue;
428
+ kept.push(errors[i]);
429
+ }
430
+ return kept;
431
+ }
432
+ /** A full lowercase git object id: 40 (SHA-1) or 64 (SHA-256) hex, nothing else. */
433
+ const FULL_OBJECT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
434
+ /**
435
+ * The one exemption: the sibling is done in THIS run's replay and its newest merge row's commit is a
436
+ * full object id that is an ancestor of the requester's task branch. Any unreadable git answer — a
437
+ * missing requester branch, a commit the repository lacks, a failed ancestry read — is no exemption.
438
+ */
439
+ function mergedSiblingExempt(cwd, events, status, siblingId, requesterRef) {
440
+ if (status !== "done")
441
+ return false;
442
+ const merge = [...events].reverse().find((e) => e.event === "merge" && e.taskId === siblingId);
443
+ const commit = typeof merge?.data.commit === "string" ? merge.data.commit : undefined;
444
+ if (commit === undefined || !FULL_OBJECT_ID.test(commit))
445
+ return false;
446
+ const ref = requesterRef();
447
+ try {
448
+ execFileSync("git", ["rev-parse", "--verify", ref], { cwd, stdio: "ignore" });
449
+ execFileSync("git", ["merge-base", "--is-ancestor", commit, ref], { cwd, stdio: "ignore" });
450
+ }
451
+ catch {
452
+ return false;
453
+ }
454
+ return true;
455
+ }
456
+ /** The conflicting unordered pairs of separabilityErrors, in its exact emission order. */
457
+ function separabilityPairs(tasks) {
458
+ const byId = new Map(tasks.map((t) => [t.id, t.deps ?? []]));
459
+ const pairs = [];
460
+ for (let i = 0; i < tasks.length; i++) {
461
+ for (let j = i + 1; j < tasks.length; j++) {
462
+ const a = tasks[i], b = tasks[j];
463
+ if (pairReachable(a.id, byId).has(b.id) || pairReachable(b.id, byId).has(a.id))
464
+ continue; // ordered
465
+ if (pairOverlap(a.files ?? [], b.files ?? []).length > 0)
466
+ pairs.push({ a: a.id, b: b.id });
467
+ }
468
+ }
469
+ return pairs;
470
+ }
471
+ /** Tasks reachable from `id` through deps (transitive) — separabilityErrors' own fold. */
472
+ function pairReachable(id, byId) {
473
+ const seen = new Set();
474
+ const stack = [...(byId.get(id) ?? [])];
475
+ while (stack.length) {
476
+ const next = stack.pop();
477
+ if (seen.has(next))
478
+ continue;
479
+ seen.add(next);
480
+ stack.push(...(byId.get(next) ?? []));
481
+ }
482
+ return seen;
483
+ }
484
+ /** separabilityErrors' conservative overlap: identical patterns, or a glob matching the other's literal path. */
485
+ function pairOverlap(a, b) {
486
+ const hits = new Set();
487
+ const isGlob = (p) => /[*?[\]{}]/.test(p);
488
+ for (const pa of a) {
489
+ for (const pb of b) {
490
+ if (pa === pb) {
491
+ hits.add(pa);
492
+ continue;
493
+ }
494
+ if (isGlob(pa) && !isGlob(pb) && filesGlob(pa)(pb))
495
+ hits.add(`${pb} ⊂ ${pa}`);
496
+ else if (isGlob(pb) && !isGlob(pa) && filesGlob(pb)(pa))
497
+ hits.add(`${pa} ⊂ ${pb}`);
498
+ }
499
+ }
500
+ return [...hits].sort();
501
+ }
397
502
  /** The row a decision on this task answers right now: its newest failure when failed, else its newest park. */
398
503
  function openDecision(journal, taskId) {
399
504
  const { events, sourceIndexes } = readJournalEvents(journal);
@@ -1,7 +1,7 @@
1
1
  import { type WorkerAdapter } from "../adapters/types.js";
2
2
  import type { Effort } from "../graph/schema.js";
3
3
  import { type ExecutorDriver, type Slot } from "../drivers/types.js";
4
- import type { SeatLaunchCause } from "../drivers/orca.js";
4
+ import { type SeatLaunchCause } from "../drivers/orca.js";
5
5
  export declare const GATE_PANE_SEP = " \u00B7 ";
6
6
  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.";
7
7
  /** Fable F3: per-call nonce echoed in verdict JSON and gate exit markers. */
package/dist/gates/llm.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { stripVTControlCharacters } from "node:util";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { constants } from "node:buffer";
3
4
  import { randomBytes } from "node:crypto";
4
5
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join } from "node:path";
7
- import { matchesTrustDialog } from "../adapters/types.js";
8
+ import { matchesTrustDialog, shq } from "../adapters/types.js";
8
9
  import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
10
+ import { CHECKOUT_MARK, checkoutProofLine } from "../drivers/orca.js";
9
11
  import { bannerShell, paneDispatchCommand, PLAIN_BANNER } from "../brand.js";
10
12
  import { sh } from "../run/git.js";
11
13
  import { harvestCpuFlatWindowMs, normalizeStallSnapshot, WorkerTreeCpuAccountant, } from "../run/stall.js";
@@ -204,6 +206,64 @@ const START_MARKER = "TICKMARKR_START_";
204
206
  const START_ROW = /^TICKMARKR_START_[\w-]+$/;
205
207
  const IDENTITY_LINE = /^(?:review\s*·|tickmarkr(?::|$))/;
206
208
  const IDENTITY_OPENERS = ["review ·", "tickmarkr"];
209
+ // Row 135(a): the orca driver's launch preamble. create returns only once it read the complete checkout
210
+ // frame, so every gate read holds the frame row whole — exactly checkoutProofLine's form (declared length
211
+ // equal to half its lowercase hex, terminated) — or, as the last row, a strict prefix of that form.
212
+ // Above it, as the first non-blank row, the shell may echo the typed launch line, which names that
213
+ // frame's own checkout as `cd <shq checkout>`. Anything else shaped like them is the seat's. Frame rows
214
+ // are read untrimmed: a trailing space or tab can never complete a frame or extend its prefix.
215
+ function frameCheckout(row) {
216
+ const hex = /^TICKMARKR_CHECKOUT \d+:(.*);$/.exec(row)?.[1];
217
+ if (hex === undefined)
218
+ return undefined;
219
+ const checkout = Buffer.from(hex, "hex").toString("utf8");
220
+ return checkoutProofLine(checkout) === row ? checkout : undefined;
221
+ }
222
+ function partialFrameRow(row) {
223
+ if (row.length > 0 && `${CHECKOUT_MARK} `.startsWith(row))
224
+ return true;
225
+ const m = /^TICKMARKR_CHECKOUT (0|[1-9]\d*)(?::([0-9a-f]*))?$/.exec(row);
226
+ if (m === null || !printableFrameLength(m[1]))
227
+ return false;
228
+ return m[2] === undefined || frameHexCompletable(m[2], Number(m[1]));
229
+ }
230
+ // checkoutProofLine prints `TICKMARKR_CHECKOUT <n>:`, 2n hex digits and `;` as one string, so the length it
231
+ // declares is a safe integer whose row fits the runtime's longest string. Any other length, unsafe or
232
+ // non-finite included, is never its output, nor a prefix of one.
233
+ function printableFrameLength(digits) {
234
+ const n = Number(digits);
235
+ return Number.isSafeInteger(n) && CHECKOUT_MARK.length + digits.length + 2 * n + 3 <= constants.MAX_STRING_LENGTH;
236
+ }
237
+ // A hex prefix checkoutProofLine could still extend to `bytes` bytes: valid UTF-8 so far, with its open
238
+ // sequence closable inside the declared length. A trailing half byte is tried at each of its 16 values.
239
+ function frameHexCompletable(hex, bytes) {
240
+ if (hex.length > bytes * 2)
241
+ return false;
242
+ const whole = Buffer.from(hex.slice(0, hex.length - (hex.length % 2)), "hex");
243
+ const candidates = hex.length % 2 === 0 ? [whole]
244
+ : Array.from({ length: 16 }, (_, lo) => Buffer.concat([whole, Buffer.of(parseInt(hex.at(-1), 16) * 16 + lo)]));
245
+ return candidates.some((c) => {
246
+ const owed = utf8Owed(c);
247
+ return owed !== undefined && owed <= bytes - c.length;
248
+ });
249
+ }
250
+ // Continuation bytes the last sequence still owes, or undefined when no continuation makes it UTF-8.
251
+ function utf8Owed(bytes) {
252
+ try {
253
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true });
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ let lead = bytes.length;
259
+ while (lead > 0 && bytes.length - lead < 3 && (bytes[lead - 1] & 0xc0) === 0x80)
260
+ lead--;
261
+ if (lead === 0)
262
+ return 0;
263
+ const b = bytes[lead - 1];
264
+ const size = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
265
+ return Math.max(0, size - (bytes.length - lead + 1));
266
+ }
207
267
  // Stages of the preamble walk. Each complete harness row is accepted only at or after its stage.
208
268
  const ECHO = 0, START = 1, BANNER = 2, IDENTITY = 3, SEAT = 4;
209
269
  // The char offset where the seat's own text begins, or -1 when the capture ends inside the preamble.
@@ -215,6 +275,7 @@ function seatStart(output, adapterBannerRows) {
215
275
  let bannerAt; // next banner row expected once the banner has begun
216
276
  let adapterBannerAt;
217
277
  let identitySeen = false;
278
+ let frameSeen = false;
218
279
  let offset = 0;
219
280
  for (let i = 0; i < rows.length; i++) {
220
281
  const row = rows[i].replace(/[ \t]+$/, "");
@@ -222,8 +283,15 @@ function seatStart(output, adapterBannerRows) {
222
283
  const last = i === rows.length - 1;
223
284
  // Complete harness rows: equality against the shape the preamble allows at this stage.
224
285
  let accepted = false;
286
+ const below = last ? undefined : frameCheckout(rows[i + 1]);
225
287
  if (!identitySeen && stage < SEAT && t.length === 0)
226
288
  accepted = true; // blank rows between preamble rows
289
+ else if (stage === ECHO && !frameSeen && frameCheckout(rows[i]) !== undefined) {
290
+ frameSeen = true;
291
+ accepted = true;
292
+ }
293
+ else if (below !== undefined && row.includes(`cd ${shq(below)}`) && rows.slice(0, i).every((r) => r.trim() === ""))
294
+ accepted = true; // orca launch echo
227
295
  else if (stage <= ECHO && completeEchoRow(row))
228
296
  accepted = true;
229
297
  else if (stage <= START && START_ROW.test(row)) {
@@ -239,13 +307,10 @@ function seatStart(output, adapterBannerRows) {
239
307
  bannerAt++;
240
308
  accepted = true;
241
309
  }
242
- else if (stage <= IDENTITY && adapterBannerAt === undefined && adapterBannerRows.includes(row)) {
310
+ else if (stage <= IDENTITY && adapterBannerRows.indexOf(row, adapterBannerAt ?? 0) >= 0) {
311
+ // A closed list in render order: each row at most once, in list order, skipping allowed.
243
312
  stage = BANNER;
244
- adapterBannerAt = adapterBannerRows.indexOf(row) + 1;
245
- accepted = true;
246
- }
247
- else if (stage <= IDENTITY && adapterBannerAt !== undefined && row === adapterBannerRows[adapterBannerAt]) {
248
- adapterBannerAt++;
313
+ adapterBannerAt = adapterBannerRows.indexOf(row, adapterBannerAt ?? 0) + 1;
249
314
  accepted = true;
250
315
  }
251
316
  else if (!identitySeen && stage <= IDENTITY && IDENTITY_LINE.test(t)) {
@@ -262,21 +327,20 @@ function seatStart(output, adapterBannerRows) {
262
327
  // The last row may be a partial paint: a prefix of the next harness row the preamble allows.
263
328
  if (stage <= ECHO && partialEchoRow(row))
264
329
  return -1;
330
+ if (stage === ECHO && !frameSeen && partialFrameRow(rows[i]))
331
+ return -1;
265
332
  if (stage <= START && (START_MARKER.startsWith(row) || /^TICKMARKR_START_[\w-]*$/.test(row)))
266
333
  return -1;
267
334
  if (stage <= BANNER && (bannerAt === undefined
268
335
  ? BANNER_ROWS.some((b) => b.startsWith(row))
269
336
  : BANNER_ROWS[bannerAt]?.startsWith(row) === true))
270
337
  return -1;
271
- if (stage <= IDENTITY && (adapterBannerAt === undefined
272
- ? adapterBannerRows.some((b) => b.startsWith(row))
273
- : adapterBannerRows[adapterBannerAt]?.startsWith(row) === true))
338
+ if (stage <= IDENTITY && adapterBannerRows.slice(adapterBannerAt ?? 0).some((b) => b.startsWith(row)))
274
339
  return -1;
275
340
  // The identity row is painted right after the banner, so its prefix is a partial paint only there;
276
341
  // with no banner in the capture, "review" or "tick" alone is the seat's own first row.
277
342
  if (!identitySeen && stage <= IDENTITY
278
- && (bannerAt === BANNER_ROWS.length
279
- || (adapterBannerRows.length > 0 && adapterBannerAt === adapterBannerRows.length))
343
+ && (bannerAt === BANNER_ROWS.length || adapterBannerAt !== undefined)
280
344
  && IDENTITY_OPENERS.some((o) => o.startsWith(t)))
281
345
  return -1;
282
346
  return offset;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.8.2",
3
+ "version": "2.8.3",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",