tickmarkr 2.6.3 → 2.6.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.
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { execFileSync } from "node:child_process";
2
3
  import { createHash } from "node:crypto";
3
4
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -6,9 +7,10 @@ import { z } from "zod";
6
7
  import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
7
8
  import { graphDefinitionHash, stateDirName, taskContentDigest, taskDefinitionFingerprint, tickmarkrDir } from "../graph/graph.js";
8
9
  import { GATE_NAMES, TIERS } from "../graph/schema.js";
9
- import { channelRouteIdentity } from "../route/preference.js";
10
+ import { channelRouteIdentity, modelProvider } from "../route/preference.js";
10
11
  import { buildProfile, classify } from "../route/profile.js";
11
12
  import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
13
+ import { PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER } from "./git.js";
12
14
  import { normalizeGateOutcome } from "./outcome.js";
13
15
  import { redactSecrets } from "./redact.js";
14
16
  export function phaseForGate(gate) {
@@ -1936,7 +1938,11 @@ export class Journal {
1936
1938
  const reducedData = event === "run-end"
1937
1939
  ? (() => {
1938
1940
  const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
1939
- return preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
1941
+ const reduced = preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
1942
+ // C1: the terminal record carries the owed-check fold as of this append, revalidated from
1943
+ // each discharge's artifact — a reopened journal re-reduces, it never copies a prior total.
1944
+ // Canonical (validated) rows are strict and keep their schema; the live producer is the tuple.
1945
+ return decisionRow ? reduced : { ...reduced, owedChecks: foldOwedChecks(priorEvents, join(this.dir, "..", "..", "..")) };
1940
1946
  })()
1941
1947
  : event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
1942
1948
  ? {
@@ -2383,3 +2389,332 @@ export class Journal {
2383
2389
  .filter((row) => row && typeof row === "object" && "kind" in row && row.kind === "judge");
2384
2390
  }
2385
2391
  }
2392
+ // ── C1 (v2.6.4): owed checks ─────────────────────────────────────────────────────────────────────
2393
+ // A waiver accepts risk on ONE immutable subject. The same task-approved row that satisfies the gate
2394
+ // carries the obligation it leaves behind: run, task, gate, the exact base..head range and its
2395
+ // canonical subject, the acceptance criteria, the run-derived lifetime patch authors, the cause and
2396
+ // the evidence that would discharge it. The fold below is the only reader. It keeps every waiver as
2397
+ // accepted-risk history, subtracts a discharge only while its proof still revalidates, and answers
2398
+ // "unknown" — never zero — for a waiver without a well-formed obligation or a proof that no longer holds.
2399
+ export const OWED_CHECK_VERSION = 1;
2400
+ export const OWED_DISCHARGE_EVENT = "owed-check-discharged";
2401
+ const owedGit = (cwd, args, input) => execFileSync("git", args, {
2402
+ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...(input === undefined ? { stdio: ["ignore", "pipe", "pipe"] } : { input }),
2403
+ });
2404
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
2405
+ export const owedCriteria = (acceptance) => sha256(JSON.stringify(acceptance));
2406
+ /** The daemon's canonical gate subject for base..head (daemon.ts gateCommitSubject), byte for byte. */
2407
+ export const owedSubject = (cwd, base, head) => sha256(owedGit(cwd, ["log", "--reverse", "--format=%T%x00%an%x00%ae%x00%cn%x00%ce%x00%B%x1e", `${base}..${head}`]));
2408
+ // Ownership joins on --stable ids (whitespace-blind: a re-indented carry keeps its author, which only
2409
+ // widens exclusions). Range identity uses --verbatim: re-indented content is a different program.
2410
+ const patchIdOf = (cwd, diff, mode = "--stable") => owedGit(cwd, ["patch-id", mode], diff).trim().split(/\s+/)[0] || undefined;
2411
+ /** Ordered verbatim patch ids of the commits in base..head; an empty commit authored no patch. */
2412
+ export const rangePatches = (cwd, base, head) => owedGit(cwd, ["rev-list", "--reverse", `${base}..${head}`]).trim().split("\n").filter(Boolean)
2413
+ .map((commit) => patchIdOf(cwd, owedGit(cwd, ["show", "--format=", "--binary", commit]), "--verbatim"))
2414
+ .filter((id) => id !== undefined);
2415
+ /** Verbatim patch identity of the whole change base..head introduces. */
2416
+ export const rangePatch = (cwd, base, head) => patchIdOf(cwd, owedGit(cwd, ["diff", "--binary", base, head]), "--verbatim") ?? "empty";
2417
+ const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
2418
+ /**
2419
+ * `head` is the task's own recorded integration merge of the obligation's range — exactly. Its first
2420
+ * parent is `mergeBase`; its second parent replays the waived history source tree for source tree from
2421
+ * a base with the waived base's tree (a carry may mint new commit objects, never new content — and
2422
+ * patch ids alone ignore where a hunk lands); its tree is git's own clean merge of the two (no
2423
+ * resolution, no amend); and it introduced the same ordered verbatim patch series and total patch.
2424
+ * Squashed, reordered, relocated, re-indented, reverted or conflict-resolved history fails one of
2425
+ * these and is refused as ambiguous; containment never counts.
2426
+ * Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
2427
+ */
2428
+ export function integrationMapped(cwd, events, o, mergeBase, head) {
2429
+ if (typeof mergeBase !== "string" || typeof head !== "string" || !COMMIT_ID.test(mergeBase) || !COMMIT_ID.test(head))
2430
+ return false;
2431
+ if (!events.some((e) => e.event === "merge" && e.taskId === o.taskId && e.data.commit === head))
2432
+ return false;
2433
+ try {
2434
+ const parents = owedGit(cwd, ["show", "-s", "--format=%P", head]).trim().split(" ");
2435
+ if (parents.length !== 2 || parents[0] !== mergeBase)
2436
+ return false;
2437
+ const [, carried] = parents;
2438
+ const tree = (rev) => owedGit(cwd, ["rev-parse", `${rev}^{tree}`]).trim();
2439
+ const trees = (from, to) => owedGit(cwd, ["log", "--reverse", "--format=%T", `${from}..${to}`]).trim();
2440
+ if (tree(owedGit(cwd, ["merge-base", mergeBase, carried]).trim()) !== tree(o.base) || trees(mergeBase, carried) !== trees(o.base, o.head))
2441
+ return false;
2442
+ // A conflicted merge-tree exits 1 and throws; a clean one prints its tree first.
2443
+ if (owedGit(cwd, ["merge-tree", "--write-tree", mergeBase, carried]).trim().split("\n")[0] !== tree(head))
2444
+ return false;
2445
+ const series = rangePatches(cwd, mergeBase, carried);
2446
+ return series.length === o.patches.length && series.every((id, i) => id === o.patches[i])
2447
+ && rangePatch(cwd, mergeBase, head) === o.patch;
2448
+ }
2449
+ catch {
2450
+ return false;
2451
+ }
2452
+ }
2453
+ /**
2454
+ * Lifetime patch authors of base..head, read from the run: the daemon's subjectAuthors fold
2455
+ * (daemon.ts, not exported and not this task's to edit) restated synchronously so the waive row is
2456
+ * written without yielding. Recreation rows name SOURCE hashes, so ownership joins by stable patch
2457
+ * identity across changed commit objects; a gate-only restore (resume-restore's seat, a recreation
2458
+ * after work started) never becomes an author. Unreadable history names an unknown author.
2459
+ */
2460
+ export function owedAuthors(events, taskId, cwd, base, head) {
2461
+ const cache = new Map();
2462
+ const patches = (commits) => {
2463
+ const ids = new Set();
2464
+ for (const commit of commits) {
2465
+ if (!cache.has(commit))
2466
+ cache.set(commit, patchIdOf(cwd, owedGit(cwd, ["show", "--format=", "--binary", commit])));
2467
+ const id = cache.get(commit);
2468
+ if (id)
2469
+ ids.add(id);
2470
+ }
2471
+ return ids;
2472
+ };
2473
+ let current;
2474
+ let previous;
2475
+ let awaitingCarry = false;
2476
+ const owners = new Map();
2477
+ const preservedOwner = new Map();
2478
+ const attribute = (ids, attempt) => {
2479
+ for (const id of ids) {
2480
+ if (attempt?.incoming.has(id))
2481
+ continue;
2482
+ const authors = owners.get(id) ?? new Set();
2483
+ authors.add(preservedOwner.get(id) ?? attempt?.author ?? "unknown author (missing task-dispatch assignment)");
2484
+ owners.set(id, authors);
2485
+ }
2486
+ };
2487
+ try {
2488
+ for (const row of events) {
2489
+ if (row.taskId !== taskId)
2490
+ continue;
2491
+ const preserved = [row.data.ref, row.data.preservedRef].find((v) => typeof v === "string" && v.startsWith("refs/tickmarkr/preserved/"));
2492
+ if (preserved) {
2493
+ const [commit, subject, trailer] = owedGit(cwd, ["show", "-s", `--format=%H%n%s%n%(trailers:key=${PRESERVE_PRODUCER_TRAILER},valueonly)`, `${preserved}^{commit}`]).trim().split("\n");
2494
+ if (commit && subject === PRESERVE_COMMIT_SUBJECT) {
2495
+ const owner = typeof row.data.producer === "string" ? row.data.producer
2496
+ : trailer?.trim().replace(/ attempt \d+$/, "") || "unknown author (legacy unattributed preservation)";
2497
+ for (const id of patches([commit])) {
2498
+ const prior = preservedOwner.get(id);
2499
+ if (prior === undefined || prior === "unknown" || prior.startsWith("unknown author"))
2500
+ preservedOwner.set(id, owner);
2501
+ }
2502
+ }
2503
+ }
2504
+ if (row.event === "task-dispatch") {
2505
+ previous = current;
2506
+ const a = row.data.assignment;
2507
+ current = { author: typeof a?.adapter === "string" && typeof a?.model === "string"
2508
+ ? `${a.adapter}:${a.model}` : "unknown author (missing task-dispatch assignment)", incoming: new Set() };
2509
+ awaitingCarry = true;
2510
+ }
2511
+ else if (row.event === "worktree-recreation") {
2512
+ const carried = patches(Array.isArray(row.data.carried) ? row.data.carried : []);
2513
+ attribute(carried, awaitingCarry ? previous : current);
2514
+ if (awaitingCarry && current)
2515
+ current.incoming = carried;
2516
+ awaitingCarry = false;
2517
+ }
2518
+ else if (["worker-launch", "worker-result", "gate-result", "task-human"].includes(row.event)) {
2519
+ awaitingCarry = false;
2520
+ }
2521
+ }
2522
+ const subject = patches(owedGit(cwd, ["rev-list", "--reverse", `${base}..${head}`]).trim().split("\n").filter(Boolean));
2523
+ attribute(subject, current);
2524
+ return [...new Set([...subject].flatMap((id) => [...(owners.get(id) ?? [])]))].sort();
2525
+ }
2526
+ catch (error) {
2527
+ return [`unknown author (${String(error).split("\n")[0]})`];
2528
+ }
2529
+ }
2530
+ /**
2531
+ * The obligation a waive of `gate` leaves on the task branch's current range. Synchronous on purpose:
2532
+ * approve writes it into the waive row under the approval serialization without yielding, so a live
2533
+ * daemon's boundary sees the same single append it always did. Anything not bound exactly — the
2534
+ * waived row measured another subject, unreadable refs, unresolved authors, unreadable criteria — is
2535
+ * recorded, never guessed, as known: false.
2536
+ */
2537
+ export function captureOwedCheck(input) {
2538
+ const { cwd, runId, taskId, gate, events } = input;
2539
+ let base = "unknown", head = "unknown", subject = "unknown", patch = "unknown";
2540
+ let patches = [];
2541
+ let authors = [];
2542
+ let unknownReason;
2543
+ try {
2544
+ head = owedGit(cwd, ["rev-parse", "--verify", `${input.taskRef}^{commit}`]).trim();
2545
+ base = owedGit(cwd, ["merge-base", input.integrationRef, head]).trim();
2546
+ subject = owedSubject(cwd, base, head);
2547
+ const measured = [...events].reverse().find((e) => e.taskId === taskId && e.event === "gate-result" && e.data.gate === gate);
2548
+ if (measured?.data.commit !== subject) {
2549
+ throw new Error(`the waived ${gate} row measured ${String(measured?.data.commit ?? "no subject")}, not ${input.taskRef}'s range`);
2550
+ }
2551
+ patches = rangePatches(cwd, base, head);
2552
+ patch = rangePatch(cwd, base, head);
2553
+ authors = owedAuthors(events, taskId, cwd, base, head);
2554
+ if (!authors.length || authors.some((a) => a.startsWith("unknown")))
2555
+ throw new Error(`unresolved patch authors: ${authors.join(", ") || "none"}`);
2556
+ }
2557
+ catch (error) {
2558
+ unknownReason = (error instanceof Error ? error.message : String(error)).split("\n")[0];
2559
+ }
2560
+ if (!Array.isArray(input.acceptance))
2561
+ unknownReason ??= "task acceptance criteria unreadable";
2562
+ const files = Array.isArray(input.files) && input.files.every((f) => typeof f === "string") ? input.files : undefined;
2563
+ if (!files)
2564
+ unknownReason ??= "task file scope unreadable";
2565
+ if (!input.declared?.length)
2566
+ unknownReason ??= "declared channels unreadable";
2567
+ const criteria = Array.isArray(input.acceptance) ? owedCriteria(input.acceptance) : "unknown";
2568
+ return {
2569
+ version: OWED_CHECK_VERSION,
2570
+ id: sha256(JSON.stringify([runId, taskId, gate, base, head, subject, criteria])).slice(0, 16),
2571
+ runId, taskId, gate, base, head, subject, patch, patches, criteria, files: files ?? [], authors,
2572
+ declared: [...(input.declared ?? [])].map(({ key, vendor }) => ({ key, vendor })), cause: input.cause,
2573
+ evidence: `tickmarkr verify --base ${base} --task ${taskId} --record ${runId}: ${gate} passing on ${base}..${head} or on its recorded integration merge, reviewed outside the authors' vendors`,
2574
+ disposition: "accepted-risk",
2575
+ known: unknownReason === undefined,
2576
+ ...(unknownReason === undefined ? {} : { unknownReason }),
2577
+ };
2578
+ }
2579
+ /** A bound fact: a non-empty string that capture did not record as unknown. */
2580
+ const isBound = (v) => typeof v === "string" && v !== "" && !v.startsWith("unknown");
2581
+ /** Every required field present and well-typed; anything else is malformed evidence, never a known check. */
2582
+ const isOwedCheck = (value, taskId, gate) => {
2583
+ if (typeof value !== "object" || value === null)
2584
+ return false;
2585
+ const o = value;
2586
+ return o.version === OWED_CHECK_VERSION && o.known === true && o.disposition === "accepted-risk"
2587
+ && o.taskId === taskId && o.gate === gate && GATE_NAMES.includes(o.gate)
2588
+ && [o.id, o.runId, o.taskId, o.base, o.head, o.subject, o.patch, o.criteria].every(isBound)
2589
+ && typeof o.cause === "string" && typeof o.evidence === "string"
2590
+ && Array.isArray(o.patches) && o.patches.every(isBound)
2591
+ && Array.isArray(o.authors) && o.authors.length > 0 && o.authors.every(isBound)
2592
+ && Array.isArray(o.files) && o.files.every((f) => typeof f === "string")
2593
+ && Array.isArray(o.declared) && o.declared.length > 0 && o.declared.every(isChannelFact);
2594
+ };
2595
+ const providerOf = (fact) => modelProvider(fact.key.slice(fact.key.indexOf(":") + 1), fact.vendor);
2596
+ /** A resolved channel: an `adapter:model` key and a bound vendor — never an empty or placeholder claim. */
2597
+ const isChannelFact = (f) => typeof f === "object" && f !== null
2598
+ && typeof f.key === "string" && /^[^:\s]+:\S+$/.test(f.key) && isBound(f.vendor);
2599
+ /**
2600
+ * The trusted channel identities, key → vendors: what this repository's adapters declared under its config
2601
+ * when the waive was written, recorded on the obligation by approve. Declaration, not doctor health — a
2602
+ * later logout does not revoke a proof, but a seat not declared (or declared under another vendor) never
2603
+ * resolved. Read from the row, the fold resolves identically in every process and every checkout.
2604
+ */
2605
+ const declaredVendors = (o) => {
2606
+ const vendors = new Map();
2607
+ for (const c of o.declared)
2608
+ vendors.set(c.key, (vendors.get(c.key) ?? new Set()).add(c.vendor));
2609
+ return vendors;
2610
+ };
2611
+ /** Why a discharge row no longer proves its obligation, re-read from its artifact; undefined when it does. */
2612
+ function dischargeProblem(row, o, events, cwd) {
2613
+ const d = row.data;
2614
+ if (row.taskId !== o.taskId)
2615
+ return "discharge names another task";
2616
+ if (d.criteria !== o.criteria)
2617
+ return "discharge proved other criteria";
2618
+ const exact = d.mapping === "exact" && d.mergeBase === o.base && d.head === o.head;
2619
+ // The integration mapping is re-proved from git on every fold, never taken from the row's claims.
2620
+ const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head);
2621
+ if (!exact && !integration)
2622
+ return "discharge range does not bind the obligation";
2623
+ let artifact;
2624
+ try {
2625
+ const bytes = readFileSync(String(d.artifactPath));
2626
+ if (createHash("sha256").update(bytes).digest("hex") !== d.artifactSha256)
2627
+ return "artifact hash mismatch";
2628
+ artifact = JSON.parse(bytes.toString("utf8")) ?? {};
2629
+ }
2630
+ catch {
2631
+ return "artifact unavailable";
2632
+ }
2633
+ if (artifact.head !== d.head || artifact.mergeBase !== d.mergeBase)
2634
+ return "artifact measured another range";
2635
+ if (artifact.green !== true)
2636
+ return "artifact verdict is not green";
2637
+ // The criteria the hash-bound evidence itself measured: the row's claim alone only repeats the obligation.
2638
+ if (artifact.criteria !== o.criteria)
2639
+ return "artifact measured other criteria";
2640
+ // The scope the waived task declared, exactly: a wider allowlist (--files '*') is weakened evidence.
2641
+ if (JSON.stringify(artifact.files) !== JSON.stringify(o.files))
2642
+ return "artifact gated another file scope";
2643
+ const rows = (Array.isArray(artifact.gateRows) ? artifact.gateRows : []);
2644
+ const gateRow = rows.find((r) => r?.gate === o.gate && r.pass === true && r.meta?.skipped !== true);
2645
+ if (!gateRow)
2646
+ return `artifact holds no passing ${o.gate} row`;
2647
+ if (o.gate === "review") {
2648
+ const reviewer = d.reviewer;
2649
+ const authors = Array.isArray(d.authorChannels) ? d.authorChannels : [];
2650
+ // The row's reviewer must be the seat the hash-bound review row recorded — key, vendor and provider.
2651
+ if (!isChannelFact(reviewer) || gateRow.meta?.reviewer !== reviewer.key || gateRow.meta.vendor !== reviewer.vendor
2652
+ || gateRow.meta.provider !== providerOf(reviewer))
2653
+ return "unresolved reviewer";
2654
+ // ...and a channel the waive recorded as declared under exactly that vendor: agreement between the row
2655
+ // and the artifact only repeats a claim, so identity and vendor resolve against the obligation's own.
2656
+ const declared = declaredVendors(o);
2657
+ const resolves = (f) => { const v = declared.get(f.key); return v?.size === 1 && v.has(f.vendor); };
2658
+ // An author key stays excluded whatever vendor any row claims for it.
2659
+ if (o.authors.includes(reviewer.key))
2660
+ return "same-vendor reviewer (the reviewer is a recorded patch author)";
2661
+ if (!resolves(reviewer))
2662
+ return "unresolved reviewer (no declared channel with that identity and vendor)";
2663
+ if (!authors.every(isChannelFact) || !o.authors.every((key) => authors.some((a) => a.key === key)))
2664
+ return "unresolved author channel";
2665
+ if (authors.some((a) => authors.some((b) => a.key === b.key && a.vendor !== b.vendor)))
2666
+ return "unresolved author channel (inconsistent vendor claims)";
2667
+ if (!authors.every(resolves))
2668
+ return "unresolved author channel (no declared channel with that identity and vendor)";
2669
+ if (authors.some((a) => a.vendor === reviewer.vendor || providerOf(a) === providerOf(reviewer)))
2670
+ return "same-vendor reviewer";
2671
+ }
2672
+ return undefined;
2673
+ }
2674
+ /**
2675
+ * The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
2676
+ * `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
2677
+ * It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
2678
+ */
2679
+ export function foldOwedChecks(events, cwd) {
2680
+ try {
2681
+ return foldOwedChecksOrThrow(events, cwd);
2682
+ }
2683
+ catch (error) {
2684
+ return { known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason: `owed-check fold failed: ${String(error).split("\n")[0]}` }] };
2685
+ }
2686
+ }
2687
+ function foldOwedChecksOrThrow(events, cwd) {
2688
+ const accepted = new Map();
2689
+ const discharged = new Set();
2690
+ const unknown = [];
2691
+ for (const e of effectiveEvents(events)) {
2692
+ if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE) {
2693
+ const o = e.data.obligation;
2694
+ if (isOwedCheck(o, e.taskId, e.data.gate))
2695
+ accepted.set(o.id, o);
2696
+ else {
2697
+ const recorded = typeof o === "object" && o !== null ? o.unknownReason : undefined;
2698
+ unknown.push({ taskId: e.taskId, gate: String(e.data.gate), reason: o === undefined ? "legacy waiver without an owed-check obligation"
2699
+ : typeof recorded === "string" ? recorded : "malformed owed-check obligation" });
2700
+ }
2701
+ }
2702
+ else if (e.event === OWED_DISCHARGE_EVENT) {
2703
+ const ids = Array.isArray(e.data.ids) ? e.data.ids : [];
2704
+ const problems = ids.map((id) => {
2705
+ const o = accepted.get(String(id));
2706
+ return o ? dischargeProblem(e, o, events, cwd) : "discharge names no recorded obligation";
2707
+ });
2708
+ const problem = ids.length ? problems.find((p) => p !== undefined) : "discharge names no obligation";
2709
+ if (problem)
2710
+ unknown.push({ taskId: e.taskId, reason: problem });
2711
+ else
2712
+ for (const id of ids)
2713
+ discharged.add(String(id));
2714
+ }
2715
+ }
2716
+ const acceptedRisk = [...accepted.values()];
2717
+ const outstanding = acceptedRisk.filter((o) => !discharged.has(o.id));
2718
+ const known = unknown.length === 0;
2719
+ return { known, debt: known ? outstanding.length : "unknown", outstanding, acceptedRisk, discharged: [...discharged], unknown };
2720
+ }
@@ -1,6 +1,8 @@
1
1
  import { type JournalEvent } from "../../run/journal.js";
