tickmarkr 2.6.2 → 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.
Files changed (79) hide show
  1. package/README.md +12 -2
  2. package/dist/adapters/codex.d.ts +2 -1
  3. package/dist/adapters/codex.js +26 -5
  4. package/dist/adapters/model-lints.d.ts +1 -0
  5. package/dist/adapters/model-lints.js +48 -1
  6. package/dist/adapters/model-windows.d.ts +2 -1
  7. package/dist/adapters/model-windows.js +18 -7
  8. package/dist/adapters/types.d.ts +6 -0
  9. package/dist/cli/commands/approve.d.ts +6 -3
  10. package/dist/cli/commands/approve.js +43 -6
  11. package/dist/cli/commands/doctor.d.ts +8 -2
  12. package/dist/cli/commands/doctor.js +6 -1
  13. package/dist/cli/commands/fleet.js +264 -59
  14. package/dist/cli/commands/init.js +5 -3
  15. package/dist/cli/commands/plan.js +13 -8
  16. package/dist/cli/commands/report.d.ts +40 -2
  17. package/dist/cli/commands/report.js +278 -11
  18. package/dist/cli/commands/resume.js +2 -4
  19. package/dist/cli/commands/run.d.ts +11 -0
  20. package/dist/cli/commands/run.js +33 -5
  21. package/dist/cli/commands/status.js +49 -4
  22. package/dist/cli/commands/verify.js +332 -121
  23. package/dist/compile/native.js +137 -0
  24. package/dist/config/config.d.ts +40 -9
  25. package/dist/config/config.js +133 -15
  26. package/dist/config/fleet-overlay.d.ts +13 -3
  27. package/dist/config/fleet-overlay.js +12 -8
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +9 -1
  31. package/dist/drivers/orca.js +29 -7
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/gates/acceptance.d.ts +7 -0
  35. package/dist/gates/acceptance.js +27 -5
  36. package/dist/gates/baseline.d.ts +20 -1
  37. package/dist/gates/baseline.js +100 -20
  38. package/dist/gates/cache.d.ts +8 -0
  39. package/dist/gates/cache.js +12 -2
  40. package/dist/gates/llm.d.ts +6 -0
  41. package/dist/gates/llm.js +27 -8
  42. package/dist/gates/review.d.ts +6 -1
  43. package/dist/gates/review.js +122 -32
  44. package/dist/gates/run-gates.d.ts +54 -3
  45. package/dist/gates/run-gates.js +331 -45
  46. package/dist/gates/test-manifest.d.ts +42 -0
  47. package/dist/gates/test-manifest.js +69 -10
  48. package/dist/route/router.d.ts +23 -1
  49. package/dist/route/router.js +54 -16
  50. package/dist/run/consult.d.ts +3 -1
  51. package/dist/run/consult.js +4 -2
  52. package/dist/run/daemon.d.ts +2 -1
  53. package/dist/run/daemon.js +349 -79
  54. package/dist/run/interactive-seed.d.ts +4 -0
  55. package/dist/run/interactive-seed.js +35 -9
  56. package/dist/run/journal.d.ts +123 -1
  57. package/dist/run/journal.js +480 -17
  58. package/dist/run/lease.d.ts +13 -0
  59. package/dist/run/lease.js +45 -0
  60. package/dist/run/protocol.d.ts +15 -0
  61. package/dist/run/protocol.js +11 -1
  62. package/dist/run/receipt-resolver.d.ts +22 -0
  63. package/dist/run/receipt-resolver.js +40 -1
  64. package/dist/run/repair-selection.d.ts +11 -1
  65. package/dist/run/repair-selection.js +17 -9
  66. package/dist/run/wall-budget.d.ts +48 -0
  67. package/dist/run/wall-budget.js +280 -0
  68. package/dist/tui/cockpit/live-store.d.ts +6 -0
  69. package/dist/tui/cockpit/live-store.js +36 -11
  70. package/dist/tui/cockpit/run-cockpit.js +2 -2
  71. package/dist/tui/cockpit/run-view.d.ts +2 -1
  72. package/dist/tui/cockpit/run-view.js +13 -9
  73. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  74. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  75. package/package.json +2 -1
  76. package/schema/config.schema.json +8 -1
  77. package/skills/tickmarkr-auto/SKILL.md +2 -2
  78. package/skills/tickmarkr-loop/SKILL.md +17 -5
  79. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -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) {
@@ -713,21 +715,53 @@ export function normalizeGateFailure(details) {
713
715
  // operator approval is a new engagement, and nothing else resets the count. Resume re-measurements
714
716
  // are excluded because the interrupted attempt already bought the result they confirm.
715
717
  export const GATE_FINGERPRINT_CAP = 2;
718
+ /** The invocation a gate row's receipt names — the identity of the execution that observed it. */
719
+ export const receiptOrigin = (data) => {
720
+ const receipt = data.evidenceReceipt ?? (Array.isArray(data.evidenceReceipts) ? data.evidenceReceipts[0] : undefined);
721
+ const id = receipt && typeof receipt === "object" ? receipt.invocationId : undefined;
722
+ return typeof id === "string" && id ? `invocation:${id}` : undefined;
723
+ };
724
+ // OBS-1106 residual: occurrences are independent OBSERVATIONS, not rows. A journal replay
725
+ // (`replayedFromAttempt`) and a verdict-cache hit (`reused`) re-state a measurement another row
726
+ // already bought, so each resolves to that row's origin — its receipt's invocation when it carries
727
+ // one, else the attempt (replay) or the newest observation on the same subject (cache) — and one
728
+ // observation counts once however many copies the ledger holds, across resume. Only a fresh
729
+ // execution mints a new origin.
716
730
  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
731
+ const counted = new Set();
732
+ const byAttempt = new Map();
733
+ const bySubject = new Map();
734
+ effectiveEvents(events).forEach((e, i) => {
719
735
  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"
736
+ return;
737
+ if (e.event === "task-approved") {
738
+ counted.clear();
739
+ return;
740
+ }
741
+ if (e.event !== "gate-result" || e.data.gate !== gate)
742
+ return;
743
+ const commit = typeof e.data.commit === "string" ? e.data.commit : undefined;
744
+ const origin = typeof e.data.replayedFromAttempt === "number"
745
+ ? byAttempt.get(e.data.replayedFromAttempt) ?? receiptOrigin(e.data) ?? `attempt:${e.data.replayedFromAttempt}`
746
+ : e.data.reused === true
747
+ ? receiptOrigin(e.data) ?? (commit ? bySubject.get(commit) : undefined) ?? `reused:${commit ?? i}`
748
+ : receiptOrigin(e.data) ?? `row:${i}`;
749
+ if (typeof e.data.attempt === "number")
750
+ byAttempt.set(e.data.attempt, origin);
751
+ if (commit)
752
+ bySubject.set(commit, origin);
753
+ if (e.data.pass === false && e.data.replayMeasurement !== true && typeof e.data.details === "string"
726
754
  && normalizeGateFailure(e.data.details) === normalized)
727
- n++;
728
- }
729
- return n;
730
- }
755
+ counted.add(origin);
756
+ });
757
+ return counted.size;
758
+ }
759
+ // OBS-1072 add.2: the controls a terminal would consume — OSC strings (ESC ] … BEL/ST), CSI sequences
760
+ // (SGR included), other ESC sequences and stray C0 bytes but tab and newline. Applied only to the
761
+ // human-facing excerpts a worker brief or a scope hint reads; the journal row, the receipt bytes and
762
+ // the failure fingerprint keep the raw text.
763
+ const TERMINAL_CONTROL_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]?|[\x00-\x08\x0b-\x1f\x7f]/g;
764
+ export const readableExcerpt = (text) => text.replace(TERMINAL_CONTROL_RE, "");
731
765
  export function repairReachSinceApproval(events, taskId) {
732
766
  const repairs = [];
733
767
  for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
@@ -899,7 +933,7 @@ export function journaledFailureBrief(events, taskId) {
899
933
  rows = [];
900
934
  else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
901
935
  && typeof e.data.details === "string")
902
- rows.push(`${e.data.gate}: ${e.data.details}`);
936
+ rows.push(`${e.data.gate}: ${readableExcerpt(e.data.details)}`);
903
937
  else if (e.event === "delivery-readiness-failed" && typeof e.data.transcript === "string") {
904
938
  rows.push(`dispatch: delivery readiness failed after ${e.data.waitedMs}ms; pane transcript:\n${e.data.transcript}`);
905
939
  }
@@ -939,6 +973,17 @@ export function observedReviewFingerprints(finding) {
939
973
  return [...new Set([finding.fingerprint, ...observed])];
940
974
  }
941
975
  const reviewNoteIdentity = (note) => note.replace(LINE_REF_RE, "").replace(/\s+/g, " ").trim();
976
+ /** OBS-1195: the anchor row a verdict's recorded settled comment spells, or undefined if malformed. */
977
+ function settledAnchorRow(anchor) {
978
+ if (!anchor || typeof anchor !== "object")
979
+ return undefined;
980
+ const { path, line, body, disposition } = anchor;
981
+ if (typeof path !== "string" || !Number.isInteger(line) || typeof body !== "string")
982
+ return undefined;
983
+ if (disposition !== "deferred" && disposition !== "resolved")
984
+ return undefined;
985
+ return structuredFindings("review", `- ${path}:${line} — ${body}`).find((finding) => finding.class === "review:anchored");
986
+ }
942
987
  function distinctReviewFinding(row) {
943
988
  const identity = JSON.stringify([reviewNoteIdentity(row.note), row.codeIdentity ?? null]);
944
989
  const symbol = `${row.symbol}#${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
@@ -1042,6 +1087,23 @@ export function carryReviewFindings(priors, rows) {
1042
1087
  * after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
1043
1088
  * row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
1044
1089
  */
1090
+ /** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
1091
+ * never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
1092
+ export function priorJudgments(events, taskId) {
1093
+ const out = [];
1094
+ for (const e of events) {
1095
+ const j = e.taskId === taskId && e.event === "gate-result" && e.data.gate === "acceptance" ? e.data.judgment : undefined;
1096
+ if (!j || typeof j !== "object")
1097
+ continue;
1098
+ const { commit, judge, criteria } = j;
1099
+ if (typeof commit !== "string" || !commit || !Array.isArray(criteria))
1100
+ continue;
1101
+ const rows = criteria.filter((c) => !!c && typeof c.id === "string" && typeof c.key === "string" && typeof c.met === "boolean"
1102
+ && Array.isArray(c.paths) && c.paths.every((p) => typeof p === "string"));
1103
+ out.unshift({ commit, ...(typeof judge === "string" ? { judge } : {}), criteria: rows });
1104
+ }
1105
+ return out;
1106
+ }
1045
1107
  export function outstandingReviewFindings(events, taskId) {
1046
1108
  let open = [];
1047
1109
  for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
@@ -1059,6 +1121,9 @@ export function outstandingReviewFindings(events, taskId) {
1059
1121
  continue;
1060
1122
  // A failed review can resolve one chain while re-raising another. Only observed, uniquely
1061
1123
  // matched spellings retire a chain; skipped/no-verdict rows were excluded above.
1124
+ // OBS-1195: every parent chain this verdict resolved or explicitly deferred; an anchor bound to one
1125
+ // retires whether it was carried in or restated by this same verdict.
1126
+ const retiredParents = new Set();
1062
1127
  if (Array.isArray(e.data.resolved)) {
1063
1128
  const resolved = e.data.resolved;
1064
1129
  const settled = new Set(resolved.flatMap((id) => {
@@ -1066,8 +1131,45 @@ export function outstandingReviewFindings(events, taskId) {
1066
1131
  && observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)));
1067
1132
  return matches.length === 1 ? matches : [];
1068
1133
  }));
1134
+ // OBS-1195: an anchor explicitly bound to a settled chain retires with it; unbound ones stay.
1135
+ for (const id of [...settled].flatMap(observedReviewFingerprints))
1136
+ retiredParents.add(id);
1069
1137
  open = open.filter((finding) => !settled.has(finding));
1070
1138
  }
1139
+ // OBS-1195: a failed verdict that restates a bound anchor's parent ONLY as a deferral (its deferred
1140
+ // entry echoes the prior's id and no material entry claims it) has explicitly deferred that parent,
1141
+ // so the anchor retires. The parent chain itself keeps whatever the closure lists said of it.
1142
+ // A material row's `reraisedFrom` is this verdict's claim only when its own reraised list names that
1143
+ // id; a link an earlier verdict drew (carried metadata) is lineage and claims nothing here. Without a
1144
+ // reraised list to check against, every link counts as a claim — fail closed.
1145
+ const rows = findingRows(e, "review");
1146
+ const reraisedHere = Array.isArray(e.data.reraised) ? e.data.reraised.filter((id) => typeof id === "string") : undefined;
1147
+ const claimedByMaterial = new Set(rows.filter((row) => !isDeferredFinding(row) && row.reraisedFrom
1148
+ && (reraisedHere === undefined || reraisedHere.some((id) => reviewFingerprintMatches(id, row.reraisedFrom)))).map((row) => row.reraisedFrom));
1149
+ const deferredParents = rows.filter((row) => isDeferredFinding(row) && row.reraisedFrom && !claimedByMaterial.has(row.reraisedFrom))
1150
+ .map((row) => row.reraisedFrom);
1151
+ for (const finding of open) {
1152
+ if (finding.class !== "review:material" || !observedReviewFingerprints(finding).some((fp) => deferredParents.includes(fp)))
1153
+ continue;
1154
+ for (const id of observedReviewFingerprints(finding))
1155
+ retiredParents.add(id);
1156
+ }
1157
+ const boundToRetired = (finding) => finding.boundTo !== undefined && retiredParents.has(finding.boundTo);
1158
+ open = open.filter((finding) => !boundToRetired(finding));
1159
+ // OBS-1195: a later verdict that re-anchors a carried anchor to a deferred entry or a resolved prior
1160
+ // settles that anchor explicitly. Only its own identity (path, symbol and note), uniquely matched,
1161
+ // retires it; a bare path coincidence binds nothing.
1162
+ if (Array.isArray(e.data.settledAnchors)) {
1163
+ const retired = new Set(e.data.settledAnchors.flatMap((anchor) => {
1164
+ const row = settledAnchorRow(anchor);
1165
+ // Identity, not fingerprint: a collision-disambiguated anchor carries a `symbol#hash` spelling.
1166
+ const matches = row ? open.filter((finding) => finding.class === "review:anchored" && finding.path === row.path
1167
+ && (finding.symbol === row.symbol || finding.symbol.startsWith(`${row.symbol}#`))
1168
+ && reviewNoteIdentity(finding.note) === reviewNoteIdentity(row.note)) : [];
1169
+ return matches.length === 1 ? matches : [];
1170
+ }));
1171
+ open = open.filter((finding) => !retired.has(finding));
1172
+ }
1071
1173
  if (e.data.pass !== false) {
1072
1174
  // a later review PASSED on this task: every finding it BLOCKED on is settled …
1073
1175
  open = open.filter(isDeferredFinding);
@@ -1077,11 +1179,39 @@ export function outstandingReviewFindings(events, taskId) {
1077
1179
  // the very next round — the same silent drop by a different door.
1078
1180
  open = carryReviewFindings(open, findingRows(e, "review").filter(isDeferredFinding));
1079
1181
  }
1080
- else
1081
- open = carryReviewFindings(open, findingRows(e, "review"));
1182
+ else {
1183
+ // OBS-1195: a repeated comment bound to a parent this verdict resolved or deferred is not re-seated.
1184
+ open = carryReviewFindings(open, rows).filter((finding) => !boundToRetired(finding));
1185
+ }
1082
1186
  }
