tickmarkr 2.1.3 → 2.1.5

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.
package/dist/run/git.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
- import { availableParallelism } from "node:os";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { availableParallelism, tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
+ import { StringDecoder } from "node:string_decoder";
6
7
  import { shq } from "../adapters/types.js";
7
8
  import { tickmarkrDir } from "../graph/graph.js";
8
9
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
@@ -62,6 +63,53 @@ export const deriveForkCap = (concurrency, cores = availableParallelism()) => Ma
62
63
  export const runWithForkBudget = (concurrency, fn) => forkBudget.run(String(deriveForkCap(concurrency)), fn);
63
64
  /** The cap owned by the run on this async context; the standalone default outside one. */
64
65
  export const resolvedForkCap = () => forkBudget.getStore() ?? DEFAULT_FORK_CAP;
66
+ const positiveInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
67
+ /**
68
+ * Three states, never two. A record carrying NO capacity is an older record from before this stamp
69
+ * existed: it keeps exactly the verdict it has today. A record carrying a capacity it cannot state —
70
+ * half the pair, an empty container, a zero, a negative, an unparseable value — is a NEWER record
71
+ * that is malformed, and reading it as an older one is how a fail-closed guard stops firing silently.
72
+ */
73
+ export function readCapacity(value) {
74
+ if (value === undefined)
75
+ return { state: "absent" };
76
+ if (value === null || typeof value !== "object")
77
+ return { state: "malformed" };
78
+ const { forkCap, cores } = value;
79
+ return positiveInt(forkCap) && positiveInt(cores)
80
+ ? { state: "present", capacity: { forkCap, cores } }
81
+ : { state: "malformed" };
82
+ }
83
+ /**
84
+ * May a verdict recorded under `recorded` be reused — forgiven, cached, replayed — by a session
85
+ * running under `current`? Absent → yes, unchanged. Present and identical → yes. Malformed, a
86
+ * different capacity, or a current capacity the caller could not state → no.
87
+ */
88
+ export function sameCapacity(recorded, current) {
89
+ const read = readCapacity(recorded);
90
+ if (read.state === "absent")
91
+ return true;
92
+ if (read.state === "malformed" || current === undefined)
93
+ return false;
94
+ return read.capacity.forkCap === current.forkCap && read.capacity.cores === current.cores;
95
+ }
96
+ export const describeCapacity = (value) => {
97
+ const read = readCapacity(value);
98
+ return read.state === "present"
99
+ ? `fork cap ${read.capacity.forkCap} of ${read.capacity.cores} cores`
100
+ : read.state === "absent" ? "an unrecorded capacity" : "a malformed capacity";
101
+ };
102
+ /**
103
+ * The capacity a child spawned on THIS async context would receive: the same precedence `shell`
104
+ * applies below — an operator export of the cap wins over the run's own derived value — beside the
105
+ * cores it was divided from. A caller holding a command's own result reads the capacity off THAT
106
+ * result (`ShResult.capacity`, stamped where the child's environment was built); this is for the
107
+ * decisions taken BEFORE any child exists — a cache hit, a reuse predicate.
108
+ */
109
+ export const resolvedCapacity = () => ({
110
+ forkCap: Number(FORK_CAP_ENV in process.env ? process.env[FORK_CAP_ENV] : resolvedForkCap()),
111
+ cores: availableParallelism(),
112
+ });
65
113
  /** The shipped shell ceiling: the fallback every caller gets when nothing measured a better one. */
66
114
  export const DEFAULT_SHELL_TIMEOUT_MS = 600000;
67
115
  /**
@@ -102,6 +150,13 @@ function shell(cmd, cwd, timeoutMs, login) {
102
150
  // OBS-110: apply the run's own fork cap only when the operator has not already set one.
103
151
  if (!(FORK_CAP_ENV in env))
104
152
  env[FORK_CAP_ENV] = resolvedForkCap();
153
+ // T7: the capacity every result of this shell carries, read HERE — off the environment the child
154
+ // is about to receive, after the precedence above has settled. An operator export is already in
155
+ // `env`, so what gets recorded is the operator's number, which is the case a release was re-taken
156
+ // for; re-deriving the run's own budget after the command returned would stamp a cap no child ran
157
+ // under. `Number` of an unparseable export is NaN, which every reader treats as malformed and
158
+ // therefore fails closed — the honest direction when the cap in play cannot be stated.
159
+ const capacity = { forkCap: Number(env[FORK_CAP_ENV]), cores: availableParallelism() };
105
160
  const attempt = () => new Promise((resolve) => {
106
161
  const startedAt = Date.now();
107
162
  // detached: bash gets its own process group so a timeout can kill the whole tree —
@@ -109,13 +164,17 @@ function shell(cmd, cwd, timeoutMs, login) {
109
164
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
110
165
  const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
111
166
  let stdout = "", stderr = "";
112
- let timedOut = false, done = false, started = false;
167
+ const stdoutDecoder = new StringDecoder("utf8");
168
+ const stderrDecoder = new StringDecoder("utf8");
169
+ let timedOut = false, done = false, started = false, outputSeen = false;
113
170
  const finish = (code, err) => {
114
171
  if (done)
115
172
  return;
116
173
  done = true;
117
174
  clearTimeout(timer);
118
- resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt });
175
+ stdout += stdoutDecoder.end();
176
+ stderr += stderrDecoder.end();
177
+ resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt, capacity });
119
178
  };
120
179
  const timer = setTimeout(() => {
121
180
  timedOut = true;
@@ -127,10 +186,23 @@ function shell(cmd, cwd, timeoutMs, login) {
127
186
  }
128
187
  }, timeoutMs);
129
188
  p.on("spawn", () => { started = true; }); // the command exists from here on — never retryable past it
130
- p.stdout.on("data", (d) => (stdout += d));
131
- p.stderr.on("data", (d) => (stderr += d));
189
+ // OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
190
+ // stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
191
+ // kernel-chosen boundaries. A deterministic fixture proves this decoder correct rather than
192
+ // proving every caller byte-safe: real chunk boundaries are the kernel's to choose, so timing
193
+ // still decides whether a chunk-local decoder exposes the defect in any particular run.
194
+ p.stdout.on("data", (d) => {
195
+ if (d.length > 0)
196
+ outputSeen = true;
197
+ stdout += stdoutDecoder.write(d);
198
+ });
199
+ p.stderr.on("data", (d) => {
200
+ if (d.length > 0)
201
+ outputSeen = true;
202
+ stderr += stderrDecoder.write(d);
203
+ });
132
204
  p.on("error", (e) => {
133
- if (!done && !started && !stdout && !stderr && e.code === RETRYABLE_SPAWN_CODE) {
205
+ if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
134
206
  done = true;
135
207
  clearTimeout(timer);
136
208
  resolve({ refused: e });
@@ -152,7 +224,7 @@ function shell(cmd, cwd, timeoutMs, login) {
152
224
  // Bounded, and the bound is what makes a persisting shortage a REPORTED failure rather than a
153
225
  // wedged daemon: past it the caller gets the refusal's own text under exit 127, as before.
154
226
  if (n >= SPAWN_ATTEMPT_LIMIT) {
155
- return { code: 127, stdout: "", stderr: String(r.refused), durationMs: Date.now() - startedAt };
227
+ return { code: 127, stdout: "", stderr: String(r.refused), durationMs: Date.now() - startedAt, capacity };
156
228
  }
157
229
  await new Promise((wake) => setTimeout(wake, SPAWN_RETRY_BACKOFF_MS * n));
158
230
  }
@@ -260,6 +332,46 @@ const resolveTaskBranch = async (repo, branch) => {
260
332
  const integration = await resolveIntegrationBranch(repo, branch.slice(0, split));
261
333
  return `${integration}${branch.slice(split)}`;
262
334
  };
335
+ /**
336
+ * Preserve the bytes an existing checkout holds before recreation removes it.
337
+ *
338
+ * `git stash create` cannot do this job: its apparent `-u` argument is accepted as a message and
339
+ * untracked files never enter the stash object. Build the snapshot through a disposable index
340
+ * instead. The index starts at HEAD (so no unrelated residue from the checkout's real index enters
341
+ * the tree), stages the complete working tree including ordinary untracked paths, and lives outside
342
+ * the repository so it cannot stage itself. None of these plumbing commands writes the checkout or
343
+ * its real index.
344
+ *
345
+ * The returned ref is the durable recovery handle. A clean checkout returns undefined and creates
346
+ * neither a commit nor a ref, keeping meaningful deaths visible rather than minting one ref per
347
+ * ordinary dispatch.
348
+ */
349
+ export async function preserveWorktree(cwd) {
350
+ if (!existsSync(cwd))
351
+ return undefined;
352
+ const scratch = mkdtempSync(join(tmpdir(), "tickmarkr-preserve-index-"));
353
+ const index = join(scratch, "index");
354
+ const withIndex = (command) => `GIT_INDEX_FILE=${shq(index)} ${command}`;
355
+ try {
356
+ await shGitOk(withIndex("git read-tree HEAD"), cwd);
357
+ await shGitOk(withIndex("git add -A -- ."), cwd);
358
+ const tree = (await shGitOk(withIndex("git write-tree"), cwd)).trim();
359
+ const headTree = (await shGitOk("git rev-parse 'HEAD^{tree}'", cwd)).trim();
360
+ if (tree === headTree)
361
+ return undefined;
362
+ // Do not depend on consumer-level identity configuration: this is an engine recovery object,
363
+ // not an authored project commit. Its HEAD parent makes the preserved tree directly inspectable.
364
+ const identity = "GIT_AUTHOR_NAME=tickmarkr GIT_AUTHOR_EMAIL=tickmarkr@localhost "
365
+ + "GIT_COMMITTER_NAME=tickmarkr GIT_COMMITTER_EMAIL=tickmarkr@localhost";
366
+ const commit = (await shGitOk(`${identity} git commit-tree ${shq(tree)} -p HEAD -m ${shq("tickmarkr: preserve uncommitted worktree")}`, cwd)).trim();
367
+ const ref = `refs/tickmarkr/preserved/${commit}`;
368
+ await shGitOk(`git update-ref ${shq(ref)} ${shq(commit)}`, cwd);
369
+ return ref;
370
+ }
371
+ finally {
372
+ rmSync(scratch, { recursive: true, force: true });
373
+ }
374
+ }
263
375
  export async function createWorktree(repo, branch, baseRef) {
264
376
  branch = await resolveTaskBranch(repo, branch);
265
377
  const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
@@ -36,6 +36,7 @@ export interface StructuredFinding {
36
36
  path: string;
37
37
  symbol: string;
38
38
  note: string;
39
+ rationale?: string;
39
40
  fingerprint: string;
40
41
  }
41
42
  export declare const UNIDENTIFIED = "<unidentified>";
@@ -52,6 +53,15 @@ export declare const UNIDENTIFIED = "<unidentified>";
52
53
  * normalized words (see toFinding).
53
54
  */
54
55
  export declare function structuredFindings(gate: string, details: string, _scopeFiles?: string[]): StructuredFinding[];
56
+ export declare function isDeferredFinding(finding: StructuredFinding): boolean;
57
+ /**
58
+ * The findings a PASSING review DEFERRED — the rows a blocking-only projection drops on the floor.
59
+ * A passing review's details are prose; without this the deferral has no identity a later round can
60
+ * match, and every structured reader of the journal is blind to a defect the reviewer itself named.
61
+ */
62
+ export declare function deferredReviewFindings(details: string): StructuredFinding[];
63
+ /** The exact review.ts details fragment represented by a structured review finding. */
64
+ export declare function renderStructuredReviewFinding(finding: StructuredFinding): string;
55
65
  export interface PriorRunJournal {
56
66
  runId: string;
57
67
  events: JournalEvent[];
@@ -104,6 +114,43 @@ export declare function repairsSinceApproval(events: JournalEvent[], taskId: str
104
114
  * retires the findings it settled — the uphold case re-derives its own brief separately).
105
115
  */
106
116
  export declare function journaledFailureBrief(events: JournalEvent[], taskId: string): string[];
117
+ /**
118
+ * T6: the review findings still OUTSTANDING on a task. A review finding is a property of the TASK,
119
+ * not of the attempt that drew it: it stays outstanding until a later review PASSES on the task (or
120
+ * an operator approval settles it), and it therefore travels on EVERY dispatch until then.
121
+ *
122
+ * The two carries beside this one are attempt-scoped by construction and both lose it. The funded
123
+ * repair (`pendingRepairFindings`) is spent at the next `worker-launch` and is budgeted at two per
124
+ * engagement; `journaledFailureBrief` is reset at that same launch, so it hands the next brief only
125
+ * the LAST attempt's bytes. The moment one attempt fails for an unrelated reason — a red build, a
126
+ * refused tree, or a death that produces no verdict at all and journals no gate row whatsoever — the
127
+ * outstanding finding is in neither carry, and the run re-derives the task from the spec and lands on
128
+ * the same gap the reviewer already anchored.
129
+ *
130
+ * Retirement is closed and narrow: a review that PASSED, or the one approval that accepts the review
131
+ * gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"` — the operator taking the diff the
132
+ * reviewer rejected). Every other approval RETAINS. `--uphold` funds an attempt to FIX the findings;
133
+ * `--recheck` and an attempt-cap release fund another dispatch and say nothing about the reviewer's
134
+ * objection; a plain human-gate approval predates any review; a gate-satisfied release naming some
135
+ * other gate settled that gate, not this one. Reading "approved" as "settled" is how a still-open
136
+ * finding was dropped at the exact moment the operator paid for another attempt to fix it. A review
137
+ * that DECLINED (`skipped`) is not a verdict and neither adds nor retires — fail closed. Findings are
138
+ * keyed by fingerprint, so a reviewer restating one across rounds carries it once, not once per round.
139
+ *
140
+ * v2.1.5 T2: a passing review settles the findings it BLOCKED on. It does not settle the ones it
141
+ * DEFERRED — those it saw, declined to block on, and recorded a rationale for, and nothing has fixed
142
+ * them. So a pass retires the blocking set and re-seats its own deferrals, and the two retirements
143
+ * stay distinguishable: the blocking finding is gone, the deferral travels on as accepted work.
144
+ *
145
+ * A deferral's bound is the SAME single release as a blocking finding's — the operator accepting the
146
+ * review gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"`), the one approval in which a
147
+ * human actually looked at what the reviewer waved through. It is deliberately NOT bounded by a round
148
+ * count or by a time window: both retire a finding by arithmetic nobody read, which is the silent drop
149
+ * this fold exists to refuse. Nor can it accumulate — a reviewer restating the same path/note round
150
+ * after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
151
+ * row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
152
+ */
153
+ export declare function outstandingReviewFindings(events: JournalEvent[], taskId: string): StructuredFinding[];
107
154
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
108
155
  export declare function pendingRepairFindings(events: JournalEvent[], taskId: string): string | undefined;
109
156
  /**
@@ -87,6 +87,13 @@ export function upheldFeedbackByTask(events) {
87
87
  && typeof e.data.details === "string") {
88
88
  lastReviewFail.set(e.taskId, e.data.details);
89
89
  }
90
+ else if (e.event === "gate-result" && e.data.gate === "review" && e.data.pass === true
91
+ && e.data.skipped !== true) {
92
+ // A later review pass settles the upheld finding. Retire both the active brief and the failed
93
+ // verdict it came from so a still-later approval cannot resurrect already-settled feedback.
94
+ upheld.delete(e.taskId);
95
+ lastReviewFail.delete(e.taskId);
96
+ }
90
97
  else if (e.event === "task-approved") {
91
98
  // any later approval supersedes: a plain accept-the-diff approval retires the uphold brief.
92
99
  if (e.data.release === REVIEW_UPHELD_RELEASE) {
@@ -107,10 +114,11 @@ export function upheldFeedbackByTask(events) {
107
114
  // for a resolved one is the silent-lie shape the gates exist to refuse, so the field says so outright.
108
115
  export const UNIDENTIFIED = "<unidentified>";
109
116
  const ANCHORED_RE = /^- (\S+?):(\d+) — (.*)$/; // "## Anchored review" rows (llm.ts)
110
- const REVIEW_ROW_RE = /^- \[([^\]]+)\] (.*)$/; // "- [material] …" (review.ts)
117
+ const REVIEW_ROW_START_RE = /^- \[([^\]\r\n]+)\] /gm; // "- [material] …" (review.ts)
111
118
  const JUDGE_ROW_RE = /^✗ ([\w.-]+): (.*)$/; // "✗ c1: …" (acceptance.ts) — id, then reason
112
119
  const PATH_RE = /\b((?:[\w.@~+-]+\/)+[\w.@~+-]+\.\w{1,6})\b/;
113
120
  const LINE_REF_RE = /(:\d+(?::\d+)?\b)|(\bline \d+\b)/gi;
121
+ const REVIEW_RATIONALE_SEPARATOR = " — rationale: ";
114
122
  // ponytail: repo-relative tail from the first known top-level directory — enough to make an absolute
115
123
  // worktree path and its repo-relative twin the same identity. Widen the marker list if a run ever
116
124
  // names findings outside these roots.
@@ -136,10 +144,46 @@ function identifierIn(note) {
136
144
  // code identity still has one stable identity of its own: its own words, with the volatile tokens
137
145
  // swept out so line/path churn cannot mint a new symbol for the same finding. It is the reviewer's
138
146
  // own bytes, never a guess, and it can never fuse two different findings into one.
139
- function toFinding(cls, note, path, symbol) {
147
+ function toFinding(cls, note, path, symbol, rationale) {
140
148
  const p = path || UNIDENTIFIED;
141
149
  const s = symbol || normalizeGateFailure(note) || UNIDENTIFIED;
142
- return { class: cls, path: p, symbol: s, note, fingerprint: `${cls}|${p}|${s}` };
150
+ return {
151
+ class: cls, path: p, symbol: s, note,
152
+ ...(rationale !== undefined ? { rationale } : {}),
153
+ fingerprint: `${cls}|${p}|${s}`,
154
+ };
155
+ }
156
+ /**
157
+ * Decode review.ts's row rendering without treating physical lines as findings. A review finding's
158
+ * note and rationale are JSON strings before rendering and may therefore contain newlines; the next
159
+ * typed row (or the anchored-review block) is the record boundary. The fixed rationale separator is
160
+ * removed before identity is computed, so changing only why a concern was accepted re-seats it.
161
+ */
162
+ function reviewDetailFindings(details) {
163
+ const anchoredAt = details.indexOf("\n\n## Anchored review");
164
+ let prose = anchoredAt === -1 ? details : details.slice(0, anchoredAt);
165
+ const inconsistencyAt = prose.search(/\nreview (?:finding|verdict) inconsistent:/);
166
+ if (inconsistencyAt !== -1)
167
+ prose = prose.slice(0, inconsistencyAt);
168
+ const starts = [...prose.matchAll(REVIEW_ROW_START_RE)];
169
+ return starts.map((start, i) => {
170
+ const contentStart = start.index + start[0].length;
171
+ const contentEnd = starts[i + 1]?.index ?? prose.length;
172
+ let content = prose.slice(contentStart, contentEnd);
173
+ // The newline before the next typed row is framing, while every earlier newline belongs to the
174
+ // reviewer's field. At EOF/anchored-review there is no framing newline to remove.
175
+ if (starts[i + 1] && content.endsWith("\n"))
176
+ content = content.slice(0, -1);
177
+ const deferred = String(start[1]).startsWith("deferred/");
178
+ const separatorAt = deferred ? content.indexOf(REVIEW_RATIONALE_SEPARATOR) : -1;
179
+ return separatorAt === -1
180
+ ? { label: start[1], note: content }
181
+ : {
182
+ label: start[1],
183
+ note: content.slice(0, separatorAt),
184
+ rationale: content.slice(separatorAt + REVIEW_RATIONALE_SEPARATOR.length),
185
+ };
186
+ });
143
187
  }
144
188
  /**
145
189
  * Structured findings for a BLOCKING review/judge gate result, parsed from the details the gate
@@ -156,24 +200,22 @@ function toFinding(cls, note, path, symbol) {
156
200
  export function structuredFindings(gate, details, _scopeFiles = []) {
157
201
  const lines = details.split("\n");
158
202
  const rows = [];
159
- const push = (cls, note, ownPath, fallbackSymbol = "") => {
203
+ const push = (cls, note, ownPath, fallbackSymbol = "", rationale) => {
160
204
  const own = canonicalPath(ownPath || PATH_RE.exec(note)?.[1] || "");
161
205
  const sym = identifierIn(note) || fallbackSymbol;
162
- rows.push(toFinding(cls, note, own, sym));
206
+ rows.push(toFinding(cls, note, own, sym, rationale));
163
207
  };
208
+ if (gate === "review") {
209
+ for (const finding of reviewDetailFindings(details)) {
210
+ push(`review:${finding.label}`, finding.note, "", "", finding.rationale);
211
+ }
212
+ }
164
213
  for (const line of lines) {
165
214
  const a = ANCHORED_RE.exec(line);
166
215
  if (a) {
167
216
  push(`${gate}:anchored`, a[3], a[1]);
168
217
  continue;
169
218
  }
170
- if (gate === "review") {
171
- const r = REVIEW_ROW_RE.exec(line);
172
- if (r) {
173
- push(`review:${r[1]}`, r[2], "");
174
- continue;
175
- }
176
- }
177
219
  if (gate === "acceptance") {
178
220
  const j = JUDGE_ROW_RE.exec(line);
179
221
  // the criterion id IS a stable symbol for an unmet acceptance criterion — the same criterion is
@@ -190,6 +232,29 @@ export function structuredFindings(gate, details, _scopeFiles = []) {
190
232
  }
191
233
  return rows;
192
234
  }
235
+ // v2.1.5 T2: the reviewer's DEFERRAL channel, kept structured. `classifyReviewFindings`
236
+ // (gates/review.ts) renders a deferred finding as `- [deferred/<severity>] <note> — rationale: …`.
237
+ // The parser above preserves multiline fields and separates rationale from identity; an older journal
238
+ // whose row holds only prose still degrades through the same parse rather than to nothing. A deferred
239
+ // finding is a concern the reviewer SAW and chose not to block on; it is not a concern that was fixed.
240
+ const DEFERRED_CLASS_RE = /^review:deferred\b/;
241
+ export function isDeferredFinding(finding) {
242
+ return DEFERRED_CLASS_RE.test(finding.class);
243
+ }
244
+ /**
245
+ * The findings a PASSING review DEFERRED — the rows a blocking-only projection drops on the floor.
246
+ * A passing review's details are prose; without this the deferral has no identity a later round can
247
+ * match, and every structured reader of the journal is blind to a defect the reviewer itself named.
248
+ */
249
+ export function deferredReviewFindings(details) {
250
+ return structuredFindings("review", details).filter(isDeferredFinding);
251
+ }
252
+ /** The exact review.ts details fragment represented by a structured review finding. */
253
+ export function renderStructuredReviewFinding(finding) {
254
+ const label = finding.class.startsWith("review:") ? finding.class.slice("review:".length) : finding.class;
255
+ const rationale = finding.rationale === undefined ? "" : `${REVIEW_RATIONALE_SEPARATOR}${finding.rationale}`;
256
+ return `- [${label}] ${finding.note}${rationale}`;
257
+ }
193
258
  const findingRows = (event, gate) => {
194
259
  if (Array.isArray(event.data.findings)) {
195
260
  const rows = event.data.findings.filter((finding) => {
@@ -198,6 +263,7 @@ const findingRows = (event, gate) => {
198
263
  const row = finding;
199
264
  return typeof row.class === "string" && typeof row.path === "string"
200
265
  && typeof row.symbol === "string" && typeof row.note === "string"
266
+ && (row.rationale === undefined || typeof row.rationale === "string")
201
267
  && typeof row.fingerprint === "string";
202
268
  });
203
269
  if (rows.length > 0)
@@ -480,6 +546,72 @@ export function journaledFailureBrief(events, taskId) {
480
546
  }
481
547
  return rows;
482
548
  }
549
+ /**
550
+ * T6: the review findings still OUTSTANDING on a task. A review finding is a property of the TASK,
551
+ * not of the attempt that drew it: it stays outstanding until a later review PASSES on the task (or
552
+ * an operator approval settles it), and it therefore travels on EVERY dispatch until then.
553
+ *
554
+ * The two carries beside this one are attempt-scoped by construction and both lose it. The funded
555
+ * repair (`pendingRepairFindings`) is spent at the next `worker-launch` and is budgeted at two per
556
+ * engagement; `journaledFailureBrief` is reset at that same launch, so it hands the next brief only
557
+ * the LAST attempt's bytes. The moment one attempt fails for an unrelated reason — a red build, a
558
+ * refused tree, or a death that produces no verdict at all and journals no gate row whatsoever — the
559
+ * outstanding finding is in neither carry, and the run re-derives the task from the spec and lands on
560
+ * the same gap the reviewer already anchored.
561
+ *
562
+ * Retirement is closed and narrow: a review that PASSED, or the one approval that accepts the review
563
+ * gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"` — the operator taking the diff the
564
+ * reviewer rejected). Every other approval RETAINS. `--uphold` funds an attempt to FIX the findings;
565
+ * `--recheck` and an attempt-cap release fund another dispatch and say nothing about the reviewer's
566
+ * objection; a plain human-gate approval predates any review; a gate-satisfied release naming some
567
+ * other gate settled that gate, not this one. Reading "approved" as "settled" is how a still-open
568
+ * finding was dropped at the exact moment the operator paid for another attempt to fix it. A review
569
+ * that DECLINED (`skipped`) is not a verdict and neither adds nor retires — fail closed. Findings are
570
+ * keyed by fingerprint, so a reviewer restating one across rounds carries it once, not once per round.
571
+ *
572
+ * v2.1.5 T2: a passing review settles the findings it BLOCKED on. It does not settle the ones it
573
+ * DEFERRED — those it saw, declined to block on, and recorded a rationale for, and nothing has fixed
574
+ * them. So a pass retires the blocking set and re-seats its own deferrals, and the two retirements
575
+ * stay distinguishable: the blocking finding is gone, the deferral travels on as accepted work.
576
+ *
577
+ * A deferral's bound is the SAME single release as a blocking finding's — the operator accepting the
578
+ * review gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"`), the one approval in which a
579
+ * human actually looked at what the reviewer waved through. It is deliberately NOT bounded by a round
580
+ * count or by a time window: both retire a finding by arithmetic nobody read, which is the silent drop
581
+ * this fold exists to refuse. Nor can it accumulate — a reviewer restating the same path/note round
582
+ * after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
583
+ * row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
584
+ */
585
+ export function outstandingReviewFindings(events, taskId) {
586
+ const open = new Map();
587
+ for (const e of events) {
588
+ if (e.taskId !== taskId)
589
+ continue;
590
+ if (e.event === "task-approved") {
591
+ if (e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
592
+ open.clear();
593
+ continue;
594
+ }
595
+ if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
596
+ continue;
597
+ if (e.data.pass !== false) {
598
+ // a later review PASSED on this task: every finding it BLOCKED on is settled …
599
+ for (const [key, finding] of open)
600
+ if (!isDeferredFinding(finding))
601
+ open.delete(key);
602
+ // … and no deferral is, whether or not this pass restated it. A pass is silent about a
603
+ // deferral it does not mention: the concern is unfixed either way, and the reviewer that
604
+ // waved it through is not the release that accepts it. Retiring on omission would drop it on
605
+ // the very next round — the same silent drop by a different door.
606
+ for (const finding of findingRows(e, "review").filter(isDeferredFinding))
607
+ open.set(finding.fingerprint, finding);
608
+ }
609
+ else
610
+ for (const finding of findingRows(e, "review"))
611
+ open.set(finding.fingerprint, finding);
612
+ }
613
+ return [...open.values()];
614
+ }
483
615
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
484
616
  export function pendingRepairFindings(events, taskId) {
485
617
  const e = decisionForNextDispatch(events, taskId, "repair-attempt");
@@ -1055,12 +1187,16 @@ export class Journal {
1055
1187
  }
1056
1188
  replayResumeState() {
1057
1189
  const m = new Map();
1190
+ const events = this.read();
1191
+ // Keep the legacy resume-state field aligned with the journal-authoritative prompt-time fold.
1192
+ // In particular, a review pass after an uphold must erase the fallback daemon.ts may consult.
1193
+ const activeUpheldFeedback = upheldFeedbackByTask(events);
1058
1194
  const pendingReroute = new Set(); // reroute verdicts not yet cleared by a later dispatch
1059
1195
  // OBS-547: what the last dispatch ADDED, so a scope-authoring event can take it back. An
1060
1196
  // unchargeable dispatch must replay as if it never happened — no attempt counted, no channel burned.
1061
1197
  const lastDispatch = new Map();
1062
1198
  const lastReviewFail = new Map(); // OBS-189: newest failed review details per task
1063
- for (const e of this.read()) {
1199
+ for (const e of events) {
1064
1200
  if (!e.taskId)
1065
1201
  continue;
1066
1202
  if (e.event === "gate-result" && e.data.gate === "review" && e.data.pass === false
@@ -1160,6 +1296,13 @@ export class Journal {
1160
1296
  st.lastAssignment = undefined;
1161
1297
  }
1162
1298
  }
1299
+ for (const [taskId, st] of m) {
1300
+ const feedback = activeUpheldFeedback.get(taskId);
1301
+ if (feedback)
1302
+ st.upheldFeedback = feedback;
1303
+ else
1304
+ delete st.upheldFeedback;
1305
+ }
1163
1306
  return m;
1164
1307
  }
1165
1308
  // OBS-130: gate satisfaction is authority, not an inferred daemon state. Only an explicit
package/dist/run/merge.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { shq } from "../adapters/types.js";
4
4
  import { ceilingKillResult, classifyFailureOutput, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
5
5
  import { tickmarkrDir } from "../graph/graph.js";
6
- import { gitHead, linkNodeModules, resolveIntegrationBranch, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
6
+ import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
7
7
  export function integrationBranch(cfg, runId) {
8
8
  return `${cfg.integrationBranchPrefix}${runId}`;
9
9
  }
@@ -99,7 +99,13 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
99
99
  // Battery parity on the infra rule too (T9): infrastructure-only output means the runner never
100
100
  // completed a suite — nothing was verified, so nothing is forgivable, however familiar its
101
101
  // fingerprints. Stricter-than-battery edge kept: unreadable output never forgives.
102
- const forgiven = r.code !== 0 && baselineRed && failing.length === 0 && !unreadable && cause !== "infra";
102
+ // T7: the SECOND reader of a baseline entry, and the same rule as the battery's (baseline.ts).
103
+ // Evidence crosses a session boundary here — the capture that would forgive this red is very
104
+ // often the previous session's — so a capture taken under a different resolved capacity forgives
105
+ // nothing at the tip either. An absent capacity is a pre-T7 baseline and keeps today's verdict.
106
+ const comparable = sameCapacity(entry?.capacity, r.capacity);
107
+ const forgiven = r.code !== 0 && baselineRed && failing.length === 0 && !unreadable && cause !== "infra"
108
+ && comparable;
103
109
  const pass = r.code === 0 || forgiven;
104
110
  if (!pass)
105
111
  writeFileSync(artifact, raw);
@@ -111,7 +117,11 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
111
117
  fingerprints: r.code !== 0 ? fingerprint(stripped) : [],
112
118
  details: r.code === 0 ? "exit 0"
113
119
  : forgiven ? `exit ${r.code} but only baseline-recorded failures (forgiven vs baseline)`
114
- : `exit ${r.code}`,
120
+ : !comparable && baselineRed && failing.length === 0
121
+ ? `exit ${r.code}; every failure is baseline-recorded, but that capture ran under `
122
+ + `${describeCapacity(entry?.capacity)} and this verification ran under ${describeCapacity(r.capacity)} `
123
+ + `— forgiveness across a changed capacity is not evidence`
124
+ : `exit ${r.code}`,
115
125
  ...(forgiven ? { forgiven: true } : {}),
116
126
  ...(cause ? { cause } : {}),
117
127
  ...(pass ? {} : { artifact }),
@@ -5,7 +5,7 @@ export declare const SUPERVISION_STALE_MS: number;
5
5
  export declare const SUPERVISION_FUTURE_GRACE_MS = 1000;
6
6
  export declare const SUPERVISION_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch"];
7
7
  export type SupervisionTier = (typeof SUPERVISION_TIERS)[number];
8
- export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator-context", "overseer-context"];
8
+ export declare const SUPERVISION_SEAT_TIERS: readonly ["orchestrator", "orchestrator-context", "overseer", "overseer-context"];
9
9
  export type SeatTier = (typeof SUPERVISION_SEAT_TIERS)[number];
10
10
  /** Does this tier's record have to name the seat it speaks for? */
11
11
  export declare const isSeatTier: (tier: string) => tier is SeatTier;