2
2
  import { type OperatorRecord } from "../../run/operator-state.js";
3
3
  export declare const OBSERVATION_INTERVAL_MS = 1000;
4
+ /** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
5
+ export declare const APPEND_RACE_OBSERVATIONS = 3;
4
6
  export declare const STORE_LIMITS: {
5
7
  readonly graphBytes: number;
6
8
  readonly history: 256;
@@ -61,12 +63,16 @@ export declare class JournalTail {
61
63
  private bytesRead;
62
64
  private lastSuccessfulReadAt?;
63
65
  private failure?;
66
+ private stale;
64
67
  constructor(source: string, hooks?: {
65
68
  record?: (record: OperatorRecord) => void;
66
69
  reset?: () => void;
67
70
  });
68
71
  private reset;
72
+ /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
69
73
  poll(now?: number): TailSnapshot;
74
+ /** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
75
+ private observe;
70
76
  private appendCarry;
71
77
  snapshot(): TailSnapshot;
72
78
  /** Demand paging scans with fixed buffers, independent of the retained history; no growing line index. */
@@ -7,6 +7,8 @@ import { isPidLive, STALE_MS } from "../../run/lock.js";
7
7
  import { readTierLiveness, SUPERVISION_TIERS } from "../../run/supervision.js";
8
8
  import { OperatorStateFold } from "../../run/operator-state.js";