1083
1187
  return open;
1084
1188
  }
1189
+ /**
1190
+ * OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
1191
+ * re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
1192
+ * count, so an unrelated finding sharing a path never lengthens it.
1193
+ */
1194
+ export function reraisedReviewChains(events, taskId) {
1195
+ let verdicts = [];
1196
+ for (const e of effectiveEvents(events)) {
1197
+ if (e.taskId !== taskId)
1198
+ continue;
1199
+ if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
1200
+ verdicts = [];
1201
+ if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
1202
+ continue;
1203
+ if (e.data.unparseable === true || e.data.noVerdict === true || e.data.cause !== undefined)
1204
+ continue;
1205
+ verdicts = e.data.pass === false ? [...verdicts, e] : [];
1206
+ }
1207
+ return outstandingReviewFindings(events, taskId)
1208
+ .filter((finding) => finding.class === "review:material")
1209
+ .map((finding) => ({
1210
+ finding,
1211
+ reraises: verdicts.filter((e) => Array.isArray(e.data.reraised) && e.data.reraised
1212
+ .some((id) => observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)))).length,
1213
+ }));
1214
+ }
1085
1215
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
1086
1216
  export function pendingRepairFindings(events, taskId) {
1087
1217
  const e = decisionForNextDispatch(events, taskId, "repair-attempt");
@@ -1808,7 +1938,11 @@ export class Journal {
1808
1938
  const reducedData = event === "run-end"
1809
1939
  ? (() => {
1810
1940
  const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
1811
- 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, "..", "..", "..")) };
1812
1946
  })()
1813
1947
  : event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
1814
1948
  ? {
@@ -2255,3 +2389,332 @@ export class Journal {
2255
2389
  .filter((row) => row && typeof row === "object" && "kind" in row && row.kind === "judge");
2256
2390
  }
2257
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
+ }
@@ -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