tickmarkr 2.1.3 → 2.1.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.
@@ -8,6 +8,9 @@ export type StatusOpts = {
8
8
  readWorkerOutput?: (taskId: string, attempt: number, runId: string) => Promise<string | undefined>;
9
9
  webhookUrl?: string;
10
10
  postWebhook?: DecisionWebhookPost;
11
+ /** How often the live board rewrites its own `watch` beat. Fixtures choose it so a death drill can
12
+ * watch the record STOP inside a test's patience; unset means SUPERVISION_BEAT_MS. */
13
+ supervisionBeatMs?: number;
11
14
  };
12
15
  export declare const GATE_KEYS: {
13
16
  readonly build: "B";
@@ -12,7 +12,7 @@ import { isPidLive } from "../../run/lock.js";
12
12
  import { normalizeGateOutcome } from "../../run/outcome.js";
13
13
  import { desiredPanes } from "../../run/reconcile.js";
14
14
  import { normalizeStallSnapshot } from "../../run/stall.js";
15
- import { readSupervision, supervisionText } from "../../run/supervision.js";
15
+ import { armSupervision, readSupervision, supervisionText } from "../../run/supervision.js";
16
16
  import { deriveRunCockpitData, } from "../../tui/cockpit/derive.js";
17
17
  import { COCKPIT_COLUMN_FLOOR } from "../../tui/cockpit/layout.js";
18
18
  import { cellWidth, fitCells, wrapCells } from "../../tui/cockpit/width.js";
@@ -1370,6 +1370,20 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1370
1370
  }
1371
1371
  return fresh;
1372
1372
  };
1373
+ // SUP-06: the live board IS the `watch` tier, so it is the only process that can beat it — which is
1374
+ // why that tier could only ever print ABSENT about itself while every other seat had a beater. A word
1375
+ // one tier can only ever say in one of its five states trains every reader to skip it.
1376
+ //
1377
+ // Armed ONLY when UNBOUNDED. A bounded render is a READER, and the purity fence (D-02: a bounded
1378
+ // --watch leaves .tickmarkr/ byte-identical) is exactly the test that catches a reader beating on a
1379
+ // watcher's behalf, which would report every dead tier healthy. Armed BEFORE the first frame, so the
1380
+ // board's own first print already names its tier armed rather than one interval later.
1381
+ //
1382
+ // The handle is HELD and stood down in the finally. Discarding it is not a smaller version of this —
1383
+ // it INVERTS it: armSupervision's interval outlives the board in any host that outlives one board, so
1384
+ // a dead board keeps writing and reads ARMED. That is the over-claiming direction, the one an operator
1385
+ // acts on, and the one this instrument exists to close.
1386
+ const armed = bounded ? undefined : armSupervision(cwd, "watch", opts.supervisionBeatMs);
1373
1387
  let titleSaved = false;
1374
1388
  const restoreTitle = () => {
1375
1389
  if (!titleSaved)
@@ -1427,6 +1441,14 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1427
1441
  }
1428
1442
  }