9
9
  export const OBSERVATION_INTERVAL_MS = 1_000;
10
+ /** Stat-to-fstat observations one poll spends on a journal a writer keeps appending to. */
11
+ export const APPEND_RACE_OBSERVATIONS = 3;
10
12
  // Graph declarations have a separate 16 MiB bound; journal retention remains unchanged.
11
13
  export const STORE_LIMITS = { graphBytes: 16 * 1024 * 1024, history: 256, historyBytes: 2 * 1024 * 1024, recordBytes: 1024 * 1024, readBytes: 1024 * 1024, subscribers: 64, metrics: 12, errors: 32 };
12
14
  const errorText = (e) => e instanceof Error ? e.message : String(e);
@@ -48,6 +50,7 @@ export class JournalTail {
48
50
  bytesRead = 0;
49
51
  lastSuccessfulReadAt;
50
52
  failure;
53
+ stale = false;
51
54
  constructor(source, hooks = {}) {
52
55
  this.source = source;
53
56
  this.hooks = hooks;
@@ -65,18 +68,45 @@ export class JournalTail {
65
68
  this.generation++;
66
69
  this.hooks.reset?.();
67
70
  }
71
+ /** A same-inode append between stat and fstat is retried; exhaustion keeps the last good snapshot, pending. */
68
72
  poll(now = Date.now()) {
73
+ try {
74
+ for (let observation = 1; !this.observe(); observation++) {
75
+ if (observation === APPEND_RACE_OBSERVATIONS) {
76
+ this.stale = true;
77
+ this.failure = undefined;
78
+ return this.snapshot();
79
+ }
80
+ }
81
+ this.stale = false;
82
+ this.failure = undefined;
83
+ this.lastSuccessfulReadAt = now;
84
+ }
85
+ catch (e) {
86
+ this.failure = { source: this.source, error: errorText(e) };
87
+ }
88
+ return this.snapshot();
89
+ }
90
+ /** One stat-to-fstat observation. False when an append raced it; nothing was consumed or reset. */
91
+ observe() {
69
92
  let fd;
70
93
  try {
71
94
  const st = statSync(this.source, { bigint: true });
72
95
  if (!st.isFile())
73
96
  throw new Error("journal source is not a regular file");
74
- if (!this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st)))
75
- this.reset();
76
- if (Number(st.size) > this.offset) {
97
+ const replaced = !this.st || fileIdentity(st) !== fileIdentity(this.st) || st.size < this.st.size || (st.size === this.st.size && stamp(st) !== stamp(this.st));
98
+ if (Number(st.size) > (replaced ? 0 : this.offset)) {
77
99
  fd = openSync(this.source, "r");
78
- if (stamp(fstatSync(fd, { bigint: true })) !== stamp(st))
100
+ const opened = fstatSync(fd, { bigint: true });
101
+ if (stamp(opened) !== stamp(st)) {
102
+ if (fileIdentity(opened) === fileIdentity(st) && opened.size > st.size)
103
+ return false;
79
104
  throw new Error("journal changed before read; retry observation");
105
+ }
106
+ }
107
+ if (replaced)
108
+ this.reset();
109
+ if (fd !== undefined) {
80
110
  let remaining = STORE_LIMITS.readBytes;
81
111
  const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, Number(st.size) - this.offset));
82
112
  while (this.offset < Number(st.size) && remaining > 0) {
@@ -113,17 +143,12 @@ export class JournalTail {
113
143
  }
114
144
  }
115
145
  this.st = st;
116
- this.failure = undefined;
117
- this.lastSuccessfulReadAt = now;
118
- }
119
- catch (e) {
120
- this.failure = { source: this.source, error: errorText(e) };
146
+ return true;
121
147
  }
122
148
  finally {
123
149
  if (fd !== undefined)
124
150
  closeSync(fd);
125
151
  }
126
- return this.snapshot();
127
152
  }
