tickmarkr 2.6.2 → 2.6.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.
Files changed (63) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/commands/approve.d.ts +6 -3
  3. package/dist/cli/commands/approve.js +16 -4
  4. package/dist/cli/commands/doctor.d.ts +6 -2
  5. package/dist/cli/commands/fleet.js +46 -8
  6. package/dist/cli/commands/plan.js +13 -8
  7. package/dist/cli/commands/report.d.ts +2 -1
  8. package/dist/cli/commands/report.js +56 -6
  9. package/dist/cli/commands/status.js +19 -1
  10. package/dist/compile/native.js +7 -0
  11. package/dist/config/config.d.ts +17 -4
  12. package/dist/config/config.js +43 -6
  13. package/dist/config/fleet-overlay.d.ts +13 -3
  14. package/dist/config/fleet-overlay.js +12 -8
  15. package/dist/drivers/herdr.d.ts +12 -0
  16. package/dist/drivers/herdr.js +51 -0
  17. package/dist/drivers/orca.d.ts +9 -1
  18. package/dist/drivers/orca.js +29 -7
  19. package/dist/drivers/types.d.ts +2 -0
  20. package/dist/drivers/types.js +2 -2
  21. package/dist/gates/acceptance.d.ts +7 -0
  22. package/dist/gates/acceptance.js +27 -5
  23. package/dist/gates/baseline.d.ts +20 -1
  24. package/dist/gates/baseline.js +100 -20
  25. package/dist/gates/cache.d.ts +8 -0
  26. package/dist/gates/cache.js +12 -2
  27. package/dist/gates/llm.d.ts +6 -0
  28. package/dist/gates/llm.js +27 -8
  29. package/dist/gates/review.d.ts +6 -1
  30. package/dist/gates/review.js +122 -32
  31. package/dist/gates/run-gates.d.ts +54 -3
  32. package/dist/gates/run-gates.js +331 -45
  33. package/dist/gates/test-manifest.d.ts +42 -0
  34. package/dist/gates/test-manifest.js +69 -10
  35. package/dist/route/router.d.ts +12 -1
  36. package/dist/route/router.js +26 -9
  37. package/dist/run/consult.d.ts +3 -1
  38. package/dist/run/consult.js +4 -2
  39. package/dist/run/daemon.d.ts +2 -1
  40. package/dist/run/daemon.js +342 -77
  41. package/dist/run/interactive-seed.d.ts +4 -0
  42. package/dist/run/interactive-seed.js +35 -9
  43. package/dist/run/journal.d.ts +26 -0
  44. package/dist/run/journal.js +143 -15
  45. package/dist/run/lease.d.ts +13 -0
  46. package/dist/run/lease.js +45 -0
  47. package/dist/run/protocol.d.ts +15 -0
  48. package/dist/run/protocol.js +11 -1
  49. package/dist/run/receipt-resolver.d.ts +22 -0
  50. package/dist/run/receipt-resolver.js +40 -1
  51. package/dist/run/repair-selection.d.ts +11 -1
  52. package/dist/run/repair-selection.js +17 -9
  53. package/dist/run/wall-budget.d.ts +48 -0
  54. package/dist/run/wall-budget.js +280 -0
  55. package/dist/tui/cockpit/run-cockpit.js +2 -2
  56. package/dist/tui/cockpit/run-view.d.ts +2 -1
  57. package/dist/tui/cockpit/run-view.js +13 -9
  58. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  59. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  60. package/package.json +2 -1
  61. package/schema/config.schema.json +8 -1
  62. package/skills/tickmarkr-loop/SKILL.md +7 -1
  63. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -1,4 +1,5 @@
1
1
  import { type Assignment, type WorkerAdapter } from "../adapters/types.js";
2
+ import { DeliveryReadinessError } from "../drivers/herdr.js";
2
3
  import type { ExecutorDriver, Slot } from "../drivers/types.js";
