tickmarkr 2.6.7 → 2.6.9

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 (43) hide show
  1. package/dist/adapters/catalog-remote.js +5 -2
  2. package/dist/adapters/codex.d.ts +1 -0
  3. package/dist/adapters/codex.js +18 -10
  4. package/dist/adapters/model-lints.js +21 -11
  5. package/dist/adapters/model-windows.js +0 -1
  6. package/dist/adapters/registry.js +7 -3
  7. package/dist/adapters/types.d.ts +2 -0
  8. package/dist/cli/commands/beat.d.ts +3 -3
  9. package/dist/cli/commands/beat.js +39 -19
  10. package/dist/cli/commands/doctor.js +3 -0
  11. package/dist/cli/commands/fleet.js +2 -0
  12. package/dist/cli/commands/plan.js +16 -18
  13. package/dist/cli/commands/verify.js +4 -2
  14. package/dist/cli/help.js +5 -2
  15. package/dist/config/config.js +6 -5
  16. package/dist/drivers/herdr.js +2 -2
  17. package/dist/drivers/orca.d.ts +12 -4
  18. package/dist/drivers/orca.js +200 -39
  19. package/dist/gates/evidence.js +3 -3
  20. package/dist/gates/review.d.ts +1 -1
  21. package/dist/gates/review.js +62 -13
  22. package/dist/gates/run-gates.d.ts +3 -0
  23. package/dist/gates/run-gates.js +115 -20
  24. package/dist/gates/test-manifest.d.ts +36 -2
  25. package/dist/gates/test-manifest.js +55 -10
  26. package/dist/run/beat-lifecycle.d.ts +109 -24
  27. package/dist/run/beat-lifecycle.js +1173 -216
  28. package/dist/run/daemon.d.ts +4 -0
  29. package/dist/run/daemon.js +111 -33
  30. package/dist/run/git-trust.d.ts +78 -3
  31. package/dist/run/git-trust.js +175 -19
  32. package/dist/run/git.d.ts +24 -3
  33. package/dist/run/git.js +140 -21
  34. package/dist/run/merge.js +2 -2
  35. package/dist/run/verification-job.d.ts +2 -0
  36. package/dist/run/verification-job.js +32 -10
  37. package/dist/tui/ink/fleet-app.d.ts +7 -2
  38. package/dist/tui/ink/fleet-app.js +81 -23
  39. package/package.json +4 -1
  40. package/skills/tickmarkr-auto/SKILL.md +58 -14
  41. package/skills/tickmarkr-loop/SKILL.md +65 -14
  42. package/skills/tickmarkr-overseer/SKILL.md +97 -28
  43. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -9,7 +9,8 @@ import { GATE_NAMES } from "../graph/schema.js";
9
9
  import { acceptanceGate } from "./acceptance.js";
10
10
  import { classifiedFileDurations, compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady } from "./baseline.js";
11
11
  import { evidenceGate } from "./evidence.js";
12
- import { captureLlmOutput } from "./llm.js";
12
+ import { captureLlmOutput, SeatLaunchError } from "./llm.js";
13
+ import { withLaunchGuard } from "../drivers/orca.js";
13
14
  import { disallowedBy, observedSeat } from "../route/preference.js";
14
15
  import { marginalCostRank } from "../route/router.js";
15
16
  import { carriedAuthorVendors, gateReviewerFloor, pickReviewer, reviewGate } from "./review.js";