128
153
  appendCarry(bytes) {
129
154
  this.carryBytes += bytes.length;
@@ -137,7 +162,7 @@ export class JournalTail {
137
162
  history: this.history.slice(), errors: this.errors.slice(), malformedCount: this.malformedCount,
138
163
  pending: this.carryBytes ? { line: this.line + 1, bytes: this.carryBytes } : undefined,
139
164
  backlogBytes: Math.max(0, Number(this.st?.size ?? 0) - this.offset),
140
- status: this.failure ? "unreadable" : this.malformedCount ? "corrupt" : this.carryBytes ? "pending" : "readable",
165
+ status: this.failure ? "unreadable" : this.malformedCount ? "corrupt" : this.carryBytes || this.stale ? "pending" : "readable",
141
166
  error: this.failure, lastSuccessfulReadAt: this.lastSuccessfulReadAt, bytesRead: this.bytesRead,
142
167
  };
143
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.6.3",
3
+ "version": "2.6.4",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,7 +92,7 @@ After sending, **confirm delivery** by reading the target pane and verifying the
92
92
  1. **Prepare** — confirm the target list. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
93
93
  2. **Compile** — run `tickmarkr compile <spec-or-directory>`. Fix source-spec defects instead of editing the generated graph.
94
94
  3. **Plan** — run `tickmarkr plan`. Review routes, capability-floor warnings, and human gates before execution.
95
- 4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every green clause for you. Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session.
96
- 5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", and the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty — a run with a parked task is partial, not green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
95
+ 4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the relevant agent session.
96
+ 5. **Verify and consolidate** — continue only after a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted work on `tickmarkr/<runId>` and never signs off to the main branch. A human controls any later release merge.
97
97
  6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout; redirect explicitly beside the spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records.
98
98
  7. **Continue** — move to the next requested target. If a target fails or is parked, stop with the journal evidence rather than silently skipping it.
@@ -88,8 +88,8 @@ When spawning consultants (agents gathering synthesis input for decisions like S
88
88
  1. **Prepare** — start from the requested spec. Run the [binary preflight](#binary-preflight-before-compile-or-run). Check `git status`, confirm no tickmarkr run is active, and work from a non-main branch.
89
89
  2. **Compile** — run `tickmarkr compile <spec>`. Correct compilation errors in the spec, never in the generated graph.
90
90
  3. **Plan** — run `tickmarkr plan`. Review the routing table, capability-floor warnings, and every human gate, including work that each gate blocks.
91
- 4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every green clause for you. Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the agent session; do not turn them into proxy questions.
92
- 5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", and the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty — a run with a parked task is partial, not green. Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
91
+ 4. **Run** — run `tickmarkr run`. A watch ending the seat's turn is no watch: keep a **blocking journal consumer** alive for the run's terminal events — the shipped watcher below, or a foreground `until grep` on the run's terminal events — and ensure it is re-armed at most every twenty minutes. Never rely on a `Monitor`-only wake. Watch the run journal rather than polling agents, using the shipped watcher — `.claude/skills/tickmarkr-overseer/scripts/watch-journal.sh <state-dir>/runs 20 28800` — which takes a line baseline at arm time, then wakes ONCE on `run-end`, `task-human`, `task-failed` or `consult-verdict` and grades the run-end summary against every execution clause for you; the debt clause is yours — read CURRENT `tickmarkr status <runId>` (step 5). Re-arm after every wake. ⛔ Never `tail -F | grep -m1` (run-end is the journal's last line, so tail never notices the broken pipe and the watcher hangs forever) and never a pane-level done wait (it fires on every agent turn end, not mission end). ⚠ A bare whole-file `grep -q '"event":"run-end"'` is the trap the watcher exists to avoid: on a resume it matches the PREVIOUS run's run-end and returns instantly, so a re-armed watcher reads as coverage that does not exist. Resolve blocked interactions in the agent session; do not turn them into proxy questions.
92
+ 5. **Verify and consolidate** — accept only a green run. A run is green when the run-end event exists in the journal, the tip verify is not "failed", the summary's `failed`, `human`, `blocked` and `pending` buckets are all empty, and CURRENT `tickmarkr status <runId>` reads its owed checks outstanding empty AND known (`outstanding 0`) — a run with a parked task is partial, not green. Empty execution buckets alone are not green either (D-660): every bucket empty and the tip passed, but an operator waived one review, leaves one accepted-risk review check owed — status reads `outstanding 1 (T7 review)` and `run`/`resume` still exit 0 on execution alone, so the run is execution complete, not green. It turns green only when a `tickmarkr verify --record <runId>` discharge moves CURRENT status to `outstanding 0`, including a discharge landing after run-end — the historical run-end record keeps the old count, so never read debt from it; `outstanding unknown` is never green. Tickmarkr consolidates accepted task work on `tickmarkr/<runId>`; it never signs off to the main branch. A human may later merge that integration branch through the repository's normal release process.
93
93
  6. **Record** — `tickmarkr report <runId> --md` prints Markdown to stdout. Redirect it explicitly beside the source spec (for example `tickmarkr report <runId> --md > feature.record.md`) and commit the execution record when the repository tracks those records. Then [stand down](#stand-down-mission-end-and-retirement).
94
94
 
95
95
  ## Cockpit, parked decisions and printed twins
@@ -122,8 +122,14 @@ own bound failure token: status prints `failed — T3 — failure <line>@<ts>`,
122
122
  `tickmarkr approve <runId> T3 --recheck --park <line>@<ts>` re-gates its landed commits. Preserve resume refusals and repair the named source/config issue
123
123
  (including deny/prefer conflicts); never edit the compiled graph to force a result. Resume
124
124
  makes CURRENT TIP PENDING; historical GATES RAN does not prove completion. The completed
125
- case has 3/3 recorded merges, a latest run-end, a nonfailed known tip result and empty
126
- `failed`, `human`, `blocked`, `pending` buckets. A mismatched graph is “not comparable.”
125
+ case has 3/3 recorded merges, a latest run-end, a nonfailed known tip result, empty
126
+ `failed`, `human`, `blocked`, `pending` buckets, and CURRENT status reading `outstanding 0`
127
+ — owed checks outstanding empty AND known. Replay D-660 before calling it green: the same
128
+ 3/3 with every bucket empty, after T2's review was waived, reads `outstanding 1 (T2 review)`;
129
+ `resume` exits 0 and its final line says `execution complete; outstanding 1 (T2 review)`, not
130
+ `verified`. It is green only once `tickmarkr verify --record <runId>` discharges that check and
131
+ CURRENT status reads `outstanding 0` — even after run-end, whose record still carries 1.
132
+ `outstanding unknown` (a legacy waiver) is never green. A mismatched graph is “not comparable.”
127
133
 
128
134
  Run offers only validated park verbs: human/attempt-cap/other non-gate parks allow approve;
129
135
  infra allows approve or `--recheck`; review gate-fail allows `--waive`, `--uphold` or