3
4
  export interface InteractiveSeedResult {
4
5
  output: string;
@@ -8,6 +9,9 @@ export interface InteractiveSeedResult {
8
9
  trustAnswered: boolean;
9
10
  }
10
11
  type SeedDriver = Pick<ExecutorDriver, "run" | "waitOutput" | "read" | "sendKey">;
12
+ export declare class SeedReadinessError extends DeliveryReadinessError {
13
+ constructor(waitedMs: number, transcript: string, readinessMatch: string);
14
+ }
11
15
  export declare function runInteractiveSeed(opts: {
12
16
  driver: SeedDriver;
13
17
  slot: Slot;
@@ -1,4 +1,5 @@
1
1
  import { matchesTrustDialog } from "../adapters/types.js";
2
+ import { DeliveryReadinessError } from "../drivers/herdr.js";
2
3
  // The workspace-trust prompt is a STARTUP gate: it renders before the readiness banner and blocks
3
4
  // it, so this wait is the only window in which anything can answer it. Observation therefore runs
4
5
  // for the WHOLE readiness budget. Review round 7 (material): a 60-second cutoff here was a window
@@ -9,6 +10,18 @@ import { matchesTrustDialog } from "../adapters/types.js";
9
10
  // blocked anyway, and it ends at the first of readiness, a modal, or the deadline.
10
11
  const TRUST_POLL_MS = 1_000;
11
12
  const TRUST_PANE_ROWS = 80;
13
+ // OBS-1205 add.1: a launch whose readiness never appeared never received the brief, so its deadline is
14
+ // the delivery-readiness class (OBS-142: the interface never became interactive), not a launched
15
+ // worker. Thrown as that class, the daemon's existing catch journals `delivery-readiness-failed`,
16
+ // closes the slot and walks the escalation ladder to a failover — with no `worker-launch` row, which is
17
+ // where a funded repair's findings expire, so the repair the seed never delivered stays owed.
18
+ export class SeedReadinessError extends DeliveryReadinessError {
19
+ constructor(waitedMs, transcript, readinessMatch) {
20
+ super(waitedMs, transcript);
21
+ this.name = "SeedReadinessError";
22
+ this.message = `seed readiness pattern not seen after ${waitedMs}ms: ${readinessMatch}`;
23
+ }
24
+ }
12
25
  // v1.89 T19 / OBS-406: wait for readiness, answering a fingerprint-matched trust modal at most ONCE
13
26
  // on the way. The daemon's own trust loop runs only after runInteractiveSeed returns, and this wait
14
27
  // is exactly the window the modal blocks in — a declaration consulted only there is unreachable
@@ -17,23 +30,34 @@ async function awaitReadiness(opts) {
17
30
  const { driver, slot, readinessMatch, deadline } = opts;
18
31
  const dialog = opts.adapter.trustDialog;
19
32
  const left = () => Math.max(0, deadline - Date.now());
33
+ // Every wait below is awaited, and the budget can run out inside any of them (OrcaDriver.waitOutput
34
+ // checks its sweep before its clock). Readiness a wait returns after the deadline is expired — it may
35
+ // be what a key pressed at the edge produced — and a launch whose deadline passed has already
36
+ // failed, so neither a key nor a seed follows it. Both branches apply it.
37
+ const expired = () => Date.now() >= deadline;
38
+ const readyInTime = async (ms) => (await driver.waitOutput(slot, readinessMatch, ms)) && !expired();
20
39
  // Nothing this launch could answer (no declaration, an honest {kind:"none"}, or a driver with no
21
- // keystroke surface): the single long wait, byte-identical to pre-T19 behaviour.
40
+ // keystroke surface): the single long wait, pre-T19 behaviour plus the post-await deadline check.
22
41
  if (!dialog || dialog.kind === "none" || !driver.sendKey) {
23
- return { ready: await driver.waitOutput(slot, readinessMatch, left()), trustAnswered: false };
42
+ return { ready: await readyInTime(left()), trustAnswered: false };
24
43
  }
25
44
  let trustAnswered = false;
26
- while (!trustAnswered && left() > 0) {
27
- if (await driver.waitOutput(slot, readinessMatch, Math.min(TRUST_POLL_MS, left()))) {
45
+ while (!trustAnswered && !expired()) {
46
+ if (await readyInTime(Math.min(TRUST_POLL_MS, left()))) {
28
47
  return { ready: true, trustAnswered };
29
48
  }
30
49
  let paneText;
31
50
  try {
32
- paneText = await driver.read(slot, TRUST_PANE_ROWS);
51
+ // OBS-1205: a cursor-drawn modal has text only on the RENDERED frame, so the slot's screen read
52
+ // is preferred when its driver has one. An unreadable or foreign frame is a failed read, never
53
+ // a cue to match the stream instead; drivers without a screen keep polling `read`.
54
+ paneText = await (slot.readScreen ? slot.readScreen() : driver.read(slot, TRUST_PANE_ROWS));
33
55
  }
34
56
  catch {
35
57
  continue; // a failed read is not a matched modal — keep observing, spend nothing
36
58
  }
59
+ if (expired())
60
+ break; // the wait or the read outlived the budget: no key after the deadline
37
61
  if (!matchesTrustDialog(paneText, dialog))
38
62
  continue;
39
63
  // Review round 7 (material): the latch is spent BEFORE the awaited send, not after it. A send
@@ -54,7 +78,8 @@ async function awaitReadiness(opts) {
54
78
  /* dispatched-then-rejected: never retried here, and never re-tried by the daemon either */
55
79
  }
56
80
  }
57
- return { ready: await driver.waitOutput(slot, readinessMatch, left()), trustAnswered };
81
+ // An expired budget gets no closing sweep: a zero-length wait still reads the terminal once.
82
+ return { ready: !expired() && await readyInTime(left()), trustAnswered };
58
83
  }
59
84
  // v1.69 T6: launch-then-seed handoff for adapters whose real TUI cannot be argv-seeded.
60
85
  // Both the launch command and the seed line are delivered through the driver's existing `run`
@@ -65,6 +90,8 @@ export async function runInteractiveSeed(opts) {
65
90
  await opts.driver.run(opts.slot, seed.launch(opts.assignment.model));
66
91
  // `trustAnswered` rides EVERY return below, including both early ones: the daemon initializes its
67
92
  // per-slot latch from it, and an omission there reads as "no key was sent" — the second-Enter defect.
93
+ // The readiness deadline throws instead; `onTrustAnswered` already carried the latch out.
94
+ const launchedAt = Date.now();
68
95
  const { ready, trustAnswered } = await awaitReadiness({
69
96
  driver: opts.driver,
70
97
  slot: opts.slot,
@@ -74,9 +101,8 @@ export async function runInteractiveSeed(opts) {
74
101
  ...(opts.onTrustAnswered ? { onTrustAnswered: opts.onTrustAnswered } : {}),
75
102
  });
76
103
  const banner = await opts.driver.read(opts.slot, 1000);
77
- if (!ready) {
78
- return { output: banner, seedFailed: true, seedError: `readiness pattern not seen: ${seed.readinessMatch}`, trustAnswered };
79
- }
104
+ if (!ready)
105
+ throw new SeedReadinessError(Date.now() - launchedAt, banner, seed.readinessMatch);
80
106
  let sessionId;
81
107
  if (seed.confirmBanner) {
82
108
  const confirm = seed.confirmBanner(banner, opts.assignment.model);
@@ -107,6 +107,8 @@ export interface StructuredFinding {
107
107
  observedFingerprints?: string[];
108
108
  /** Prior id validated against the reviewer's reraised list by the review gate. */
109
109
  reraisedFrom?: string;
110
+ /** OBS-1195: an anchor's explicitly named parent chain; it retires when that chain is resolved. */
111
+ boundTo?: string;
110
112
  /** Resolved definition AND defect identity; bare symbol spelling is never lineage. */
111
113
  codeIdentity?: {
112
114
  definition: string;
@@ -185,7 +187,10 @@ export declare function formatPriorFindingEvidence(evidence: PriorFindingEvidenc
185
187
  /** Normalized identity of a gate failure: the same defect, seen twice, normalizes to the same bytes. */
186
188
  export declare function normalizeGateFailure(details: string): string;
187
189
  export declare const GATE_FINGERPRINT_CAP = 2;
190
+ /** The invocation a gate row's receipt names — the identity of the execution that observed it. */
191
+ export declare const receiptOrigin: (data: Record<string, unknown>) => string | undefined;
188
192
  export declare function identicalGateFailures(events: JournalEvent[], taskId: string, gate: string, normalized: string): number;
193
+ export declare const readableExcerpt: (text: string) => string;
189
194
  /** What each funded repair's next battery actually reached. A repair spends its budget only
190
195
  * when that battery reaches one of the gates it was funded to fix; an earlier red is evidence that
191
196
  * this repair never got its funded turn, not a charge against the repair ladder. */
@@ -309,7 +314,28 @@ export declare function carryReviewFindings(priors: readonly StructuredFinding[]
309
314
  * after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
310
315
  * row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
311
316
  */
317
+ /** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
318
+ * never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
319
+ export declare function priorJudgments(events: readonly JournalEvent[], taskId: string): Array<{
320
+ commit: string;
321
+ judge?: string;
322
+ criteria: Array<{
323
+ id: string;
324
+ key: string;
325
+ met: boolean;
326
+ paths: string[];
327
+ }>;
328
+ }>;
312
329
  export declare function outstandingReviewFindings(events: JournalEvent[], taskId: string): StructuredFinding[];
330
+ /**
331
+ * OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
332
+ * re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
333
+ * count, so an unrelated finding sharing a path never lengthens it.
334
+ */
335
+ export declare function reraisedReviewChains(events: JournalEvent[], taskId: string): Array<{
336
+ finding: StructuredFinding;
337
+ reraises: number;
338
+ }>;
313
339
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
314
340
  export declare function pendingRepairFindings(events: JournalEvent[], taskId: string): string | undefined;
315
341
  /**
@@ -713,21 +713,53 @@ export function normalizeGateFailure(details) {
713
713
  // operator approval is a new engagement, and nothing else resets the count. Resume re-measurements
714
714
  // are excluded because the interrupted attempt already bought the result they confirm.
715
715
  export const GATE_FINGERPRINT_CAP = 2;
716
+ /** The invocation a gate row's receipt names — the identity of the execution that observed it. */
717
+ export const receiptOrigin = (data) => {
718
+ const receipt = data.evidenceReceipt ?? (Array.isArray(data.evidenceReceipts) ? data.evidenceReceipts[0] : undefined);
719
+ const id = receipt && typeof receipt === "object" ? receipt.invocationId : undefined;
720
+ return typeof id === "string" && id ? `invocation:${id}` : undefined;
721
+ };
722
+ // OBS-1106 residual: occurrences are independent OBSERVATIONS, not rows. A journal replay
723
+ // (`replayedFromAttempt`) and a verdict-cache hit (`reused`) re-state a measurement another row
724
+ // already bought, so each resolves to that row's origin — its receipt's invocation when it carries
725
+ // one, else the attempt (replay) or the newest observation on the same subject (cache) — and one
726
+ // observation counts once however many copies the ledger holds, across resume. Only a fresh
727
+ // execution mints a new origin.
716
728
  export function identicalGateFailures(events, taskId, gate, normalized) {
717
- let n = 0;
718
- for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval is no new engagement
729
+ const counted = new Set();
730
+ const byAttempt = new Map();
731
+ const bySubject = new Map();
732
+ effectiveEvents(events).forEach((e, i) => {
719
733
  if (e.taskId !== taskId)
720
- continue;
721
- if (e.event === "task-approved")
722
- n = 0;
723
- else if (e.event === "gate-result" && e.data.gate === gate && e.data.pass === false
724
- && e.data.replayMeasurement !== true
725
- && typeof e.data.details === "string"
734
+ return;
735
+ if (e.event === "task-approved") {
736
+ counted.clear();
737
+ return;
738
+ }
739
+ if (e.event !== "gate-result" || e.data.gate !== gate)
740
+ return;
741
+ const commit = typeof e.data.commit === "string" ? e.data.commit : undefined;
742
+ const origin = typeof e.data.replayedFromAttempt === "number"
743
+ ? byAttempt.get(e.data.replayedFromAttempt) ?? receiptOrigin(e.data) ?? `attempt:${e.data.replayedFromAttempt}`
744
+ : e.data.reused === true
745
+ ? receiptOrigin(e.data) ?? (commit ? bySubject.get(commit) : undefined) ?? `reused:${commit ?? i}`
746
+ : receiptOrigin(e.data) ?? `row:${i}`;
747
+ if (typeof e.data.attempt === "number")
748
+ byAttempt.set(e.data.attempt, origin);
749
+ if (commit)
750
+ bySubject.set(commit, origin);
751
+ if (e.data.pass === false && e.data.replayMeasurement !== true && typeof e.data.details === "string"
726
752
  && normalizeGateFailure(e.data.details) === normalized)
727
- n++;
728
- }
729
- return n;
730
- }
753
+ counted.add(origin);
754
+ });
755
+ return counted.size;
756
+ }
757
+ // OBS-1072 add.2: the controls a terminal would consume — OSC strings (ESC ] … BEL/ST), CSI sequences
758
+ // (SGR included), other ESC sequences and stray C0 bytes but tab and newline. Applied only to the
759
+ // human-facing excerpts a worker brief or a scope hint reads; the journal row, the receipt bytes and
760
+ // the failure fingerprint keep the raw text.
761
+ const TERMINAL_CONTROL_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]?|[\x00-\x08\x0b-\x1f\x7f]/g;
762
+ export const readableExcerpt = (text) => text.replace(TERMINAL_CONTROL_RE, "");
731
763
  export function repairReachSinceApproval(events, taskId) {
732
764
  const repairs = [];
733
765
  for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
@@ -899,7 +931,7 @@ export function journaledFailureBrief(events, taskId) {
899
931
  rows = [];
900
932
  else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
901
933
  && typeof e.data.details === "string")
902
- rows.push(`${e.data.gate}: ${e.data.details}`);
934
+ rows.push(`${e.data.gate}: ${readableExcerpt(e.data.details)}`);
903
935
  else if (e.event === "delivery-readiness-failed" && typeof e.data.transcript === "string") {
904
936
  rows.push(`dispatch: delivery readiness failed after ${e.data.waitedMs}ms; pane transcript:\n${e.data.transcript}`);
905
937
  }
@@ -939,6 +971,17 @@ export function observedReviewFingerprints(finding) {
939
971
  return [...new Set([finding.fingerprint, ...observed])];
940
972
  }
941
973
  const reviewNoteIdentity = (note) => note.replace(LINE_REF_RE, "").replace(/\s+/g, " ").trim();
974
+ /** OBS-1195: the anchor row a verdict's recorded settled comment spells, or undefined if malformed. */
975
+ function settledAnchorRow(anchor) {
976
+ if (!anchor || typeof anchor !== "object")
977
+ return undefined;
978
+ const { path, line, body, disposition } = anchor;
979
+ if (typeof path !== "string" || !Number.isInteger(line) || typeof body !== "string")
980
+ return undefined;
981
+ if (disposition !== "deferred" && disposition !== "resolved")
982
+ return undefined;
983
+ return structuredFindings("review", `- ${path}:${line} — ${body}`).find((finding) => finding.class === "review:anchored");
984
+ }
942
985
  function distinctReviewFinding(row) {
943
986
  const identity = JSON.stringify([reviewNoteIdentity(row.note), row.codeIdentity ?? null]);
944
987
  const symbol = `${row.symbol}#${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
@@ -1042,6 +1085,23 @@ export function carryReviewFindings(priors, rows) {
1042
1085
  * after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
1043
1086
  * row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
1044
1087
  */
1088
+ /** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
1089
+ * never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
1090
+ export function priorJudgments(events, taskId) {
1091
+ const out = [];
1092
+ for (const e of events) {
1093
+ const j = e.taskId === taskId && e.event === "gate-result" && e.data.gate === "acceptance" ? e.data.judgment : undefined;
1094
+ if (!j || typeof j !== "object")
1095
+ continue;
1096
+ const { commit, judge, criteria } = j;
1097
+ if (typeof commit !== "string" || !commit || !Array.isArray(criteria))
1098
+ continue;
1099
+ const rows = criteria.filter((c) => !!c && typeof c.id === "string" && typeof c.key === "string" && typeof c.met === "boolean"
1100
+ && Array.isArray(c.paths) && c.paths.every((p) => typeof p === "string"));
1101
+ out.unshift({ commit, ...(typeof judge === "string" ? { judge } : {}), criteria: rows });
1102
+ }
1103
+ return out;
1104
+ }
1045
1105
  export function outstandingReviewFindings(events, taskId) {
1046
1106
  let open = [];
1047
1107
  for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
@@ -1059,6 +1119,9 @@ export function outstandingReviewFindings(events, taskId) {
1059
1119
  continue;
1060
1120
  // A failed review can resolve one chain while re-raising another. Only observed, uniquely
1061
1121
  // matched spellings retire a chain; skipped/no-verdict rows were excluded above.
1122
+ // OBS-1195: every parent chain this verdict resolved or explicitly deferred; an anchor bound to one
1123
+ // retires whether it was carried in or restated by this same verdict.
1124
+ const retiredParents = new Set();
1062
1125
  if (Array.isArray(e.data.resolved)) {
1063
1126
  const resolved = e.data.resolved;
1064
1127
  const settled = new Set(resolved.flatMap((id) => {
@@ -1066,8 +1129,45 @@ export function outstandingReviewFindings(events, taskId) {
1066
1129
  && observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)));
1067
1130
  return matches.length === 1 ? matches : [];
1068
1131
  }));
1132
+ // OBS-1195: an anchor explicitly bound to a settled chain retires with it; unbound ones stay.
1133
+ for (const id of [...settled].flatMap(observedReviewFingerprints))
1134
+ retiredParents.add(id);
1069
1135
  open = open.filter((finding) => !settled.has(finding));
1070
1136
  }
1137
+ // OBS-1195: a failed verdict that restates a bound anchor's parent ONLY as a deferral (its deferred
1138
+ // entry echoes the prior's id and no material entry claims it) has explicitly deferred that parent,
1139
+ // so the anchor retires. The parent chain itself keeps whatever the closure lists said of it.
1140
+ // A material row's `reraisedFrom` is this verdict's claim only when its own reraised list names that
1141
+ // id; a link an earlier verdict drew (carried metadata) is lineage and claims nothing here. Without a
1142
+ // reraised list to check against, every link counts as a claim — fail closed.
1143
+ const rows = findingRows(e, "review");
1144
+ const reraisedHere = Array.isArray(e.data.reraised) ? e.data.reraised.filter((id) => typeof id === "string") : undefined;
1145
+ const claimedByMaterial = new Set(rows.filter((row) => !isDeferredFinding(row) && row.reraisedFrom
1146
+ && (reraisedHere === undefined || reraisedHere.some((id) => reviewFingerprintMatches(id, row.reraisedFrom)))).map((row) => row.reraisedFrom));
1147
+ const deferredParents = rows.filter((row) => isDeferredFinding(row) && row.reraisedFrom && !claimedByMaterial.has(row.reraisedFrom))
1148
+ .map((row) => row.reraisedFrom);
1149
+ for (const finding of open) {
1150
+ if (finding.class !== "review:material" || !observedReviewFingerprints(finding).some((fp) => deferredParents.includes(fp)))
1151
+ continue;
1152
+ for (const id of observedReviewFingerprints(finding))
1153
+ retiredParents.add(id);
1154
+ }
1155
+ const boundToRetired = (finding) => finding.boundTo !== undefined && retiredParents.has(finding.boundTo);
1156
+ open = open.filter((finding) => !boundToRetired(finding));
1157
+ // OBS-1195: a later verdict that re-anchors a carried anchor to a deferred entry or a resolved prior
1158
+ // settles that anchor explicitly. Only its own identity (path, symbol and note), uniquely matched,
1159
+ // retires it; a bare path coincidence binds nothing.
1160
+ if (Array.isArray(e.data.settledAnchors)) {
1161
+ const retired = new Set(e.data.settledAnchors.flatMap((anchor) => {
1162
+ const row = settledAnchorRow(anchor);
1163
+ // Identity, not fingerprint: a collision-disambiguated anchor carries a `symbol#hash` spelling.
1164
+ const matches = row ? open.filter((finding) => finding.class === "review:anchored" && finding.path === row.path
1165
+ && (finding.symbol === row.symbol || finding.symbol.startsWith(`${row.symbol}#`))
1166
+ && reviewNoteIdentity(finding.note) === reviewNoteIdentity(row.note)) : [];
1167
+ return matches.length === 1 ? matches : [];
1168
+ }));
1169
+ open = open.filter((finding) => !retired.has(finding));
1170
+ }
1071
1171
  if (e.data.pass !== false) {
1072
1172
  // a later review PASSED on this task: every finding it BLOCKED on is settled …
1073
1173
  open = open.filter(isDeferredFinding);
@@ -1077,11 +1177,39 @@ export function outstandingReviewFindings(events, taskId) {
1077
1177
  // the very next round — the same silent drop by a different door.
1078
1178
  open = carryReviewFindings(open, findingRows(e, "review").filter(isDeferredFinding));
1079
1179
  }
1080
- else
1081
- open = carryReviewFindings(open, findingRows(e, "review"));
1180
+ else {
1181
+ // OBS-1195: a repeated comment bound to a parent this verdict resolved or deferred is not re-seated.
1182
+ open = carryReviewFindings(open, rows).filter((finding) => !boundToRetired(finding));
1183
+ }
1082
1184
  }
1083
1185
  return open;
1084
1186
  }
1187
+ /**
1188
+ * OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
1189
+ * re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
1190
+ * count, so an unrelated finding sharing a path never lengthens it.
1191
+ */
1192
+ export function reraisedReviewChains(events, taskId) {
1193
+ let verdicts = [];
1194
+ for (const e of effectiveEvents(events)) {
1195
+ if (e.taskId !== taskId)
1196
+ continue;
1197
+ if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
1198
+ verdicts = [];
1199
+ if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
1200
+ continue;
1201
+ if (e.data.unparseable === true || e.data.noVerdict === true || e.data.cause !== undefined)
1202
+ continue;
1203
+ verdicts = e.data.pass === false ? [...verdicts, e] : [];
1204
+ }
1205
+ return outstandingReviewFindings(events, taskId)
1206
+ .filter((finding) => finding.class === "review:material")
1207
+ .map((finding) => ({
1208
+ finding,
1209
+ reraises: verdicts.filter((e) => Array.isArray(e.data.reraised) && e.data.reraised
1210
+ .some((id) => observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)))).length,
1211
+ }));
1212
+ }
1085
1213
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
1086
1214
  export function pendingRepairFindings(events, taskId) {
1087
1215
  const e = decisionForNextDispatch(events, taskId, "repair-attempt");
@@ -33,7 +33,20 @@ export interface RepositoryLeaseOptions {
33
33
  signal?: AbortSignal;
34
34
  /** Called once per distinct holder the waiter is queued behind. */
35
35
  onWait?: (holder: RepositoryLeaseHolder) => void;
36
+ /** The token a descendant inherited; defaults to REPOSITORY_LEASE_TOKEN_ENV. */
37
+ inherited?: string;
36
38
  }
39
+ /** OBS-880/1071: the file lease's token, exported to the holder's descendants for the span it is held
40
+ * so a nested runner (a gate's suite under `tickmarkr verify`, a mutation child inside a leased suite)
41
+ * reenters instead of waiting on its own ancestor. Deliberately NOT COMMAND_LEASE_TOKEN_ENV: the
42
+ * installed daemon clears and rewrites that in-process token, and a command-lease token never names
43
+ * this file. Environment text alone proves nothing: see `reentrantHolder`. */
44
+ export declare const REPOSITORY_LEASE_TOKEN_ENV = "TICKMARKR_REPOSITORY_LEASE_TOKEN";
45
+ /** Reentry is identity-bound: the inherited token must name the record at THIS repository's lease path
46
+ * right now, its holder must be alive, and that holder must be a strict ancestor of this process. A
47
+ * forged, stale (released or superseded), foreign-repository or command-lease token matches no record;
48
+ * an in-process sibling is not a descendant — each of those waits like any other runner. */
49
+ export declare function reentrantHolder(path: string, inherited: string | undefined): Promise<RepositoryLeaseHolder | undefined>;
37
50
  export declare function repositoryLeasePath(cwd: string): Promise<string>;
38
51
  /** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
39
52
  * number straight to the next file, so a stale observation of a dead or corrupt reservation matched
package/dist/run/lease.js CHANGED
@@ -91,6 +91,38 @@ export class CommandLeases {
91
91
  }
92
92
  }
93
93
  }
94
+ /** OBS-880/1071: the file lease's token, exported to the holder's descendants for the span it is held
95
+ * so a nested runner (a gate's suite under `tickmarkr verify`, a mutation child inside a leased suite)
96
+ * reenters instead of waiting on its own ancestor. Deliberately NOT COMMAND_LEASE_TOKEN_ENV: the
97
+ * installed daemon clears and rewrites that in-process token, and a command-lease token never names
98
+ * this file. Environment text alone proves nothing: see `reentrantHolder`. */
99
+ export const REPOSITORY_LEASE_TOKEN_ENV = "TICKMARKR_REPOSITORY_LEASE_TOKEN";
100
+ /** The strict ancestors of this process, from one `ps` snapshot; empty when unreadable (fail closed). */
101
+ const ancestorPids = () => new Promise((ok) => execFile("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }, (error, stdout) => {
102
+ const parent = new Map();
103
+ if (!error)
104
+ for (const line of stdout.split("\n")) {
105
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
106
+ if (Number.isInteger(pid) && Number.isInteger(ppid))
107
+ parent.set(pid, ppid);
108
+ }
109
+ const seen = new Set();
110
+ for (let pid = error ? 0 : process.ppid; pid > 1 && !seen.has(pid); pid = parent.get(pid) ?? 0)
111
+ seen.add(pid);
112
+ ok(seen);
113
+ }));
114
+ /** Reentry is identity-bound: the inherited token must name the record at THIS repository's lease path
115
+ * right now, its holder must be alive, and that holder must be a strict ancestor of this process. A
116
+ * forged, stale (released or superseded), foreign-repository or command-lease token matches no record;
117
+ * an in-process sibling is not a descendant — each of those waits like any other runner. */
118
+ export async function reentrantHolder(path, inherited) {
119
+ if (!inherited)
120
+ return undefined;
121
+ const holder = readHolder(path);
122
+ if (holder?.token !== inherited || holder.pid === process.pid || !alive(holder.pid))
123
+ return undefined;
124
+ return (await ancestorPids()).has(holder.pid) ? holder : undefined;
125
+ }
94
126
  const LEASE_FILE = "tickmarkr-runner.lease";
95
127
  export async function repositoryLeasePath(cwd) {
96
128
  const commonDir = await new Promise((ok, fail) => execFile("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8" }, (error, stdout) => error ? fail(error) : ok(stdout.trim())));
@@ -283,6 +315,9 @@ const inspectAndReserve = (path, mine) => {
283
315
  };
284
316
  export async function withRepositoryLease(cwd, run, opts = {}) {
285
317
  const path = await repositoryLeasePath(cwd);
318
+ // A descendant of the live holder runs inside its ancestor's reservation and releases nothing.
319
+ if (await reentrantHolder(path, opts.inherited ?? process.env[REPOSITORY_LEASE_TOKEN_ENV]))
320
+ return run();
286
321
  const pollMs = opts.pollMs ?? 1_000;
287
322
  const mine = { pid: process.pid, cwd, at: Date.now(), token: randomUUID() };
288
323
  let waitingOn;
@@ -297,10 +332,20 @@ export async function withRepositoryLease(cwd, run, opts = {}) {
297
332
  }
298
333
  await new Promise((wake) => setTimeout(wake, pollMs));
299
334
  }
335
+ // Exported before run() so every descendant forked or spawned during the hold can reenter; restored
336
+ // at release unless another reservation in this process has since exported its own.
337
+ const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
338
+ process.env[REPOSITORY_LEASE_TOKEN_ENV] = mine.token;
300
339
  try {
301
340
  return await run();
302
341
  }
303
342
  finally {
343
+ if (process.env[REPOSITORY_LEASE_TOKEN_ENV] === mine.token) {
344
+ if (exported === undefined)
345
+ delete process.env[REPOSITORY_LEASE_TOKEN_ENV];
346
+ else
347
+ process.env[REPOSITORY_LEASE_TOKEN_ENV] = exported;
348
+ }
304
349
  // Release is serialized with reclamation/acquisition and removes only this generation.
305
350
  await withMutationLock(path, pollMs, undefined, () => {
306
351
  if (readHolder(path)?.token === mine.token)
@@ -53,6 +53,15 @@ export declare const EvidenceArtifactSchema: z.ZodObject<{
53
53
  droppedBytes: z.ZodNumber;
54
54
  truncated: z.ZodBoolean;
55
55
  }, z.core.$strict>;
56
+ export declare const RedactionSchema: z.ZodObject<{
57
+ material: z.ZodBoolean;
58
+ counts: z.ZodOptional<z.ZodObject<{
59
+ token: z.ZodNumber;
60
+ assignment: z.ZodNumber;
61
+ secretEnv: z.ZodNumber;
62
+ benignEnv: z.ZodNumber;
63
+ }, z.core.$strict>>;
64
+ }, z.core.$strict>;
56
65
  export declare const GateEvidenceReceiptSchema: z.ZodObject<{
57
66
  invocationId: z.ZodString;
58
67
  nonce: z.ZodOptional<z.ZodString>;
@@ -85,6 +94,12 @@ export declare const GateEvidenceReceiptSchema: z.ZodObject<{
85
94
  }>;
86
95
  redaction: z.ZodObject<{
87
96
  material: z.ZodBoolean;
97
+ counts: z.ZodOptional<z.ZodObject<{
98
+ token: z.ZodNumber;
99
+ assignment: z.ZodNumber;
100
+ secretEnv: z.ZodNumber;
101
+ benignEnv: z.ZodNumber;
102
+ }, z.core.$strict>>;
88
103
  }, z.core.$strict>;
89
104
  stdout: z.ZodObject<{
90
105
  path: z.ZodString;
@@ -47,6 +47,16 @@ export const EvidenceArtifactSchema = z.object({
47
47
  if (ref.availability === "available" && ref.sha256 === null)
48
48
  ctx.addIssue({ code: "custom", message: "available bytes require a hash" });
49
49
  });
50
+ // OBS-1139: one count per original span, never a value. A legacy receipt carries `material` alone and
51
+ // keeps it as recorded; a counted one must agree — benign HOME/TMPDIR substitutions are never material.
52
+ const RedactionCountsSchema = z.object({
53
+ token: z.number().int().nonnegative(),
54
+ assignment: z.number().int().nonnegative(),
55
+ secretEnv: z.number().int().nonnegative(),
56
+ benignEnv: z.number().int().nonnegative(),
57
+ }).strict();
58
+ export const RedactionSchema = z.object({ material: z.boolean(), counts: RedactionCountsSchema.optional() }).strict()
59
+ .refine(r => !r.counts || r.material === (r.counts.token + r.counts.assignment + r.counts.secretEnv > 0), { message: "material must agree with the token, assignment and secret-environment counts" });
50
60
  export const GateEvidenceReceiptSchema = z.object({
51
61
  invocationId: NonEmptyStringSchema,
52
62
  nonce: NonEmptyStringSchema.optional(),
@@ -64,7 +74,7 @@ export const GateEvidenceReceiptSchema = z.object({
64
74
  timedOut: z.boolean().nullable(),
65
75
  }).strict(),
66
76
  availability: z.enum(EVIDENCE_AVAILABILITIES),
67
- redaction: z.object({ material: z.boolean() }).strict(),
77
+ redaction: RedactionSchema,
68
78
  stdout: EvidenceArtifactSchema,
69
79
  stderr: EvidenceArtifactSchema,
70
80
  }).strict();
@@ -1,6 +1,18 @@
1
1
  import { type GateEvidenceReceipt } from "./protocol.js";
2
2
  export declare const RECEIPT_UNAVAILABLE_REASONS: readonly ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
3
3
  export type ReceiptUnavailableReason = (typeof RECEIPT_UNAVAILABLE_REASONS)[number];
4
+ /** OBS-1140: at most this many newest eviction tombstones persist; an older eviction reads as missing. */
5
+ export declare const EVICTION_TOMBSTONE_LIMIT = 256;
6
+ /** Beside gate-evidence/, never inside it: tombstones are metadata, not retained artifact bytes. */
7
+ export declare const EVICTION_TOMBSTONES_FILE = "evidence-evictions.json";
8
+ /** Bound to the evicted bytes themselves, so a replacement artifact at the same path never inherits it. */
9
+ export interface EvictionTombstone {
10
+ readonly path: string;
11
+ readonly sha256: string;
12
+ readonly retainedBytes: number;
13
+ }
14
+ /** Newest last, at most the limit; anything malformed is no tombstone at all (fail closed to missing). */
15
+ export declare function parseEvictionTombstones(bytes: Buffer): EvictionTombstone[];
4
16
  export type ReceiptResolution = {
5
17
  readonly ok: true;
6
18
  readonly path: string;
@@ -12,6 +24,16 @@ export type ReceiptResolution = {
12
24
  };
13
25
  /** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
14
26
  export declare function resolveReceipt(reference: unknown, runRoot: string): ReceiptResolution;
27
+ export type ReceiptRedaction = {
28
+ readonly material: boolean;
29
+ readonly counts: GateEvidenceReceipt["redaction"]["counts"] | null;
30
+ };
31
+ /**
32
+ * OBS-1139: what a receipt says was withheld. A counted receipt reports its per-span counts; a legacy
33
+ * one keeps its recorded `material` with no counts; anything unparseable is material (fail closed).
34
+ */
35
+ export declare function resolveReceiptRedaction(receipt: unknown): ReceiptRedaction;
36
+ export declare function formatReceiptRedaction(r: ReceiptRedaction): string;
15
37
  /** Both artifacts of a receipt, stdout first, each resolved independently. */
16
38
  export declare function resolveReceiptArtifacts(receipt: GateEvidenceReceipt, runRoot: string): ReceiptResolution[];
17
39
  /** One line per artifact for a human surface: the reference, then its verified availability. */
@@ -1,13 +1,28 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
- import { EvidenceArtifactSchema } from "./protocol.js";
4
+ import { EvidenceArtifactSchema, RedactionSchema } from "./protocol.js";
5
5
  // OBS-1101 retrieval (D-174): the one production reader of an execution-evidence receipt. It answers
6
6
  // from the bytes on disk under the run root the receipt was minted in and nothing else — no
7
7
  // re-execution, no substitute chosen by name or timestamp, no symlink followed anywhere: not in the
8
8
  // reference, not at the root, not above it. The caller hands a canonical root (its own trust anchor);
9
9
  // a root reached through any link is refused outright.
10
10
  export const RECEIPT_UNAVAILABLE_REASONS = ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
11
+ /** OBS-1140: at most this many newest eviction tombstones persist; an older eviction reads as missing. */
12
+ export const EVICTION_TOMBSTONE_LIMIT = 256;
13
+ /** Beside gate-evidence/, never inside it: tombstones are metadata, not retained artifact bytes. */
14
+ export const EVICTION_TOMBSTONES_FILE = "evidence-evictions.json";
15
+ /** Newest last, at most the limit; anything malformed is no tombstone at all (fail closed to missing). */
16
+ export function parseEvictionTombstones(bytes) {
17
+ try {
18
+ const raw = JSON.parse(bytes.toString("utf8"));
19
+ return Array.isArray(raw) ? raw.filter((t) => typeof t?.path === "string"
20
+ && typeof t.sha256 === "string" && Number.isInteger(t.retainedBytes)).slice(-EVICTION_TOMBSTONE_LIMIT) : [];
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
11
26
  /** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
12
27
  export function resolveReceipt(reference, runRoot) {
13
28
  const parsed = EvidenceArtifactSchema.safeParse(reference);
@@ -27,6 +42,8 @@ export function resolveReceipt(reference, runRoot) {
27
42
  if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
28
43
  return { ok: false, path: ref.path, reason: "outside-root" };
29
44
  const read = readUnderRoot(root, rel.split(sep));
45
+ if (read === "missing" && evictedByQuota(root, ref))
46
+ return { ok: false, path: ref.path, reason: "expired" };
30
47
  if (typeof read === "string")
31
48
  return { ok: false, path: ref.path, reason: read };
32
49
  const sha256 = createHash("sha256").update(read).digest("hex");
@@ -34,6 +51,28 @@ export function resolveReceipt(reference, runRoot) {
34
51
  return { ok: false, path: ref.path, reason: "hash-mismatch" };
35
52
  return { ok: true, path: ref.path, sha256 };
36
53
  }
54
+ /** A deletion is explained only by a tombstone naming this exact path, hash and length (OBS-1140). */
55
+ function evictedByQuota(root, ref) {
56
+ const bytes = readUnderRoot(root, [EVICTION_TOMBSTONES_FILE]);
57
+ return typeof bytes !== "string" && parseEvictionTombstones(bytes)
58
+ .some(t => t.path === ref.path && t.sha256 === ref.sha256 && t.retainedBytes === ref.retainedBytes);
59
+ }
60
+ /**
61
+ * OBS-1139: what a receipt says was withheld. A counted receipt reports its per-span counts; a legacy
62
+ * one keeps its recorded `material` with no counts; anything unparseable is material (fail closed).
63
+ */
64
+ export function resolveReceiptRedaction(receipt) {
65
+ const parsed = RedactionSchema.safeParse(receipt?.redaction);
66
+ if (!parsed.success)
67
+ return { material: true, counts: null };
68
+ return { material: parsed.data.material, counts: parsed.data.counts ?? null };
69
+ }
70
+ export function formatReceiptRedaction(r) {
71
+ const head = r.material ? "material" : "nonmaterial";
72
+ return r.counts
73
+ ? `redaction ${head} token=${r.counts.token} assignment=${r.counts.assignment} secret-env=${r.counts.secretEnv} benign-env=${r.counts.benignEnv}`
74
+ : `redaction ${head} (uncounted)`;
75
+ }
37
76
  const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
38
77
  /** Every ancestor of the root, root first excluded — "/" itself cannot be a link. */
39
78
  function ancestors(root) {