@@ -1243,27 +1244,66 @@ export async function runGates(task, ctx) {
1243
1244
  // seat spends two invocations, and one blended span cannot tell a slow reviewer from two.
1244
1245
  // A pick that found NO eligible seat dispatched nothing, so it contributes no invocation.
1245
1246
  const invocations = [];
1247
+ const retiredNow = () => [...(ctx.reviewNoVerdicts ?? [])].filter(([, causes]) => causes.length >= 2).map(([seat]) => seat);
1248
+ // v2.6.8 T1: retirement is live through the LAUNCH, not only the pick. reviewGate awaits its diff read
1249
+ // after it picks, and a pane seat awaits its slot, so another task can retire the picked seat inside
1250
+ // either await. The seat is named when its command is built and checked against the live tally there and
1251
+ // again as its pane is bound (onSlot runs inside llm.ts's launch guard, which closes that pane unrun), and
1252
+ // a driver that still awaits lookups before it submits the terminal (Orca's runtime probe and worktree
1253
+ // lookup) re-reads it at that submission (withLaunchGuard): a retired seat is never launched, and dispatch
1254
+ // picks again under the live exclusions (atPick) — the next eligible seat, or none. Bounded: each
1255
+ // withdrawal is a seat newly retired, and the pool is finite.
1256
+ let launching = {};
1257
+ const refuseRetired = () => {
1258
+ if (launching.seat === undefined || !retiredNow().includes(launching.seat))
1259
+ return;
1260
+ launching.withdrawn = true;
1261
+ throw new SeatLaunchError(launching.seat, new Error("retired for the run before it launched"));
1262
+ };
1263
+ const reviewVia = via && { ...via, onSlot: (slot) => { via.onSlot?.(slot); refuseRetired(); } };
1264
+ const guarded = (adapter) => new Proxy(adapter, {
1265
+ get(target, property) {
1266
+ if (property === "headlessCommand") {
1267
+ return (promptFile, model, effort) => {
1268
+ launching.seat = channelKey({ adapter: target.id, model });
1269
+ refuseRetired();
1270
+ return target.headlessCommand(promptFile, model, effort);
1271
+ };
1272
+ }
1273
+ const value = Reflect.get(target, property, target);
1274
+ return typeof value === "function" ? value.bind(target) : value;
1275
+ },
1276
+ });
1246
1277
  // Dispatch is PROVEN, never inferred: captureLlmOutput records one output per runLlm return, so an
1247
1278
  // empty capture means reviewGate returned before asking anyone — a policy skip, a pre-dispatch diff
1248
1279
  // cap, or no eligible seat. Reading `noEligibleReviewer` alone missed the first two and invented
1249
1280
  // an "unknown" span for each.
1250
1281
  const dispatch = async (run) => {
1251
- const captured = await captureLlmDispatches(ctx.adapters, run);
1252
- invocations.push(...captured.invocations);
1253
- const rv = captured.value;
1282
+ const withdrawn = [];
1283
+ let picked;
1284
+ for (;;) {
1285
+ launching = {};
1286
+ const captured = await withLaunchGuard(refuseRetired, () => captureLlmDispatches(ctx.adapters.map(guarded), run));
1287
+ invocations.push(...captured.invocations);
1288
+ picked = captured.value;
1289
+ if (cancelled || !launching.withdrawn)
1290
+ break;
1291
+ withdrawn.push(launching.seat);
1292
+ }
1293
+ const rv = withdrawn.length === 0 ? picked : { ...picked, details: `${withdrawn.map((seat) => `review dispatch withdrawn: ${seat} was retired for the run (two real no-verdicts) after it was picked, before it launched; picked again`).join("\n")}\n${picked.details}` };
1254
1294
  if (cancelled)
1255
1295
  return rv; // a cancelled seat's non-answer says nothing about the seat
1256
1296
  if (rv.meta?.noVerdict === true || rv.meta?.unparseable === true) {
1257
1297
  await ctx.onGate?.({ phase: "note", gate: "review", name: "review-no-verdict", payload: { ...rv.meta }, result: rv });
1258
- if (typeof rv.meta.reviewer === "string") {
1298
+ // v2.6.8 T1: a seat that never LAUNCHED (any launchCause, checkout-proof-timeout or none) said
1299
+ // nothing about the work or about itself: no run-scoped strike and no soft demotion. It gets one
1300
+ // bounded same-seat launch retry below and is excluded for the rest of THIS round only.
1301
+ if (typeof rv.meta.reviewer === "string" && rv.meta.cause !== "seat-launch-failed") {
1259
1302
  const reviewer = rv.meta.reviewer;
1260
1303
  const cause = String(rv.meta.cause);
1261
- // E1: a checkout-proof timeout is the host being slow to print the seat's proof, not the seat
1262
- // failing. It is still a no-verdict re-routed away from for THIS round, but it never strikes the
1263
- // run-scoped tally; every other launch failure (foreign or malformed proof included) does.
1264
- const proofTimeout = cause === "seat-launch-failed" && rv.meta.launchCause === "checkout-proof-timeout";
1265
- // OBS-1025 add.2: the run-scoped tally; two no-verdicts retire the seat for the rest of the run.
1266
- const causes = rv.meta.noVerdict === true && ctx.reviewNoVerdicts && !proofTimeout
1304
+ // OBS-1025 add.2: the run-scoped tally of REAL no-verdicts (silent, truncated, closure-mismatch,
1305
+ // no nonce …); two of them retire the seat for the run — a hard exclusion, never a soft ranking.
1306
+ const causes = rv.meta.noVerdict === true && ctx.reviewNoVerdicts
1267
1307
  ? [...(ctx.reviewNoVerdicts.get(reviewer) ?? []), cause] : undefined;
1268
1308
  if (causes)
1269
1309
  ctx.reviewNoVerdicts.set(reviewer, causes);
@@ -1279,15 +1319,34 @@ export async function runGates(task, ctx) {
1279
1319
  }
1280
1320
  return rv;
1281
1321
  };
1282
- // OBS-1025 add.2: seats with two no-verdicts this run are out of the rotation for every pick below.
1283
- const retired = [...(ctx.reviewNoVerdicts ?? [])].filter(([, causes]) => causes.length >= 2).map(([seat]) => seat);
1322
+ // OBS-1025 add.2: seats with two REAL no-verdicts this run (launch failures never strike) are RETIRED:
1323
+ // out of every pick below for the rest of the run, and a resumed daemon re-seeds the tally from the
1324
+ // journal. demotedReviewers is only a soft preference, live or replayed on resume — it ranks a seat
1325
+ // last and never excludes or retires it; history or prefer can never resurrect a retired seat.
1326
+ // The tally is shared across the run's tasks, so it is read LIVE at every pick — after every awaited
1327
+ // notification and inside reviewGate after its promotion reads (atPick), since another task can retire a
1328
+ // seat inside any await: a seat retired while this round's dispatch, note or read is pending is out of
1329
+ // the initial pick, the launch retry, the re-emission and the re-route alike — and one retired after its
1330
+ // pick is refused at launch (dispatch, above).
1331
+ const retired = retiredNow();
1332
+ // reviewGate awaits its promotion path reads BEFORE it picks, and another task can retire a seat inside
1333
+ // that await too. So the exclusions every reviewGate call is handed are a live view: each read — the pick's
1334
+ // included — answers from `fixed` plus the tally as it stands at that read, never a copy taken before.
1335
+ // ponytail: a read-through Proxy because review.ts takes a plain list; a live-exclusion parameter there retires it.
1336
+ const atPick = (fixed) => new Proxy([], {
1337
+ get: (_target, prop) => {
1338
+ const now = [...new Set([...fixed, ...retiredNow()])];
1339
+ const value = Reflect.get(now, prop);
1340
+ return typeof value === "function" ? value.bind(now) : value;
1341
+ },
1342
+ });
1284
1343
  // RF-1: THIS task's prior reviewers — earlier rounds' seats plus the seats that produced garbage for
1285
1344
  // it (excludeReviewers names only dispatched seats). Kept apart from the eligibility exclusions the
1286
1345
  // retry below adds for a flaked seat's whole adapter: those sibling channels never reviewed.
1287
1346
  const priorReviewers = [...(ctx.priorReviewers ?? []), ...(ctx.excludeReviewers ?? [])];
1288
1347
  const carriedAuthors = ctx.carriedAuthors ?? [];
1289
1348
  let exclusions = [...(ctx.excludeReviewers ?? []), ...retired];
1290
- let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, via, exclusions, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, priorReviewers, carriedAuthors, ctx.operatorContext));
1349
+ let rv = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, reviewVia, atPick(exclusions), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, priorReviewers, carriedAuthors, ctx.operatorContext));
1291
1350
  // OBS-193/574: an unparseable review verdict retries the REVIEW, preferring a different adapter. Only
1292
1351
  // a single-adapter eligible pool may fall back to another channel on the flaked adapter. The flaked
1293
1352
  // verdict never enters results; an exhausted pool preserves its cause.