1429
1443
  finally {
1444
+ // Stops the interval as well as recording the hand-off: a board that returns must not leave a
1445
+ // timer beating for it. A board that is KILLED never reaches here, its beat ages out, and the
1446
+ // tier reads STALE — armed-then-lost, which is the truth about a killed board.
1447
+ // Boards OVERLAP — a second pane is one keystroke away — and the stand-down marker speaks for the
1448
+ // whole tier, so this hand-off is recorded only when no other board is still present (see
1449
+ // armSupervision's presence files). Otherwise the first pane closed would render the second
1450
+ // pane's own tier down while it is drawing frames.
1451
+ armed?.disarm();
1430
1452
  if (titleSaved) {
1431
1453
  process.removeListener("exit", restoreTitle);
1432
1454
  restoreTitle();
@@ -106,6 +106,22 @@ function extractWriteDirectives(body, storedPath) {
106
106
  return writes;
107
107
  }
108
108
  const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
109
+ function summaryCompletionStatus(planFile) {
110
+ const summaryFile = join(dirname(planFile), `${basename(planFile).replace(/-PLAN\.md$/, "")}-SUMMARY.md`);
111
+ if (!existsSync(summaryFile))
112
+ return undefined;
113
+ try {
114
+ const content = readFileSync(summaryFile, "utf8");
115
+ const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
116
+ if (!fmMatch)
117
+ return undefined;
118
+ const fm = parseYaml(fmMatch[1]);
119
+ return isPlainObject(fm) ? fm.status : undefined;
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
109
125
  // schema-legal, branch-safe id segment: dots etc. → dashes; no "--" runs (task-branch separator)
110
126
  // or trailing dash — the id schema rejects both
111
127
  const sanitize = (s) => s
@@ -170,7 +186,11 @@ function compileOne(file, storedPath) {
170
186
  .filter((p) => !p.includes("$") && !p.startsWith("~") && !p.startsWith("/") && !p.split("/").includes(".."));
171
187
  const taskCount = [...body.matchAll(/<task[\s>]/g)].length;
172
188
  const humanGate = fm.autonomous === false || /<task type="checkpoint:/.test(body);
173
- const done = existsSync(join(dirname(file), `${basename(file).replace(/-PLAN\.md$/, "")}-SUMMARY.md`));
189
+ const summaryStatus = summaryCompletionStatus(file);
190
+ // GSD source rule (`/gsd:quick status`): a SUMMARY is complete only when its frontmatter says
191
+ // `status: complete`; when `status` is absent it reports `INCOMPLETE`. The compiler must preserve
192
+ // that distinction because an artifact can record a reviewed failure rather than completion.
193
+ const done = summaryStatus === "complete";
174
194
  const files = strings(fm.files_modified).map((f) => f.replace(/^\.\//, ""));
175
195
  assertWriteScope(file, `P${key}`, files, extractWriteDirectives(body, storedPath));
176
196
  // fail closed (D-03): a silently dropped floor/pin routes the task cheap instead of erroring
@@ -18,7 +18,6 @@ export interface RunOptions {
18
18
  mode?: RoutingMode;
19
19
  narrate?: (event: JournalEvent) => void;
20
20
  exit?: (code: number) => void;
21
- supervise?: boolean;
22
21
  }
23
22
  export type ModeSource = "run flag" | "spec" | "repo config" | "global config" | "default";
24
23
  export interface ResolvedRunMode {
@@ -19,16 +19,15 @@ import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHa
19
19
  import { GATE_NAMES } from "../graph/schema.js";
20
20
  import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
21
21
  import { runEnvironment } from "./environment.js";
22
- import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, runWithForkBudget, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
22
+ import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, preserveWorktree, runWithForkBudget, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
23
23
  import { runInteractiveSeed } from "./interactive-seed.js";
24
- import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
24
+ import { activeRetryBan, classifyTaskFailure, classifyWorkerResultCause, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingReviewFindings, pendingRepairFindings, phaseForGate, readPriorRunEvidence, recordedTaskFailureKind, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, structuredFindings, upheldFeedbackByTask } from "./journal.js";
25
25
  import { isDiffCapPark } from "../gates/review.js";
26
26
  import { acquireApprovalSerialization, acquireRunLock, releaseRunLock } from "./lock.js";
27
27
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
28
28
  import { nextChannel, route } from "../route/router.js";
29
29
  import { desiredPanes } from "./reconcile.js";
30
30
  import { harvestCpuFlatWindowMs, NUDGEABLE_ADAPTERS, PANE_READ_ROWS, StallProgressTracker, stallSnapshotBannerRows, WorkerTreeCpuAccountant, } from "./stall.js";
31
- import { armSupervision } from "./supervision.js";
32
31
  // Compatibility exports for the daemon liveness tests and existing consumers. The implementation
33
32
  // lives in stall.ts so gate dispatch can depend on it without importing the daemon.
34
33
  export { harvestCpuFlatWindowMs, resetHarvestCpuFlatMsForTests, setHarvestCpuFlatMsForTests, workerTreeCpuMs, } from "./stall.js";
@@ -721,13 +720,9 @@ export async function runDaemon(repoRoot, opts = {}) {
721
720
  // subprocess, so the optional-chain open below is a no-op there). Cosmetic-only: any failure is
722
721
  // swallowed (never affects the run); the operator closes a surviving watch pane.
723
722
  const lock = acquireRunLock(repoRoot, runId);
724
- // T16: the orchestrator seat IS this daemon, so this is where the tier gets armed — the writer half
725
- // T3 shipped had no caller outside its own tests, which made the reader honest and useless: it read
726
- // ABSENT for the entire life of every run. Armed immediately after the lock (the first instant this
727
- // process owns the run) and held to the last, so the beat's span is the run's span. armSupervision
728
- // never throws, so an unwritable beat can never take a run down; it is deregistered in BOTH exits
729
- // below, because the signal reaper exits the process before the finally can run.
730
- const supervision = opts.supervise === false ? undefined : armSupervision(repoRoot, "orchestrator");
723
+ // D10: the lock is this daemon's liveness record and already carries its pid; status prints that
724
+ // identity beside the supervision row. The `orchestrator` tier belongs exclusively to the seated
725
+ // supervisor, so a run never beats or stands down that seat's record on the daemon's behalf.
731
726
  // v1.54 T2: declared before the try so the finally can always deregister (a throw before
732
727
  // registration leaves it undefined — the guard below covers that path).
733
728
  let onTermination;
@@ -855,7 +850,6 @@ export async function runDaemon(repoRoot, opts = {}) {
855
850
  }
856
851
  catch { /* cosmetic — visibility is never a gate */ }
857
852
  }
858
- supervision?.disarm(); // T16: same reason as the lock — this seat stood down, it did not die
859
853
  releaseRunLock(repoRoot); // the process dies at exit() below — the finally never runs on this path
860
854
  }
861
855
  abortRun(new Error(`terminated by ${sig}`));
@@ -1145,6 +1139,17 @@ export async function runDaemon(repoRoot, opts = {}) {
1145
1139
  await park(t, `humanGate: "${t.title}" requires approval before dispatch`, "human-gate", null, 0, startMs);
1146
1140
  return;
1147
1141
  }
1142
+ // The driver owns how a checkout is created, but runDaemon owns the destructive transition:
1143
+ // every task-checkout recreation passes through this wrapper before any driver can remove the
1144
+ // old path. A preservation failure throws and therefore leaves the old checkout in place. The
1145
+ // row is deliberately written before the later worktree-recreation row so the journal cannot
1146
+ // describe only the commits it carried while omitting uncommitted work the removal destroyed.
1147
+ const recreateTaskWorktree = async (taskBranch, taskBase, priorWt) => {
1148
+ const ref = await preserveWorktree(priorWt);
1149
+ if (ref)
1150
+ journal.append("worktree-preserved", t.id, { ref });
1151
+ return driver.worktree(repoRoot, taskBranch, taskBase);
1152
+ };
1148
1153
  const r = route(t, cfg, channels, profile, undefined, demotedChannels);
1149
1154
  for (const lint of r.lints)
1150
1155
  journal.append("routing-lint", t.id, { lint });
@@ -1457,7 +1462,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1457
1462
  const priorTaskTip = await gitHead(priorWt);
1458
1463
  const priorTaskSubject = await gateCommitSubject(taskBase, priorTaskTip, priorWt);
1459
1464
  const commitsToCarry = await commitsAheadOf(taskBase, priorWt);
1460
- const wt = await driver.worktree(repoRoot, taskBranch, taskBase);
1465
+ const wt = await recreateTaskWorktree(taskBranch, taskBase, priorWt);
1461
1466
  const carriedCommits = await cherryPickCommits(wt, commitsToCarry);
1462
1467
  // Reuse is about the tree the gates will actually inspect. The integration tip may have moved
1463
1468
  // while the daemon was down, so compare after recreating the task on today's taskBase rather
@@ -1753,6 +1758,22 @@ export async function runDaemon(repoRoot, opts = {}) {
1753
1758
  const brief = journaledRows.join("\n\n");
1754
1759
  feedback = feedback ? `${brief}\n\n${feedback}` : brief;
1755
1760
  }
1761
+ // T6: both carries above are ATTEMPT-scoped — the funded repair is spent at the next
1762
+ // worker-launch (and budgeted at two), and the journaled brief is reset there too, so it hands
1763
+ // this dispatch only the LAST attempt's bytes. An unresolved review finding is a property of the
1764
+ // TASK: the moment one attempt fails for an unrelated reason — a red build, a refused tree, or a
1765
+ // death that journals no gate row at all — the finding is in neither carry and the next worker
1766
+ // re-derives the task from the spec and lands on the same gap the reviewer already anchored.
1767
+ // Re-derived from the journal on EVERY dispatch and retired only by a review that passes on this
1768
+ // task (journal.ts `outstandingReviewFindings`). Appended row-wise, because this round's own
1769
+ // feedback or a repair brief may already quote a finding and repeating it helps no worker.
1770
+ const outstandingFindings = outstandingReviewFindings(journaledSoFar, t.id);
1771
+ const unquoted = outstandingFindings.filter((f) => !feedback.includes(f.note));
1772
+ if (unquoted.length > 0) {
1773
+ const brief = ["## Outstanding review findings — a review has NOT passed on these yet",
1774
+ ...unquoted.map((f) => `- ${f.path}: ${f.note}`)].join("\n");
1775
+ feedback = feedback ? `${feedback}\n\n${brief}` : brief;
1776
+ }
1756
1777
  retryMode = repairFindings
1757
1778
  ? "repair"
1758
1779
  : priorSession
@@ -1775,21 +1796,29 @@ export async function runDaemon(repoRoot, opts = {}) {
1775
1796
  lastContextTokens = undefined;
1776
1797
  graph = setStatus(graph, t.id, "running");
1777
1798
  saveGraph(repoRoot, graph);
1778
- journal.append("task-dispatch", t.id, { assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode });
1799
+ // T6: a dispatch that carries an outstanding finding says so, and names it. Without this the
1800
+ // ledger cannot tell a carried dispatch from an amnesiac one — the exact question a run that
1801
+ // spends two frontier attempts re-deriving a known defect has to be able to answer afterwards.
1802
+ journal.append("task-dispatch", t.id, {
1803
+ assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode,
1804
+ ...(outstandingFindings.length > 0 ? { carriedFindings: outstandingFindings } : {}),
1805
+ });
1779
1806
  journal.phaseStart(t.id, "worker", { attempt, assignment });
1780
1807
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
1781
1808
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
1782
1809
  const priorWt = worktreePath(repoRoot, taskBranch);
1783
- const commitsToCarry = existsSync(priorWt) ? await commitsAheadOf(taskBase, priorWt) : [];
1784
- const wt = await driver.worktree(repoRoot, taskBranch, taskBase);
1810
+ const recreating = existsSync(priorWt);
1811
+ const commitsToCarry = recreating ? await commitsAheadOf(taskBase, priorWt) : [];
1812
+ const wt = await recreateTaskWorktree(taskBranch, taskBase, priorWt);
1785
1813
  // OBS-58: quota-failover and every retry recreate the task worktree from the integration tip —
1786
1814
  // cherry-pick prior attempts' landed commits forward so a failover dispatch cannot silently
1787
1815
  // orphan work a consult already verified as landed.
1788
1816
  let carriedCommits = [];
1789
1817
  if (commitsToCarry.length > 0) {
1790
1818
  carriedCommits = await cherryPickCommits(wt, commitsToCarry);
1791
- journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
1792
1819
  }
1820
+ if (recreating)
1821
+ journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
1793
1822
  // T2 review (material): harvest eligibility is "does this WORKTREE carry unverified work",
1794
1823
  // measured against taskBase — the same base the fast-kill's delta probe and the gates
1795
1824
  // themselves use. It was measured against this attempt's post-carry HEAD, which excluded
@@ -2892,6 +2921,15 @@ export async function runDaemon(repoRoot, opts = {}) {
2892
2921
  if (results.some((g) => g.gate === "test" && !g.pass))
2893
2922
  testGateFailed = true;
2894
2923
  if (results.every(gateSatisfied)) {
2924
+ // T6: every gate — the review included — is satisfied on this commit, so the failure brief
2925
+ // this loop is still holding describes nothing outstanding. It is dropped HERE, before the
2926
+ // merge, because a conflict below sends the task around the attempt loop again: a brief kept
2927
+ // across that retry hands the next worker findings a later review has since passed on, while
2928
+ // `carriedFindings` — re-derived from the journal, which retired them — is correctly empty,
2929
+ // leaving that dispatch's row indistinguishable from an amnesiac one. Rebuilt exactly as the
2930
+ // gate-fail brief is, so prior-RUN evidence (retired by its own rule, not by this reviewer)
2931
+ // survives and only this run's settled findings go.
2932
+ feedback = withCarriedEvidence("");
2895
2933
  const m = await mergeSerial(taskBranch, t, gated);
2896
2934
  if (m.tipMoved) {
2897
2935
  journal.append("tip-moved", t.id, m.tipMoved);
@@ -3188,9 +3226,6 @@ export async function runDaemon(repoRoot, opts = {}) {
3188
3226
  process.removeListener("SIGINT", onTermination);
3189
3227
  process.removeListener("SIGTERM", onTermination);
3190
3228
  }
3191
- // T16: every other exit — normal end, throw, termination unwind. disarm() is idempotent, so the
3192
- // signal path having already stood the tier down changes nothing here.
3193
- supervision?.disarm();
3194
3229
  try {
3195
3230
  releaseRunLock(repoRoot);
3196
3231
  }
package/dist/run/git.d.ts CHANGED
@@ -63,6 +63,21 @@ export declare function cleanupRunWorktrees(repo: string, branch: string, opts:
63
63
  removeTaskIds: string[];
64
64
  }): Promise<void>;
65
65
  export declare function resolveIntegrationBranch(_repo: string, branch: string): Promise<string>;
66
+ /**
67
+ * Preserve the bytes an existing checkout holds before recreation removes it.
68
+ *
69
+ * `git stash create` cannot do this job: its apparent `-u` argument is accepted as a message and
70
+ * untracked files never enter the stash object. Build the snapshot through a disposable index
71
+ * instead. The index starts at HEAD (so no unrelated residue from the checkout's real index enters
72
+ * the tree), stages the complete working tree including ordinary untracked paths, and lives outside
73
+ * the repository so it cannot stage itself. None of these plumbing commands writes the checkout or
74
+ * its real index.
75
+ *
76
+ * The returned ref is the durable recovery handle. A clean checkout returns undefined and creates
77
+ * neither a commit nor a ref, keeping meaningful deaths visible rather than minting one ref per
78
+ * ordinary dispatch.
79
+ */
80
+ export declare function preserveWorktree(cwd: string): Promise<string | undefined>;
66
81
  export declare function createWorktree(repo: string, branch: string, baseRef: string): Promise<string>;
67
82
  export declare function linkNodeModules(repo: string, dir: string, { force }?: {
68
83
  force?: boolean | undefined;
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";
@@ -109,12 +110,16 @@ function shell(cmd, cwd, timeoutMs, login) {
109
110
  // open, so "close" never fires and the promise wedges forever (v1.33.1 init hang).
110
111
  const p = (spawnChild ?? spawn)("bash", [login ? "-lc" : "-c", cmd], { cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
111
112
  let stdout = "", stderr = "";
112
- let timedOut = false, done = false, started = false;
113
+ const stdoutDecoder = new StringDecoder("utf8");
114
+ const stderrDecoder = new StringDecoder("utf8");
115
+ let timedOut = false, done = false, started = false, outputSeen = false;
113
116
  const finish = (code, err) => {
114
117
  if (done)
115
118
  return;
116
119
  done = true;
117
120
  clearTimeout(timer);
121
+ stdout += stdoutDecoder.end();
122
+ stderr += stderrDecoder.end();
118
123
  resolve({ code, stdout, stderr: err ?? stderr, timedOut, durationMs: Date.now() - startedAt });
119
124
  };
120
125
  const timer = setTimeout(() => {
@@ -127,10 +132,23 @@ function shell(cmd, cwd, timeoutMs, login) {
127
132
  }
128
133
  }, timeoutMs);
129
134
  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));
135
+ // OBS-716: one stateful decoder per stream carries an incomplete UTF-8 sequence into that
136
+ // stream's next pipe chunk; decoding each chunk through string concatenation corrupts bytes at
137
+ // kernel-chosen boundaries. A deterministic fixture proves this decoder correct rather than
138
+ // proving every caller byte-safe: real chunk boundaries are the kernel's to choose, so timing
139
+ // still decides whether a chunk-local decoder exposes the defect in any particular run.
140
+ p.stdout.on("data", (d) => {
141
+ if (d.length > 0)
142
+ outputSeen = true;
143
+ stdout += stdoutDecoder.write(d);
144
+ });
145
+ p.stderr.on("data", (d) => {
146
+ if (d.length > 0)
147
+ outputSeen = true;
148
+ stderr += stderrDecoder.write(d);
149
+ });
132
150
  p.on("error", (e) => {
133
- if (!done && !started && !stdout && !stderr && e.code === RETRYABLE_SPAWN_CODE) {
151
+ if (!done && !started && !outputSeen && e.code === RETRYABLE_SPAWN_CODE) {
134
152
  done = true;
135
153
  clearTimeout(timer);
136
154
  resolve({ refused: e });
@@ -260,6 +278,46 @@ const resolveTaskBranch = async (repo, branch) => {
260
278
  const integration = await resolveIntegrationBranch(repo, branch.slice(0, split));
261
279
  return `${integration}${branch.slice(split)}`;
262
280
  };
281
+ /**
282
+ * Preserve the bytes an existing checkout holds before recreation removes it.
283
+ *
284
+ * `git stash create` cannot do this job: its apparent `-u` argument is accepted as a message and
285
+ * untracked files never enter the stash object. Build the snapshot through a disposable index
286
+ * instead. The index starts at HEAD (so no unrelated residue from the checkout's real index enters
287
+ * the tree), stages the complete working tree including ordinary untracked paths, and lives outside
288
+ * the repository so it cannot stage itself. None of these plumbing commands writes the checkout or
289
+ * its real index.
290
+ *
291
+ * The returned ref is the durable recovery handle. A clean checkout returns undefined and creates
292
+ * neither a commit nor a ref, keeping meaningful deaths visible rather than minting one ref per
293
+ * ordinary dispatch.
294
+ */
295
+ export async function preserveWorktree(cwd) {
296
+ if (!existsSync(cwd))
297
+ return undefined;
298
+ const scratch = mkdtempSync(join(tmpdir(), "tickmarkr-preserve-index-"));
299
+ const index = join(scratch, "index");
300
+ const withIndex = (command) => `GIT_INDEX_FILE=${shq(index)} ${command}`;
301
+ try {
302
+ await shGitOk(withIndex("git read-tree HEAD"), cwd);
303
+ await shGitOk(withIndex("git add -A -- ."), cwd);
304
+ const tree = (await shGitOk(withIndex("git write-tree"), cwd)).trim();
305
+ const headTree = (await shGitOk("git rev-parse 'HEAD^{tree}'", cwd)).trim();
306
+ if (tree === headTree)
307
+ return undefined;
308
+ // Do not depend on consumer-level identity configuration: this is an engine recovery object,
309
+ // not an authored project commit. Its HEAD parent makes the preserved tree directly inspectable.
310
+ const identity = "GIT_AUTHOR_NAME=tickmarkr GIT_AUTHOR_EMAIL=tickmarkr@localhost "
311
+ + "GIT_COMMITTER_NAME=tickmarkr GIT_COMMITTER_EMAIL=tickmarkr@localhost";
312
+ const commit = (await shGitOk(`${identity} git commit-tree ${shq(tree)} -p HEAD -m ${shq("tickmarkr: preserve uncommitted worktree")}`, cwd)).trim();
313
+ const ref = `refs/tickmarkr/preserved/${commit}`;
314
+ await shGitOk(`git update-ref ${shq(ref)} ${shq(commit)}`, cwd);
315
+ return ref;
316
+ }
317
+ finally {
318
+ rmSync(scratch, { recursive: true, force: true });
319
+ }
320
+ }
263
321
  export async function createWorktree(repo, branch, baseRef) {
264
322
  branch = await resolveTaskBranch(repo, branch);
265
323
  const dir = join(tickmarkrDir(repo), WORKTREES_DIR, sanitize(branch));
@@ -104,6 +104,30 @@ export declare function repairsSinceApproval(events: JournalEvent[], taskId: str
104
104
  * retires the findings it settled — the uphold case re-derives its own brief separately).
105
105
  */
106
106
  export declare function journaledFailureBrief(events: JournalEvent[], taskId: string): string[];
107
+ /**
108
+ * T6: the review findings still OUTSTANDING on a task. A review finding is a property of the TASK,
109
+ * not of the attempt that drew it: it stays outstanding until a later review PASSES on the task (or
110
+ * an operator approval settles it), and it therefore travels on EVERY dispatch until then.
111
+ *
112
+ * The two carries beside this one are attempt-scoped by construction and both lose it. The funded
113
+ * repair (`pendingRepairFindings`) is spent at the next `worker-launch` and is budgeted at two per
114
+ * engagement; `journaledFailureBrief` is reset at that same launch, so it hands the next brief only
115
+ * the LAST attempt's bytes. The moment one attempt fails for an unrelated reason — a red build, a
116
+ * refused tree, or a death that produces no verdict at all and journals no gate row whatsoever — the
117
+ * outstanding finding is in neither carry, and the run re-derives the task from the spec and lands on
118
+ * the same gap the reviewer already anchored.
119
+ *
120
+ * Retirement is closed and narrow: a review that PASSED, or the one approval that accepts the review
121
+ * gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"` — the operator taking the diff the
122
+ * reviewer rejected). Every other approval RETAINS. `--uphold` funds an attempt to FIX the findings;
123
+ * `--recheck` and an attempt-cap release fund another dispatch and say nothing about the reviewer's
124
+ * objection; a plain human-gate approval predates any review; a gate-satisfied release naming some
125
+ * other gate settled that gate, not this one. Reading "approved" as "settled" is how a still-open
126
+ * finding was dropped at the exact moment the operator paid for another attempt to fix it. A review
127
+ * that DECLINED (`skipped`) is not a verdict and neither adds nor retires — fail closed. Findings are
128
+ * keyed by fingerprint, so a reviewer restating one across rounds carries it once, not once per round.
129
+ */
130
+ export declare function outstandingReviewFindings(events: JournalEvent[], taskId: string): StructuredFinding[];
107
131
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
108
132
  export declare function pendingRepairFindings(events: JournalEvent[], taskId: string): string | undefined;
109
133
  /**
@@ -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) {
@@ -480,6 +487,49 @@ export function journaledFailureBrief(events, taskId) {
480
487
  }
481
488
  return rows;
482
489
  }
490
+ /**
491
+ * T6: the review findings still OUTSTANDING on a task. A review finding is a property of the TASK,
492
+ * not of the attempt that drew it: it stays outstanding until a later review PASSES on the task (or
493
+ * an operator approval settles it), and it therefore travels on EVERY dispatch until then.
494
+ *
495
+ * The two carries beside this one are attempt-scoped by construction and both lose it. The funded
496
+ * repair (`pendingRepairFindings`) is spent at the next `worker-launch` and is budgeted at two per
497
+ * engagement; `journaledFailureBrief` is reset at that same launch, so it hands the next brief only
498
+ * the LAST attempt's bytes. The moment one attempt fails for an unrelated reason — a red build, a
499
+ * refused tree, or a death that produces no verdict at all and journals no gate row whatsoever — the
500
+ * outstanding finding is in neither carry, and the run re-derives the task from the spec and lands on
501
+ * the same gap the reviewer already anchored.
502
+ *
503
+ * Retirement is closed and narrow: a review that PASSED, or the one approval that accepts the review
504
+ * gate itself (`GATE_SATISFIED_RELEASE` stamped `gate: "review"` — the operator taking the diff the
505
+ * reviewer rejected). Every other approval RETAINS. `--uphold` funds an attempt to FIX the findings;
506
+ * `--recheck` and an attempt-cap release fund another dispatch and say nothing about the reviewer's
507
+ * objection; a plain human-gate approval predates any review; a gate-satisfied release naming some
508
+ * other gate settled that gate, not this one. Reading "approved" as "settled" is how a still-open
509
+ * finding was dropped at the exact moment the operator paid for another attempt to fix it. A review
510
+ * that DECLINED (`skipped`) is not a verdict and neither adds nor retires — fail closed. Findings are
511
+ * keyed by fingerprint, so a reviewer restating one across rounds carries it once, not once per round.
512
+ */
513
+ export function outstandingReviewFindings(events, taskId) {
514
+ const open = new Map();
515
+ for (const e of events) {
516
+ if (e.taskId !== taskId)
517
+ continue;
518
+ if (e.event === "task-approved") {
519
+ if (e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
520
+ open.clear();
521
+ continue;
522
+ }
523
+ if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
524
+ continue;
525
+ if (e.data.pass !== false)
526
+ open.clear(); // a later review PASSED on this task: nothing is outstanding
527
+ else
528
+ for (const finding of findingRows(e, "review"))
529
+ open.set(finding.fingerprint, finding);
530
+ }
531
+ return [...open.values()];
532
+ }
483
533
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
484
534
  export function pendingRepairFindings(events, taskId) {
485
535
  const e = decisionForNextDispatch(events, taskId, "repair-attempt");
@@ -1055,12 +1105,16 @@ export class Journal {
1055
1105
  }
1056
1106
  replayResumeState() {
1057
1107
  const m = new Map();
1108
+ const events = this.read();
1109
+ // Keep the legacy resume-state field aligned with the journal-authoritative prompt-time fold.
1110
+ // In particular, a review pass after an uphold must erase the fallback daemon.ts may consult.
1111
+ const activeUpheldFeedback = upheldFeedbackByTask(events);
1058
1112
  const pendingReroute = new Set(); // reroute verdicts not yet cleared by a later dispatch
1059
1113
  // OBS-547: what the last dispatch ADDED, so a scope-authoring event can take it back. An
1060
1114
  // unchargeable dispatch must replay as if it never happened — no attempt counted, no channel burned.
1061
1115
  const lastDispatch = new Map();
1062
1116
  const lastReviewFail = new Map(); // OBS-189: newest failed review details per task
1063
- for (const e of this.read()) {
1117
+ for (const e of events) {
1064
1118
  if (!e.taskId)
1065
1119
  continue;
1066
1120
  if (e.event === "gate-result" && e.data.gate === "review" && e.data.pass === false
@@ -1160,6 +1214,13 @@ export class Journal {
1160
1214
  st.lastAssignment = undefined;
1161
1215
  }
1162
1216
  }
1217
+ for (const [taskId, st] of m) {
1218
+ const feedback = activeUpheldFeedback.get(taskId);
1219
+ if (feedback)
1220
+ st.upheldFeedback = feedback;
1221
+ else
1222
+ delete st.upheldFeedback;
1223
+ }
1163
1224
  return m;
1164
1225
  }
1165
1226
  // OBS-130: gate satisfaction is authority, not an inferred daemon state. Only an explicit
@@ -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;
@@ -1,4 +1,5 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { dirname, join } from "node:path";
3
4
  import { stateDirName, tickmarkrDir } from "../graph/graph.js";
4
5
  // SUP-01: supervision liveness as FILE STATE, not as a report — lock.ts's proven shape, one file per
@@ -32,8 +33,9 @@ export const SUPERVISION_FUTURE_GRACE_MS = 1_000;
32
33
  // SUP-05: CONTEXT is per SEAT, so its tiers are per seat too. One shared `context` tier would be read
33
34
  // by both supervising seats and beaten by whichever of them still had a watcher, so a live overseer
34
35
  // watcher would render the dead orchestrator one as armed — the mask this whole instrument exists to
35
- // remove. The enumeration is CLOSED at one tier per supervising seat: orchestrator and overseer.
36
- // `watch` beats nowhere on this tree and stays ABSENT, which is what ABSENT means.
36
+ // remove. The enumeration is CLOSED at one supervision tier and one context tier per supervising
37
+ // seat: orchestrator and overseer. `watch` is the sole process-owned tier and is armed seatlessly by
38
+ // the live unbounded board.
37
39
  export const SUPERVISION_TIERS = [
38
40
  "orchestrator", "orchestrator-context", "overseer", "overseer-context", "watch",
39
41
  ];
@@ -42,18 +44,59 @@ export const SUPERVISION_TIERS = [
42
44
  // to a seat. Measured 2026-08-26: a consult seat of another tier ran the documented beat loop and the
43
45
  // board read that tier armed with no seat of that tier having armed anything. ARMED-and-seatless reads
44
46
  // as coverage, which is worse than ABSENT, so on these tiers a record that names no seat is not a beat.
45
- export const SUPERVISION_SEAT_TIERS = ["orchestrator-context", "overseer-context"];
47
+ export const SUPERVISION_SEAT_TIERS = [
48
+ "orchestrator", "orchestrator-context", "overseer", "overseer-context",
49
+ ];
46
50
  /** Does this tier's record have to name the seat it speaks for? */
47
51
  export const isSeatTier = (tier) => SUPERVISION_SEAT_TIERS.includes(tier);
48
52
  // PURE path math: stateDirName, never tickmarkrDir — the latter mkdirs the state dir and writes its
49
53
  // .gitignore, so routing a READER through it would make status create the very tree it reports on.
50
- export const supervisionBeatPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.beat`);
54
+ const supervisionDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "supervision");
55
+ export const supervisionBeatPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.beat`);
51
56
  /** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
52
- export const supervisionStandDownPath = (repoRoot, tier) => join(repoRoot, stateDirName(repoRoot), "supervision", `${tier}.standdown`);
57
+ export const supervisionStandDownPath = (repoRoot, tier) => join(supervisionDir(repoRoot), `${tier}.standdown`);
58
+ // SUP-06: PRESENCE — one file per ARMED WATCHER, because a tier may legitimately have more than one.
59
+ // Two boards watch one repo the moment an operator opens a second pane, and the tier is armed while
60
+ // EITHER of them lives. The stand-down marker speaks for the whole tier, so the first board out
61
+ // writing it renders the second board's own tier DISARMED until that board's next beat — a live seat
62
+ // reported down, which is the under-claiming half of exactly the lie this instrument exists to remove.
63
+ // So the marker is written only by the LAST watcher out, and these files are how it knows it is last.
64
+ // Freshness decides presence, never a process table (SUP-02): a watcher that is killed cannot remove
65
+ // its own file, and an unremoved file ages past the same ceiling a beat does and stops counting.
66
+ const presencePrefix = (tier) => `${tier}.live.`;
67
+ const supervisionPresencePath = (repoRoot, tier, id) => join(supervisionDir(repoRoot), `${presencePrefix(tier)}${id}`);
68
+ /** Every presence file on this tier, by name. Missing directory ⇒ nobody is present. */
69
+ const presenceNames = (repoRoot, tier) => {
70
+ try {
71
+ return readdirSync(supervisionDir(repoRoot)).filter((n) => n.startsWith(presencePrefix(tier)));
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ };
77
+ /**
78
+ * Stale peers observed in ONE directory snapshot, or undefined when that same snapshot saw a live
79
+ * one. A later arm has a new id and is deliberately absent from the returned cleanup set.
80
+ */
81
+ function stalePeersIfLast(repoRoot, tier, id, now = Date.now()) {
82
+ const own = `${presencePrefix(tier)}${id}`;
83
+ const stale = [];
84
+ for (const name of presenceNames(repoRoot, tier)) {
85
+ if (name === own)
86
+ continue;
87
+ try {
88
+ if (now - statSync(join(supervisionDir(repoRoot), name)).mtimeMs <= SUPERVISION_STALE_MS)
89
+ return undefined;
90
+ stale.push(name);
91
+ }
92
+ catch { /* a vanished peer needs no cleanup and is not evidence of a live watcher */ }
93
+ }
94
+ return stale;
95
+ }
53
96
  // WRITER — a watcher's own call, on its own tier, every SUPERVISION_BEAT_MS. Never a reader's: the
54
97
  // purity fence (status --watch leaves the state dir byte-identical) is the test that catches a reader
55
98
  // that beats on the watcher's behalf, which would report every dead tier as healthy.
56
- export function beatSupervision(repoRoot, tier, seat) {
99
+ function writeSupervisionBeat(repoRoot, tier, seat, armId) {
57
100
  // A seat tier may not be armed anonymously, and the refusal belongs HERE rather than only in the
58
101
  // verb: any caller that could write a seatless record could arm a tier nobody occupies.
59
102
  if (isSeatTier(tier) && !seat?.trim()) {
@@ -62,7 +105,13 @@ export function beatSupervision(repoRoot, tier, seat) {
62
105
  tickmarkrDir(repoRoot); // the write path DOES create — beats land inside the gitignored state dir
63
106
  const p = supervisionBeatPath(repoRoot, tier);
64
107
  mkdirSync(dirname(p), { recursive: true });
65
- writeFileSync(p, JSON.stringify({ tier, ...(seat ? { seat } : {}), pid: process.pid, beatAt: new Date().toISOString() }) + "\n");
108
+ writeFileSync(p, JSON.stringify({
109
+ tier, ...(seat ? { seat } : {}), ...(armId ? { armId } : {}),
110
+ pid: process.pid, beatAt: new Date().toISOString(),
111
+ }) + "\n");
112
+ }
113
+ export function beatSupervision(repoRoot, tier, seat) {
114
+ writeSupervisionBeat(repoRoot, tier, seat);
66
115
  }
67
116
  // THE WATCHER-FACING ENTRY POINT — the loop SUPERVISION_BEAT_MS actually drives. A supervising seat
68
117
  // calls this once at the top of its watch and holds the handle for the duration; a seat that dies,
@@ -71,9 +120,11 @@ export function beatSupervision(repoRoot, tier, seat) {
71
120
  // an unref'd interval that never holds the watcher's event loop open, and a beat failure that is
72
121
  // swallowed rather than crashing the watcher — an unwritten beat ages out and reads STALE, which is
73
122
  // the truth. The FIRST beat is swallowed on the same rule: a cosmetic instrument that could not write
74
- // must not take the run down with it. NOT called from `status`: status is a reader (its purity fence
75
- // is a test); the in-repo callsite is runDaemon, which arms the orchestrator tier for the life of
76
- // the run. A tier nobody arms reads ABSENT — exactly what ABSENT means, not a false "healthy".
123
+ // must not take the watcher down with it. The sole production in-repo callsite is `status --watch`
124
+ // when UNBOUNDED, which arms `watch` seatlessly for the life of the board — a bounded render is a
125
+ // reader and arms nothing, which is the purity fence D-02 tests. Supervising seats write their named
126
+ // tiers through the shipped beat verb instead. A tier nobody arms reads ABSENT — exactly what ABSENT
127
+ // means, not a false "healthy".
77
128
  //
78
129
  // Arming CLEARS any prior stand-down record: a tier that stood down and armed again is armed, and a
79
130
  // marker left behind by the last run would otherwise report the live one as stood down forever.
@@ -86,40 +137,64 @@ export function armSupervision(repoRoot, tier, beatMs = SUPERVISION_BEAT_MS, sea
86
137
  rmSync(supervisionStandDownPath(repoRoot, tier), { force: true, recursive: true });
87
138
  }
88
139
  catch { /* uncleared: the reader validates the marker and a newer beat outranks it — never masked */ }
89
- try {
90
- beatSupervision(repoRoot, tier, seat);
91
- }
92
- catch { /* repo gone / disk full / no seat — the tier reads ABSENT rather than crashing its watcher */ }
93
- const timer = setInterval(() => {
140
+ // This watcher's own identity fences BOTH its presence and its stand-down against every later arm.
141
+ // pid alone collides between two boards in one host (and after pid reuse); a UUID never aliases the
142
+ // stale presence of a killed process that a later last-one-out cleanup may already have observed.
143
+ const id = `${process.pid}.${randomUUID()}`;
144
+ const presence = supervisionPresencePath(repoRoot, tier, id);
145
+ // Presence is refreshed with the beat, so it ages by the same clock and needs no separate loop.
146
+ const mark = () => {
94
147
  try {
95
- beatSupervision(repoRoot, tier, seat);
148
+ writeSupervisionBeat(repoRoot, tier, seat, id); // creates the directory presence is written into
149
+ writeFileSync(presence, JSON.stringify({ tier, pid: process.pid, id }) + "\n");
96
150
  }
97
- catch { /* repo gone / disk full — let the beat expire */ }
98
- }, beatMs);
151
+ catch { /* repo gone / disk full / no seat — the tier ages out rather than crashing its watcher */ }
152
+ };
153
+ mark();
154
+ const timer = setInterval(mark, beatMs);
99
155
  timer.unref();
100
156
  let stoodDown = false;
101
157
  return {
102
- // Stand down: stop beating AND say so. Idempotent because the daemon disarms from more than one
103
- // exit path (its signal reaper exits the process before the finally can run), and the recorded
104
- // instant belongs to the first stand-down.
158
+ // Stand down: stop beating AND say so. Idempotence lets a watcher safely share cleanup across
159
+ // multiple exit paths; the recorded instant belongs to the first stand-down.
105
160
  disarm: () => {
106
161
  if (stoodDown)
107
162
  return;
108
163
  stoodDown = true;
109
164
  clearInterval(timer);
165
+ try {
166
+ rmSync(presence, { force: true, recursive: true });
167
+ }
168
+ catch { /* ages out on its own */ }
169
+ // The marker speaks for the TIER, so only the last watcher out may write one: a peer still
170
+ // present means the tier is not down, and saying it is would render that live board's own tier
171
+ // DISARMED. The snapshot also fixes the cleanup set: a board arming after this decision receives
172
+ // a new id, so this older board can neither sweep its presence nor claim its beat stood down.
173
+ const stalePeers = stalePeersIfLast(repoRoot, tier, id);
174
+ if (stalePeers === undefined)
175
+ return;
110
176
  // Published ATOMICALLY — written aside, renamed over — so no reader can ever meet a half-written
111
177
  // marker. A torn marker is rejected anyway (see readStandDown), but a stand-down that reads as
112
178
  // garbage is a stand-down that reports as a death, and the rename costs one line.
113
179
  const p = supervisionStandDownPath(repoRoot, tier);
114
- const tmp = `${p}.${process.pid}.tmp`;
180
+ const tmp = `${p}.${id}.tmp`;
115
181
  try {
116
182
  mkdirSync(dirname(p), { recursive: true });
117
183
  writeFileSync(tmp, JSON.stringify({
118
- tier, ...(seat ? { seat } : {}), pid: process.pid, disarmedAt: new Date().toISOString(),
184
+ tier, ...(seat ? { seat } : {}), armId: id,
185
+ pid: process.pid, disarmedAt: new Date().toISOString(),
119
186
  }) + "\n");
120
187
  renameSync(tmp, p);
121
188
  }
122
189
  catch { /* unrecordable stand-down ages out as STALE — pessimistic, which is the safe way to fail */ }
190
+ // Sweep only stale names in the pre-publication snapshot. Re-reading here used to catch and
191
+ // delete a newer board that armed between the peer check and this older board's rename.
192
+ for (const name of stalePeers) {
193
+ try {
194
+ rmSync(join(supervisionDir(repoRoot), name), { force: true, recursive: true });
195
+ }
196
+ catch { /* next sweep */ }
197
+ }
123
198
  },
124
199
  };
125
200
  }
@@ -153,20 +228,26 @@ function readBeat(repoRoot, tier) {
153
228
  // is not evidence that nobody armed the tier). On a SEAT tier it is the opposite: a record naming no
154
229
  // seat leaves the tier armed and unattributable, which reads as coverage no seat is providing, so
155
230
  // it is UNREADABLE — something is there and no beat any reader can attribute comes out of it.
156
- const seat = beatSeat(p);
231
+ const { seat, armId } = beatMetadata(p);
157
232
  if (isSeatTier(tier) && seat === undefined)
158
233
  return "UNREADABLE";
159
- return { mtimeMs: st.mtimeMs, ...(seat !== undefined ? { seat } : {}) };
234
+ return {
235
+ mtimeMs: st.mtimeMs,
236
+ ...(seat !== undefined ? { seat } : {}),
237
+ ...(armId !== undefined ? { armId } : {}),
238
+ };
160
239
  }
161
- /** The seat a record declares, or undefined for any record that declares none this reader can use. */
162
- function beatSeat(path) {
240
+ /** Optional metadata declared by a beat; its mtime remains the only source of age. */
241
+ function beatMetadata(path) {
163
242
  try {
164
243
  const rec = JSON.parse(readFileSync(path, "utf8"));
165
- return typeof rec?.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
244
+ const seat = typeof rec?.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
245
+ const armId = typeof rec?.armId === "string" && rec.armId.trim() ? rec.armId : undefined;
246
+ return { ...(seat !== undefined ? { seat } : {}), ...(armId !== undefined ? { armId } : {}) };
166
247
  }
167
248
  catch {
168
- return undefined;
169
- } // unparseable bytes name no seat — the caller decides what that means
249
+ return {};
250
+ } // unparseable bytes name no seat or arm — the caller decides what that means
170
251
  }
171
252
  function beatLiveness(tier, beat, now) {
172
253
  if (typeof beat !== "object")
@@ -203,6 +284,7 @@ function readStandDown(repoRoot, tier) {
203
284
  if (!st.isFile())
204
285
  return "UNREADABLE";
205
286
  let seat;
287
+ let armId;
206
288
  try {
207
289
  const rec = JSON.parse(readFileSync(p, "utf8"));
208
290
  if (rec?.tier !== tier)
@@ -210,6 +292,7 @@ function readStandDown(repoRoot, tier) {
210
292
  if (typeof rec.disarmedAt !== "string" || Number.isNaN(Date.parse(rec.disarmedAt)))
211
293
  return "UNREADABLE";
212
294
  seat = typeof rec.seat === "string" && rec.seat.trim() ? rec.seat : undefined;
295
+ armId = typeof rec.armId === "string" && rec.armId.trim() ? rec.armId : undefined;
213
296
  // A seat tier's hand-off names WHICH seat left, on the same rule as its beat: an anonymous
214
297
  // stand-down on a per-seat tier says a watcher left without saying whose, so it is no record.
215
298
  if (isSeatTier(tier) && seat === undefined)
@@ -218,18 +301,26 @@ function readStandDown(repoRoot, tier) {
218
301
  catch {
219
302
  return "UNREADABLE";
220
303
  } // unparseable or unreadable bytes — not a stand-down anyone can read
221
- return { mtimeMs: st.mtimeMs, ...(seat !== undefined ? { seat } : {}) };
304
+ return {
305
+ mtimeMs: st.mtimeMs,
306
+ ...(seat !== undefined ? { seat } : {}),
307
+ ...(armId !== undefined ? { armId } : {}),
308
+ };
222
309
  }
223
310
  // THE TIER'S STATE — what every surface and every operator reads. A valid stand-down outranks the beat:
224
311
  // the watcher that wrote it is gone ON PURPOSE, and its last beat ages out exactly like a dead one's
225
- // would. It outranks the beat it FOLLOWED and no other — a beat stamped after the marker was written by
226
- // a watcher that armed again, so a marker some failed cleanup left behind can never mask a live tier.
312
+ // would. It outranks the beat it FOLLOWED and no other — a later timestamp OR a FRESH different
313
+ // armed-watcher identity is another arm, so a marker whose rename lost that race cannot mask a live
314
+ // watcher. Once that foreign beat is stale, a newer clean hand-off must win: otherwise overlapping
315
+ // boards closed in last-beater-first order would leave the tier reporting a death forever.
227
316
  export function supervisionStatus(repoRoot, tier, now = Date.now()) {
228
317
  const beat = readBeat(repoRoot, tier);
229
318
  const standDown = readStandDown(repoRoot, tier);
230
319
  if (standDown === "UNREADABLE")
231
320
  return { tier, state: "UNREADABLE" };
232
- if (standDown !== "NONE" && !(typeof beat === "object" && beat.mtimeMs > standDown.mtimeMs)) {
321
+ const beatOutranksStandDown = standDown !== "NONE" && typeof beat === "object" && (beat.mtimeMs > standDown.mtimeMs || (now - beat.mtimeMs <= SUPERVISION_STALE_MS &&
322
+ beat.armId !== undefined && standDown.armId !== undefined && beat.armId !== standDown.armId));
323
+ if (standDown !== "NONE" && !beatOutranksStandDown) {
233
324
  // the seat that stood down is named by the marker, falling back to whatever its last beat named
234
325
  const seat = standDown.seat ?? (typeof beat === "object" ? beat.seat : undefined);
235
326
  return { tier, state: "DISARMED", ...(seat !== undefined ? { seat } : {}) };
@@ -1 +1,5 @@
1
+ ---
2
+ status: complete
3
+ ---
4
+
1
5
  # done earlier
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -81,7 +81,7 @@ handoff_fresh() {
81
81
  [ -f "$HANDOFF" ] || return 1
82
82
  local age now mt
83
83
  now=$(date +%s)
84
- mt=$(stat -f %m "$HANDOFF" 2>/dev/null || stat -c %Y "$HANDOFF" 2>/dev/null) || return 1
84
+ mt=$(stat -c %Y "$HANDOFF" 2>/dev/null || stat -f %m "$HANDOFF" 2>/dev/null) || return 1
85
85
  age=$((now - mt))
86
86
  [ "$age" -le "$MAXAGE" ]
87
87
  }