@@ -1299,19 +1358,50 @@ export async function runGates(task, ctx) {
1299
1358
  let hop = 0;
1300
1359
  // OBS-1196: seats already re-asked after a malformed verdict — once per seat, so never unbounded.
1301
1360
  const reemitted = new Set();
1361
+ // v2.6.8 T1: seats already relaunched after a launch failure — once per seat per candidate (the
1362
+ // daemon replays the spent ones), so a launch retry is bounded and never a second malformed allowance.
1363
+ const launchRetried = new Set(ctx.launchRetried ?? []);
1302
1364
  while (!cancelled && (rv.meta?.unparseable === true || rv.meta?.noVerdict === true) && typeof rv.meta.reviewer === "string") {
1303
1365
  hop++;
1304
1366
  const flaked = rv.meta.reviewer;
1305
- const retryVia = via
1306
- ? { ...via, nameFor: (role, adapter) => via.nameFor(role, adapter) + `-r${hop}` }
1367
+ const retryVia = reviewVia
1368
+ ? { ...reviewVia, nameFor: (role, adapter) => reviewVia.nameFor(role, adapter) + `-r${hop}` }
1307
1369
  : undefined;
1370
+ // v2.6.8 T1: bounded same-seat launch retry. A seat that could not launch is launched ONCE more
1371
+ // (fresh launch identity, fresh nonce, same subject) before any other seat is picked, journaled as
1372
+ // the review-infra-retry naming that seat; its verdict decides. A second refusal re-routes below. A
1373
+ // seat two real no-verdicts retired — before the note, while it was awaited, or during the relaunch's
1374
+ // own promotion read (atPick) — is never relaunched: the withdrawn retry is said in the round's
1375
+ // details, and the re-route below excludes the seat.
1376
+ if (rv.meta.cause === "seat-launch-failed" && !launchRetried.has(flaked) && !retiredNow().includes(flaked)) {
1377
+ launchRetried.add(flaked);
1378
+ await ctx.onGate?.({ phase: "note", gate: "review", name: "review-infra-retry", payload: { reviewer: flaked, cause: "seat-launch-failed",
1379
+ sameSeat: true, ...(rv.meta.launchCause ? { launchCause: rv.meta.launchCause } : {}) }, result: rv });
1380
+ const others = ctx.channels.map(channelKey).filter((key) => key !== flaked);
1381
+ const again = retiredNow().includes(flaked) ? undefined : await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(others), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
1382
+ if (again && again.meta?.noEligibleReviewer !== true) {
1383
+ // The relaunch that completes a malformed verdict's re-emission (the re-emission was what failed to
1384
+ // launch) publishes that delivery, so the seat is a delivery flake again, not a bad reviewer.
1385
+ const reemission = rv.meta.reviewReemission?.reviewer === flaked;
1386
+ if (reemission && again.meta?.unparseable !== true && again.meta?.noVerdict !== true) {
1387
+ await ctx.onGate?.({ phase: "note", gate: "review", name: "review-reemission", payload: { reviewer: flaked, cause: "malformed-verdict",
1388
+ delivered: true, launchRetry: true }, result: again });
1389
+ }
1390
+ routes.push(`review launch retry (same seat, fresh launch): ${flaked} failed to launch; launched once more`);
1391
+ rv = { ...again, details: `${routes.join("\n")}\n${again.details}`, meta: { ...again.meta, reviewLaunchRetry: { reviewer: flaked } } };
1392
+ continue;
1393
+ }
1394
+ if (retiredNow().includes(flaked)) {
1395
+ routes.push(`review launch retry withdrawn: ${flaked} was retired for the run (two real no-verdicts) while its relaunch was pending`);
1396
+ }
1397
+ }
1308
1398
  // OBS-1196: a malformed (unparseable) verdict is a delivery defect of THIS seat, not a reason to drop it. Ask the
1309
1399
  // same seat once more on the same subject; reviewGate mints a fresh nonce, so only a new, whole,
1310
1400
  // nonce-bound verdict can answer — the malformed bytes are never salvaged into one.
1311
- if (rv.meta.cause === "malformed-verdict" && rv.meta.closureInvalid !== true && !reemitted.has(flaked)) {
1401
+ if (rv.meta.cause === "malformed-verdict" && rv.meta.closureInvalid !== true && !reemitted.has(flaked) && !retiredNow().includes(flaked)) {
1312
1402
  reemitted.add(flaked);
1313
1403
  const others = ctx.channels.map(channelKey).filter((key) => key !== flaked);
1314
- const again = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, others, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
1404
+ const again = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(others), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
1315
1405
  if (again.meta?.noEligibleReviewer !== true) {
1316
1406
  await ctx.onGate?.({ phase: "note", gate: "review", name: "review-reemission", payload: { reviewer: flaked, cause: "malformed-verdict",
1317
1407
  delivered: again.meta?.unparseable !== true && again.meta?.noVerdict !== true }, result: again });
@@ -1319,6 +1409,9 @@ export async function runGates(task, ctx) {
1319
1409
  rv = { ...again, details: `${routes.join("\n")}\n${again.details}`, meta: { ...again.meta, reviewReemission: { reviewer: flaked } } };
1320
1410
  continue;
1321
1411
  }
1412
+ if (retiredNow().includes(flaked)) {
1413
+ routes.push(`review re-emission withdrawn: ${flaked} was retired for the run (two real no-verdicts) while its re-emission was pending`);
1414
+ }
1322
1415
  }
1323
1416
  const emptyOutput = rv.meta.cause === "empty-output";
1324
1417
  if (emptyOutput) {
@@ -1334,16 +1427,18 @@ export async function runGates(task, ctx) {
1334
1427
  // and the prior reviewers' tiers, the flaked seat's own included, so a retry never drops a tier.
1335
1428
  retryPrior = [...retryPrior, flaked];
1336
1429
  const retryFloor = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, retryPrior).floor;
1430
+ exclusions = [...new Set([...exclusions, ...retiredNow()])];
1337
1431
  const crossAdapter = pickReviewer(ctx.author, ctx.channels, [...exclusions, ...adapterExclusions], ctx.cfg.review.prefer ?? [], retryFloor, undefined, undefined, undefined, carriedAuthorVendors(ctx.channels, carriedAuthors));
1338
1432
  const exclusion = crossAdapter ? "adapter" : "channel";
1339
1433
  exclusions = [...exclusions, ...(crossAdapter ? adapterExclusions : [flaked])];
1340
- const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, exclusions, ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
1434
+ const second = await dispatch((adapters) => reviewGate(task, ctx.worktree, ctx.baseRef, ctx.author, ctx.channels, adapters, ctx.cfg, retryVia, atPick(exclusions), ctx.artifactDir, ctx.reviewHistory, ctx.demotedReviewers, ctx.carriedFindings, retryPrior, carriedAuthors, ctx.operatorContext));
1341
1435
  if (second.meta?.noEligibleReviewer !== true) {
1342
1436
  const retried = typeof second.meta?.reviewer === "string" ? second.meta.reviewer : "none";
1343
1437
  const route = exclusion === "adapter"
1344
1438
  ? `different-adapter retry; excluded flaked adapter ${flakedAdapter}`
1345
1439
  : `same-adapter fallback; excluded flaked channel ${flaked}`;
1346
- const produced = emptyOutput ? "EMPTY output" : rv.meta.cause === "closure-mismatch" ? "a verdict closing no carried fingerprint" : "no parseable verdict";
1440
+ const produced = emptyOutput ? "EMPTY output" : rv.meta.cause === "closure-mismatch" ? "a verdict closing no carried fingerprint"
1441
+ : rv.meta.cause === "seat-launch-failed" ? "no verdict (it failed to launch)" : "no parseable verdict";
1347
1442
  // `details` is lifted onto the journal's gate-result row; meta.reviewRetry is not. Keep every
1348
1443
  // re-route visible in the result text a reader actually opens, including on a red retry.
1349
1444
  routes.push(`review re-route (${route}): ${flaked} produced ${produced}; replaced by ${retried}`);
@@ -88,13 +88,16 @@ export declare function verifyManifestReport(opts: {
88
88
  export declare const FILE_HANG_SLACK = 3;
89
89
  export declare const DEFAULT_FILE_HANG_BUDGET_MS = 60000;
90
90
  /**
91
- * No file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins below it). A fast
92
- * suite's per-file timings are a few ms, so 3 × longest killed a new or slower file that would have passed, or a scheduling gap, as an infra hang.
91
+ * No measured-green file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins
92
+ * below it). A fast suite's per-file timings are a few ms, so 3 × its own killed a slower file that would have passed.
93
93
  */
94
94
  export declare const MIN_FILE_HANG_BUDGET_MS = 10000;
95
95
  /** Tests of the baseline-derived budget's kill mechanics opt out of the floor; production never does. */
96
96
  export declare const setHangBudgetFloorForTests: (ms: number) => void;
97
97
  export declare const resetHangBudgetFloorForTests: () => void;
98
+ /** v2.6.8 P4 (prospectively superseding v2.5.5's "untimed = 3 × longest"): only the file's OWN usable green duration K
99
+ * buys measured slack, max(3 × K, L) with L the longest usable entry (red included) or legacy longestFile. An untimed,
100
+ * baseline-red or legacy longest-only file has no measure of its runtime and gets the finite battery ceiling. */
98
101
  export declare function fileHangBudgetMs(file: string, baselineDurations?: readonly BaselineFileDuration[] | null, ceilingMs?: number, longestFile?: BaselineFileDuration | null): number;
99
102
  /**
100
103
  * OBS-953 (+add): a per-file hang budget counted in raw wall time charged a lid-close to the file that
@@ -130,6 +133,37 @@ export declare function classifyPollGap(prev: {
130
133
  wall: number;
131
134
  mono: number;
132
135
  }, pollMs: number): HostInterruption | undefined;
136
+ /**
137
+ * v2.6.8 P4: an overdue poll on Darwin asks the kernel when it last slept and woke (`sysctl -n kern.sleeptime
138
+ * kern.waketime`), synchronously and bounded, before that poll's hang decision; the segment's overlap with the gap is
139
+ * credited as `host-suspend`. Unreadable evidence is unknown and credits 0. NAMED RESIDUAL: the kernel keeps only the
140
+ * MOST RECENT segment, so a gap holding several sleeps or maintenance DarkWakes credits only the last one — that
141
+ * under-credit fails closed: the file can still be killed as a hang, as on 2.6.7.
142
+ */
143
+ export interface SleepSegment {
144
+ sleep: number;
145
+ wake: number;
146
+ }
147
+ export interface SleepEvidence {
148
+ platform: () => NodeJS.Platform;
149
+ /** The synchronous OS-command boundary (spawnSync's shape). */
150
+ command: (file: string, args: string[], opts: {
151
+ encoding: "utf8";
152
+ timeout: number;
153
+ maxBuffer: number;
154
+ killSignal: "SIGKILL";
155
+ }) => {
156
+ status: number | null;
157
+ signal: NodeJS.Signals | null;
158
+ stdout?: string;
159
+ stderr?: string;
160
+ error?: Error;
161
+ };
162
+ /** Replaces the default sysctl reader; production supplies none. */
163
+ reader?: () => SleepSegment | undefined;
164
+ }
165
+ export declare const setSleepEvidenceForTests: (evidence: Partial<SleepEvidence>) => void;
166
+ export declare const resetSleepEvidenceForTests: () => void;
133
167
  export declare const runWithInterruptionSink: <T>(sink: (interruption: HostInterruption) => void, run: () => Promise<T>) => Promise<T>;
134
168
  export interface ManifestRunResult {
135
169
  evidenceReceipt: GateEvidenceReceipt;
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
@@ -331,8 +332,8 @@ export function verifyManifestReport(opts) {
331
332
  export const FILE_HANG_SLACK = 3;
332
333
  export const DEFAULT_FILE_HANG_BUDGET_MS = 60_000;
333
334
  /**
334
- * No file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins below it). A fast
335
- * suite's per-file timings are a few ms, so 3 × longest killed a new or slower file that would have passed, or a scheduling gap, as an infra hang.
335
+ * No measured-green file is a hang before this much active time, whatever its baseline says (the battery ceiling still wins
336
+ * below it). A fast suite's per-file timings are a few ms, so 3 × its own killed a slower file that would have passed.
336
337
  */
337
338
  export const MIN_FILE_HANG_BUDGET_MS = 10_000;
338
339
  let hangBudgetFloorMs = MIN_FILE_HANG_BUDGET_MS;
@@ -340,17 +341,20 @@ let hangBudgetFloorMs = MIN_FILE_HANG_BUDGET_MS;
340
341
  export const setHangBudgetFloorForTests = (ms) => { hangBudgetFloorMs = ms; };
341
342
  export const resetHangBudgetFloorForTests = () => { hangBudgetFloorMs = MIN_FILE_HANG_BUDGET_MS; };
342
343
  const usable = (n) => n !== undefined && Number.isFinite(n) && n > 0;
344
+ /** v2.6.8 P4 (prospectively superseding v2.5.5's "untimed = 3 × longest"): only the file's OWN usable green duration K
345
+ * buys measured slack, max(3 × K, L) with L the longest usable entry (red included) or legacy longestFile. An untimed,
346
+ * baseline-red or legacy longest-only file has no measure of its runtime and gets the finite battery ceiling. */
343
347
  export function fileHangBudgetMs(file, baselineDurations, ceilingMs = DEFAULT_FILE_HANG_BUDGET_MS, longestFile) {
344
348
  const ceiling = usable(ceilingMs) ? ceilingMs : DEFAULT_FILE_HANG_BUDGET_MS;
345
349
  const timings = baselineDurations?.filter((d) => usable(d.durationMs)) ?? [];
346
- const longest = Math.max(0, ...timings.map((d) => d.durationMs), usable(longestFile?.durationMs) ? longestFile.durationMs : 0);
347
- if (!longest)
348
- return ceiling;
349
350
  // A file that was RED in the baseline ended early there: its duration is no measure of its green runtime. The
350
351
  // failure is read from EVERY entry, before the usable-duration filter can drop a 0 ms or non-finite red one.
351
352
  const red = baselineDurations?.some((d) => d.file === file && d.failed) ?? false;
352
353
  const known = red ? undefined : timings.find((d) => d.file === file)?.durationMs;
353
- return Math.min(ceiling, known === undefined ? FILE_HANG_SLACK * longest : Math.max(FILE_HANG_SLACK * known, longest));
354
+ if (known === undefined)
355
+ return ceiling;
356
+ const longest = Math.max(...timings.map((d) => d.durationMs), usable(longestFile?.durationMs) ? longestFile.durationMs : 0);
357
+ return Math.min(ceiling, Math.max(FILE_HANG_SLACK * known, longest));
354
358
  }
355
359
  /**
356
360
  * OBS-953 (+add): a per-file hang budget counted in raw wall time charged a lid-close to the file that
@@ -377,8 +381,29 @@ export function classifyPollGap(prev, now, pollMs) {
377
381
  return { kind: "unknown", ...gap, subtractedMs: 0 };
378
382
  return undefined;
379
383
  }
380
- /** The detected suspend a file started at `startedAt` sat through. */
381
- const suspendedSince = (interruptions, startedAt) => interruptions.reduce((sum, i) => sum + Math.min(i.subtractedMs, Math.max(0, i.to - Math.max(i.from, startedAt))), 0);
384
+ const systemSleepEvidence = { platform: () => process.platform, command: (file, args, opts) => spawnSync(file, args, opts) };
385
+ let sleepEvidence = systemSleepEvidence;
386
+ export const setSleepEvidenceForTests = (evidence) => { sleepEvidence = { ...systemSleepEvidence, ...evidence }; };
387
+ export const resetSleepEvidenceForTests = () => { sleepEvidence = systemSleepEvidence; };
388
+ const SYSCTL_TIMEOUT_MS = 1_000, SYSCTL_MAX_BYTES = 4_096;
389
+ const SYSCTL_STAMP = /^\{ sec = (\d{1,15}), usec = (\d{1,6}) \} [A-Z][a-z]{2} [A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/;
390
+ /** Lazy: runs only from an overdue poll, never at import. A timeout or overrun of 1000 ms, a signal, nonzero exit, more
391
+ * than 4096 combined output bytes, or a truncated, unparseable or reversed pair is unknown. spawnSync SIGKILLs the child
392
+ * at either bound and joins it: a child ignoring SIGTERM cannot hold the poll, so hang enforcement still runs. */
393
+ function readDarwinSleepSegment() {
394
+ const startedMs = hangClocks.mono();
395
+ const r = sleepEvidence.command("sysctl", ["-n", "kern.sleeptime", "kern.waketime"], { encoding: "utf8", timeout: SYSCTL_TIMEOUT_MS, maxBuffer: SYSCTL_MAX_BYTES, killSignal: "SIGKILL" });
396
+ const out = r.stdout ?? "", err = r.stderr ?? "";
397
+ if (hangClocks.mono() - startedMs >= SYSCTL_TIMEOUT_MS || r.error || r.signal || r.status !== 0
398
+ || Buffer.byteLength(out) + Buffer.byteLength(err) > SYSCTL_MAX_BYTES)
399
+ return undefined;
400
+ const [sleep, wake, ...rest] = out.replace(/\n$/, "").split("\n").map((line) => SYSCTL_STAMP.exec(line))
401
+ .map((m) => m ? Number(m[1]) * 1000 + Number(m[2]) / 1000 : NaN);
402
+ return !rest.length && sleep < wake ? { sleep: sleep, wake: wake } : undefined;
403
+ }
404
+ /** Per gap, a file started at `startedAt` is credited the larger of the offset it sat through and its own overlap with
405
+ * the kernel segment: the same suspend is never counted twice, and nothing before the file started is credited. */
406
+ const suspendedSince = (credits, startedAt) => credits.reduce((sum, c) => sum + Math.max(Math.min(c.offsetMs, Math.max(0, c.to - Math.max(c.from, startedAt))), Math.max(0, c.wake - Math.max(c.sleep, startedAt))), 0);
382
407
  // The daemon journals each interruption where it ran (task gate, tip verify); no sink, no journal row.
383
408
  const interruptionSink = new AsyncLocalStorage();
384
409
  export const runWithInterruptionSink = (sink, run) => interruptionSink.run(sink, run);
@@ -397,13 +422,33 @@ export function runManifestedTest(cmd, cwd, opts) {
397
422
  let hangWallMs;
398
423
  let poll;
399
424
  const interruptions = [];
425
+ const credits = [];
426
+ let creditedUntil = -Infinity;
400
427
  const sink = interruptionSink.getStore();
401
428
  let last = { wall: hangClocks.wall(), mono: hangClocks.mono() };
402
429
  const checkHang = (atCeiling = false) => {
403
430
  const at = { wall: hangClocks.wall(), mono: hangClocks.mono() };
404
- const gap = classifyPollGap(last, at, pollMs);
431
+ let gap = classifyPollGap(last, at, pollMs);
405
432
  last = at;
406
433
  if (gap) {
434
+ let os;
435
+ if (sleepEvidence.platform() === "darwin") {
436
+ try {
437
+ os = (sleepEvidence.reader ?? readDarwinSleepSegment)();
438
+ }
439
+ catch {
440
+ os = undefined;
441
+ }
442
+ last = { wall: hangClocks.wall(), mono: hangClocks.mono() }; // the bounded read itself is no poll gap
443
+ }
444
+ // Clipped to this gap and past any segment already credited, so a repeated pair never credits twice.
445
+ const sleep = Math.max(os?.sleep ?? Infinity, gap.from, creditedUntil), wake = Math.min(os?.wake ?? -Infinity, gap.to);
446
+ const osMs = wake > sleep ? wake - sleep : 0;
447
+ if (osMs)
448
+ creditedUntil = wake;
449
+ credits.push({ from: gap.from, to: gap.to, offsetMs: gap.subtractedMs, ...(osMs ? { sleep, wake } : { sleep: 0, wake: 0 }) });
450
+ if (osMs > gap.subtractedMs)
451
+ gap = { ...gap, kind: "host-suspend", subtractedMs: osMs };
407
452
  interruptions.push(gap);
408
453
  sink?.(gap);
409
454
  }
@@ -416,7 +461,7 @@ export function runManifestedTest(cmd, cwd, opts) {
416
461
  continue;
417
462
  const budget = Math.min(overallCeilingMs, Math.max(hangBudgetFloorMs, fileHangBudgetMs(file, opts.baselineDurations, overallCeilingMs, opts.longestFile)));
418
463
  const wallMs = at.wall - startedAt;
419
- const activeMs = wallMs - suspendedSince(interruptions, startedAt);
464
+ const activeMs = wallMs - suspendedSince(credits, startedAt);
420
465
  if (activeMs >= budget || (atCeiling && budget === overallCeilingMs)) {
421
466
  killedFile = file;
422
467
  hangBudgetMs = budget;
@@ -10,7 +10,13 @@ export interface ProcessIdentity {
10
10
  cwd: string;
11
11
  pgid: number;
12
12
  }
13
- export type IdentityRead = ProcessIdentity | "DEAD" | "UNKNOWN";
13
+ /** `UNKNOWN — <why>` is UNKNOWN that names its cause (Darwin's lsof missing or unusable): never life, never death. */
14
+ export type IdentityRead = ProcessIdentity | "DEAD" | "UNKNOWN" | `UNKNOWN — ${string}`;
15
+ /** The host the production reader inspects through; Darwin (and every non-Linux host) reads cwd only via lsof. */
16
+ export interface IdentityHost {
17
+ platform?: NodeJS.Platform;
18
+ lsof?: string;
19
+ }
14
20
  /** Every boundary the lifecycle crosses, injectable so failures are exercised where they really occur. */
15
21
  export interface BeatLifecycleDeps {
16
22
  /** Command prefix that re-executes this same CLI (node, its flags, the entry the launcher ran). */
@@ -23,15 +29,19 @@ export interface BeatLifecycleDeps {
23
29
  sleep: (ms: number) => Promise<void>;
24
30
  env: NodeJS.ProcessEnv;
25
31
  }
26
- export declare function defaultBeatDeps(): BeatLifecycleDeps;
27
- /** A named, nonzero refusal: dispatch renders the message and exits 1. */
32
+ export declare function defaultBeatDeps(host?: IdentityHost): BeatLifecycleDeps;
33
+ /** A named, nonzero refusal: dispatch renders the message and exits 1. `notice` is a reclaim it must not lose. */
28
34
  export declare class BeatRefusal extends Error {
35
+ readonly notice?: string | undefined;
36
+ constructor(message: string, notice?: string | undefined);
29
37
  }
30
38
  /**
31
39
  * The live process behind a pid: birth (lstart), full argv, cwd and process group. A missing process is
32
- * DEAD only when the kernel says so (ESRCH or a zombie); anything unreadable is UNKNOWN, never DEAD.
40
+ * DEAD only when the kernel says so (ESRCH or a zombie) — also when it exits between ps and the cwd read;
41
+ * anything unreadable is UNKNOWN, never DEAD. Linux reads cwd from /proc and never needs lsof; every other
42
+ * host reads it through lsof, so a missing or unusable lsof beside a LIVE pid is UNKNOWN that names lsof.
33
43
  */
34
- export declare function readProcessIdentity(pid: number): IdentityRead;
44
+ export declare function readProcessIdentity(pid: number, host?: IdentityHost): IdentityRead;
35
45
  export declare const beatClaimPath: (repoRoot: string, tier: SupervisionTier) => string;
36
46
  export declare const beatOwnerPath: (repoRoot: string, tier: SupervisionTier) => string;
37
47
  export declare const beatLogPath: (repoRoot: string, tier: SupervisionTier) => string;
@@ -58,12 +68,61 @@ export interface BeatOwner {
58
68
  }
59
69
  type Read<T> = T | "NONE" | "UNREADABLE";
60
70
  export declare function readBeatOwner(repoRoot: string, tier: SupervisionTier): Read<BeatOwner>;
61
- /** Atomic first-writer-wins claim; a held claim is BUSY and is never taken over. */
62
- export declare function acquireBeatClaim(repoRoot: string, tier: SupervisionTier, token: string): void;
63
- export declare function releaseBeatClaim(repoRoot: string, tier: SupervisionTier, token: string): void;
71
+ /** Runs `mutate` as ONE claimed act (claimedAct); `check` is the act's own barrier (owner generation, records). */
72
+ export type ClaimGuard = <T>(mutate: () => T, check?: () => void) => T;
73
+ /** Outside any claim (a direct call): the barrier, then the mutation. */
74
+ export declare const unclaimed: ClaimGuard;
64
75
  /**
65
- * A legacy writer: the launch generation it carries (if any), its seat, the arm a running loop holds, and the
66
- * boundaries its checks read through.
76
+ * Atomic first-writer-wins claim. A held claim is BUSY unless `reclaim` (every claimant passes its boundaries) confirms
77
+ * DEAD every pid parsed from the claim's own bytes — its holder and any launched writer publishing under it
78
+ * (reclaimDeadClaim): then exactly those bytes are removed (takeClaim) and the claim is taken; the returned notice says
79
+ * so (on a refusal, `BeatRefusal.notice` does). A live, reused or unreadable holder or publisher is never taken over.
80
+ * A linked claim is SETTLED (returned) only when no claim removal is under way and it is still the canonical claim: a
81
+ * removal's put-back may replace a claim linked into its gap, so until that removal ends the claim holds nothing — the
82
+ * claimant withdraws exactly its own linked bytes (takeClaim) and refuses BUSY, so no caller, a long-lived bridge
83
+ * included, is left holding a live claim nobody acts under; its next attempt links afresh. When another live taker
84
+ * holds or has moved aside those very bytes (a mistaken death proof included), the withdrawal cannot land then; once
85
+ * this attempt refuses, its token is no longer in use, so the bytes are this process's STRAND wherever they are
86
+ * (strandOf), proven from the bytes alone: every later attempt in this process withdraws them from the claim path
87
+ * first (withdrawStrand) and discards them from a killed taker's lock instead of restoring them (recoverLock) — so
88
+ * recovery never waits for the caller to exit.
89
+ */
90
+ export declare function acquireBeatClaim(repoRoot: string, tier: SupervisionTier, token: string, reclaim?: BeatLifecycleDeps): string | undefined;
91
+ /** The ONE finite contention policy shared by every waiting claimant: loop ticks, legacy one-shots and stop. */
92
+ export declare const BEAT_CLAIM_WAIT: {
93
+ readonly tries: 20;
94
+ readonly intervalMs: 250;
95
+ };
96
+ export interface ClaimWait {
97
+ tries?: number;
98
+ intervalMs?: number;
99
+ sleep: (ms: number) => Promise<void>;
100
+ }
101
+ /**
102
+ * Release removes exactly the bytes that name this token (takeClaim), after any claimed act under them — a launched
103
+ * writer's included — has finished: a replacement found at any instant is left in place. However it ends, the token is
104
+ * then no longer in use: bytes under it that a live taker kept or moved aside are this process's strand (strandOf),
105
+ * withdrawn or discarded by its next attempt. `told` gets the notice of a dead taker's lock recovered on the way.
106
+ */
107
+ export declare function releaseBeatClaim(repoRoot: string, tier: SupervisionTier, token: string, told?: (notice: string) => void): void;
108
+ /**
109
+ * Run ONE supervision.ts writer (stand-down, arm or beat) under a claim without letting it touch the tier: it writes
110
+ * into a STAGED copy of the tier's records (arm, clear-duty latch, beat, stand-down marker), then every record it
111
+ * changed is committed by one rename — a discharged latch by one removal — as ONE claimed act each (`guard`), in the
112
+ * writers' own order (arm, raised latch, beat, marker, discharged latch). Compare-and-commit: inside each act, after
113
+ * the caller's own barrier (its owner generation) and before the final claim check, every record must still be
114
+ * exactly what this write was staged from (or what it committed itself) — a record replaced meanwhile, the owner by
115
+ * the caller's barrier, refuses with nothing further committed. A claim lost, or a record or owner replaced, before
116
+ * the first commit therefore lands nothing; one lost between two commits lands nothing further; either refuses. What a
117
+ * failing writer published before its failure is committed the same way, then the failure is rethrown.
118
+ * ponytail: staging stands in for a commit guard supervision.ts's atomic writers do not take; a writer killed mid-write
119
+ * leaves an inert `<tier>.stage.<pid>.<uuid>` directory (no reader lists it), which the next start, stop or legacy
120
+ * write removes once that pid is confirmed dead (acquireBeatClaim).
121
+ */
122
+ export declare function supervised<T>(repoRoot: string, tier: SupervisionTier, guard: ClaimGuard, write: (root: string) => T): T;
123
+ /**
124
+ * A legacy writer: the launch generation it carries (if any), its seat, the arm a running loop holds, the
125
+ * boundaries its checks read through, and where the notice of a dead claim it reclaimed goes.
67
126
  */
68
127
  export interface LegacyWriter {
69
128
  generation?: string;
@@ -71,6 +130,7 @@ export interface LegacyWriter {
71
130
  settled?: boolean;
72
131
  arm?: SupervisionArm;
73
132
  deps: BeatLifecycleDeps;
133
+ noticed?: (notice: string) => void;
74
134
  }
75
135
  /**
76
136
  * The recheck a legacy write calls immediately before it mutates; `arm` is the arm the writer itself holds, and
@@ -79,17 +139,23 @@ export interface LegacyWriter {
79
139
  export type LegacyRecheck = (arm?: SupervisionArm, created?: boolean) => void;
80
140
  /**
81
141
  * Run one legacy write (one-shot tick, new arm, EVERY loop tick, stand-down) under the tier claim. A launched
82
- * child acts under its launcher's claim while that claim is still held for its generation; afterwards it takes
83
- * the tier claim itself like every other writer. `act` receives the recheck it must call immediately before it
84
- * writes: the claim still names this holder and the tier's ownership still admits this writer. A claim held by
85
- * anyone else refuses with nothing written — `waits` only lets a running loop's tick outlast a brief holder.
142
+ * child acts under its launcher's claim (underLaunchClaim) while that claim is still held for its generation, names
143
+ * this child as its publisher and its launcher is not confirmed dead; afterwards it takes the tier claim itself like
144
+ * every other writer. `act` receives the recheck it must call before it writes (the tier's ownership still admits
145
+ * this writer, then the claim still names this holder) and the guard every supervision write it makes commits under
146
+ * (supervised): each commit is one claimed act whose barrier re-runs that same ownership check. A claim held by
147
+ * anyone else is waited out under BEAT_CLAIM_WAIT (20 attempts 250 ms apart, absent `waits` fields default to it)
148
+ * and then refuses BUSY with nothing written; invalid tries/interval refuse before any claim or write. Like start
149
+ * and stop, the writer reclaims a claim whose recorded pids are all confirmed dead (`noticed` receives the notice): a
150
+ * legacy --loop writer killed inside its claimed tick never strands the --stand-down migration step, and a live
151
+ * writer's claim is never taken. Under its own claim, the release that follows `act` is the claim's last check: a claim
152
+ * lost by then refuses, never reports success. Under the launcher's claim no release follows and nothing is checked
153
+ * after the last act — each act verified the claim last — so the launcher's release after its read-back never ends
154
+ * the loop it launched. A dead or abandoned generation recorded for this writer's seat is retired first (retireDead).
86
155
  */
87
- export declare function withLegacyClaim<T>(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, act: (recheck: LegacyRecheck) => T | Promise<T>, waits?: {
88
- tries: number;
89
- sleep: (ms: number) => Promise<void>;
90
- }): Promise<T>;
156
+ export declare function withLegacyClaim<T>(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, act: (recheck: LegacyRecheck, held: ClaimGuard) => T | Promise<T>, waits?: ClaimWait): Promise<T>;
91
157
  /** The ownership a legacy writer needs before each mutation: its own COMPLETE launched generation, or no owner at all. */
92
- export declare function assertLegacyOwnership(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, arm?: SupervisionArm, created?: boolean, held?: BeatOwner): BeatOwner | undefined;
158
+ export declare function assertLegacyOwnership(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter, arm?: SupervisionArm, created?: boolean, held?: BeatOwner, claimed?: ClaimGuard): BeatOwner | undefined;
93
159
  /**
94
160
  * ONE RULE: every legacy write is preceded by the shared complete owned-generation check of the CURRENT owner
95
161
  * record and durable arm against the EXACT generation this writer holds — token, seat, pid (once settled), armId
@@ -107,7 +173,7 @@ export declare function assertLegacyOwnership(repoRoot: string, tier: Supervisio
107
173
  */
108
174
  export declare function assertOwnedGeneration(repoRoot: string, tier: SupervisionTier, writer: LegacyWriter & {
109
175
  generation: string;
110
- }, arm?: SupervisionArm | undefined, created?: boolean, held?: BeatOwner, pid?: number): BeatOwner;
176
+ }, arm?: SupervisionArm | undefined, created?: boolean, held?: BeatOwner, claimed?: ClaimGuard, pid?: number): BeatOwner;
111
177
  export type CheckedBeat = {
112
178
  kind: "EMPTY";
113
179
  } | {
@@ -124,9 +190,14 @@ export type CheckedBeat = {
124
190
  owner: BeatOwner & {
125
191
  pid: number;
126
192
  };
193
+ } | {
194
+ kind: "ABANDONED";
195
+ owner: BeatOwner;
196
+ reason: string;
127
197
  } | {
128
198
  kind: "FOREIGN";
129
199
  reason: string;
200
+ migrate?: string;
130
201
  } | {
131
202
  kind: "PID-REUSED";
132
203
  owner: BeatOwner & {
@@ -140,15 +211,29 @@ export type CheckedBeat = {
140
211
  * The checked state of one tier, read-only: no claim, no write, no signal. Every authoritative record —
141
212
  * owner, marker, beat and durable arm — is validated before any outcome is selected, and ownership binds to
142
213
  * this repository's canonical root, the recorded seat and the recorded arm before the process is consulted.
214
+ * An UNBOUND owner (no pid, or no arm epoch) is a launch in progress only while a claim is held: a launch publishes and
215
+ * binds only under its own claim. So when the caller holds the tier claim (`held`), or no claim exists before and after
216
+ * the owner is read (status), an unbound owner with no live recorded process is ABANDONED — its launcher died — and
217
+ * the next start, stop or legacy write retires it (retireDead); a live recorded process keeps it UNKNOWN.
218
+ */
219
+ export declare function inspectBeat(repoRoot: string, tier: SupervisionTier, seat: string | undefined, deps: BeatLifecycleDeps, held?: boolean): CheckedBeat;
220
+ /**
221
+ * Status: the recorded liveness, rendered without writing; healthy states exit 0, the rest nonzero. It is observation
222
+ * only: a claim whose recorded pids are all dead, what a killed taker or writer left, or — read in the process that
223
+ * stranded it (a bridge) — that process's own strand (strandOf), is REPORTED (nonzero) with the pids and the exact
224
+ * command that recovers it — never recovered here, and no file is touched.
143
225
  */
144
- export declare function inspectBeat(repoRoot: string, tier: SupervisionTier, seat: string | undefined, deps: BeatLifecycleDeps): CheckedBeat;
145
- /** Status: the recorded liveness, rendered without writing; healthy states exit 0, the rest nonzero. */
146
226
  export declare function beatStatus(repoRoot: string, tier: SupervisionTier, seat: string | undefined, deps: BeatLifecycleDeps): {
147
227
  out: string;
148
228
  code: number;
149
229
  };
150
- /** start: one claim, one child; exit zero only after this exact child's own advancing beat reads back. */
230
+ /** start: one claim (a single attempt), one child; exit zero only after this exact child's own advancing beat reads back. */
151
231
  export declare function startBeat(repoRoot: string, tier: SupervisionTier, seat: string, deps: BeatLifecycleDeps): Promise<string>;
152
- /** stop: retire only the recorded generation, signalling its exact pid, then read back DISARMED. */
232
+ /**
233
+ * stop: retire only the recorded generation, signalling its exact pid, then read back DISARMED. A loop tick
234
+ * holding the claim is waited out under BEAT_CLAIM_WAIT; a claim whose recorded pids are all confirmed dead is
235
+ * reclaimed. Every stand-down commit (supervised), signal and removal is one claimed act under stop's claim (`held`)
236
+ * with the owner generation it acts on re-verified inside it.
237
+ */
153
238
  export declare function stopBeat(repoRoot: string, tier: SupervisionTier, seat: string, deps: BeatLifecycleDeps): Promise<string>;
154
239
  export {};