tickmarkr 2.6.4 → 2.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/adapters/catalog-remote.d.ts +23 -0
  2. package/dist/adapters/catalog-remote.js +38 -0
  3. package/dist/adapters/claude-code.d.ts +1 -1
  4. package/dist/adapters/claude-code.js +4 -1
  5. package/dist/adapters/codex-commit-check.d.ts +25 -0
  6. package/dist/adapters/codex-commit-check.js +108 -0
  7. package/dist/adapters/model-discovery-check.d.ts +33 -0
  8. package/dist/adapters/model-discovery-check.js +123 -0
  9. package/dist/adapters/model-lints.d.ts +1 -1
  10. package/dist/adapters/model-lints.js +4 -0
  11. package/dist/adapters/prompt.js +3 -2
  12. package/dist/adapters/types.d.ts +7 -0
  13. package/dist/cli/commands/approve.js +10 -4
  14. package/dist/cli/commands/doctor.d.ts +2 -0
  15. package/dist/cli/commands/doctor.js +14 -2
  16. package/dist/cli/commands/fleet.d.ts +10 -1
  17. package/dist/cli/commands/fleet.js +55 -38
  18. package/dist/cli/commands/init.js +5 -1
  19. package/dist/cli/commands/plan.js +65 -4
  20. package/dist/cli/commands/verify.d.ts +1 -1
  21. package/dist/cli/commands/verify.js +28 -6
  22. package/dist/config/fleet-overlay.d.ts +2 -1
  23. package/dist/config/fleet-overlay.js +77 -4
  24. package/dist/drivers/orca.d.ts +19 -1
  25. package/dist/drivers/orca.js +161 -31
  26. package/dist/gates/cache.d.ts +4 -0
  27. package/dist/gates/cache.js +10 -2
  28. package/dist/gates/llm.d.ts +4 -1
  29. package/dist/gates/llm.js +6 -1
  30. package/dist/gates/review.js +8 -3
  31. package/dist/gates/run-gates.d.ts +3 -2
  32. package/dist/gates/run-gates.js +91 -50
  33. package/dist/run/daemon.js +150 -19
  34. package/dist/run/git.js +9 -0
  35. package/dist/run/journal.d.ts +6 -2
  36. package/dist/run/journal.js +22 -7
  37. package/dist/run/launchservices-check.d.ts +56 -0
  38. package/dist/run/launchservices-check.js +117 -0
  39. package/dist/run/operator-state.d.ts +22 -2
  40. package/dist/run/operator-state.js +20 -3
  41. package/dist/run/title-environment.d.ts +45 -0
  42. package/dist/run/title-environment.js +127 -0
  43. package/dist/run/title-preload.cjs +38 -0
  44. package/dist/run/title-preload.d.cts +1 -0
  45. package/dist/tui/cockpit/board.js +12 -6
  46. package/dist/tui/cockpit/keys.d.ts +8 -0
  47. package/dist/tui/cockpit/keys.js +4 -1
  48. package/dist/tui/cockpit/layout.d.ts +2 -2
  49. package/dist/tui/cockpit/layout.js +2 -2
  50. package/dist/tui/cockpit/live-runtime.js +46 -20
  51. package/dist/tui/cockpit/live-store.d.ts +24 -1
  52. package/dist/tui/cockpit/live-store.js +99 -8
  53. package/dist/tui/cockpit/log-view.d.ts +28 -0
  54. package/dist/tui/cockpit/log-view.js +60 -0
  55. package/dist/tui/cockpit/run-view.js +4 -0
  56. package/dist/tui/cockpit/shell.d.ts +2 -1
  57. package/dist/tui/cockpit/shell.js +6 -4
  58. package/dist/tui/ink/fleet-app.d.ts +12 -6
  59. package/dist/tui/ink/fleet-app.js +17 -11
  60. package/package.json +1 -1
  61. package/skills/tickmarkr-auto/SKILL.md +32 -2
  62. package/skills/tickmarkr-loop/SKILL.md +46 -2
  63. package/skills/tickmarkr-overseer/SKILL.md +83 -8
@@ -36,7 +36,7 @@ import { changeRepresented, cleanupRunWorktrees, deriveForkCap, FORK_CAP_ENV, gi
36
36
  import { runInteractiveSeed } from "./interactive-seed.js";
37
37
  import { classifyRepairDisposition, resolveScopeHints } from "./repair-disposition.js";
38
38
  import { applyScopeAmendments, activeRetryBan, interruptedAttempt, RESUME_HARVEST_SOURCE, resumeHarvestAuthor, approvalAction, APPROVAL_REFUSED, bindingToken, effectiveEvents, foldDecisions, physicalLine, staleApprovals, classifyTaskFailure, classifyWorkerResultCause, deferredReviewFindings, engagementComparable, formatPriorFindingEvidence, GATE_FINGERPRINT_CAP, GATE_SATISFIED_RELEASE, identicalGateFailures, isDeferredFinding, journaledFailureBrief, Journal, loadRoutingProfile, newRunId, normalizeGateFailure, outstandingConsultGuidance, readableExcerpt, receiptOrigin, outstandingReviewFindings, pendingApprovalActions, pendingRechecks, pendingRepairFindings, phaseForGate, priorJudgments, readPriorRunEvidence, recordedTaskFailureKind, reraisedReviewChains, RECHECK_RELEASE, renderStructuredReviewFinding, repairReachSinceApproval, repairsSinceApproval, reviewRoundsSinceApproval, runHasEnded, standingRulings, structuredFindings, upheldFeedbackByTask } from "./journal.js";
39
- import { gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
39
+ import { carriedAuthorVendors, gateReviewerFloor, isDiffCapPark, isGarbageReview, pickReviewer } from "../gates/review.js";
40
40
  import { acquireApprovalSerialization, acquireRunLock, isPidLive, releaseRunLock } from "./lock.js";
41
41
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, reusedTipEvidence, verifyIntegrationTip } from "./merge.js";
42
42
  import { channelExclusion, climbChannel, inTaskPool, marginalCostRank, nextChannel, poolExhaustion, route, taskPool } from "../route/router.js";
@@ -377,6 +377,49 @@ function classifyInfraResult(g) {
377
377
  const TIMEOUT_SHAPED_RE = /\b(?:Test|Hook) timed out in (?:\d+|#)\s*ms\b|\bexpected (?:\d+|#)(?:\.(?:\d+|#))? to be (?:less than|below)(?: or equal to)? (?:\d+|#)/i;
378
378
  const RUNNER_INFRA_DIAGNOSTIC_RE = /\bnever-started (?:[1-9]\d*|#)(?![\d#])|\[vitest-worker\]: Timeout calling\b/;
379
379
  const timeoutShaped = (details) => TIMEOUT_SHAPED_RE.test(readableExcerpt(details));
380
+ /** C (E1): the one no-verdict that is the host's slowness, not the seat's — the same predicate run-gates
381
+ * applies to its run-scoped tally, read here for the live task strike and the resume reconstruction. */
382
+ const proofTimeoutNote = (data) => data.cause === "seat-launch-failed" && data.launchCause === "checkout-proof-timeout";
383
+ /** C: the gate-fresh-forced reason that bounds the out-of-scope re-execution, one per journaled subject. */
384
+ const OUTSIDE_SCOPE_RED = "no-commit-out-of-scope-red";
385
+ const NAMED_FAILURE_FILE_RE = /^\s*(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\s+>/gm;
386
+ /** D-854: the runner certificate the vitest manifest path appends to a red (test-manifest.ts). */
387
+ const RUNNER_CERTIFICATE_RE = /runner-level diagnostic: never-started ([^;\s]+); reporter errors ([^;\s]+)/g;
388
+ /** C: a journaled test red whose failing-file attribution is nonempty, trustworthy and complete (repair
389
+ * selection's own rule: a behavioral red on a real subject from a full suite or a screen that names
390
+ * every failure), POSITIVELY certified complete by its runner, uncontradicted by the files its own text
391
+ * names, and wholly outside a declared files[]. Empty files[] is unrestricted and never qualifies;
392
+ * every other shape keeps its replay. */
393
+ function outsideScopeRed(t, taskId, data, commit) {
394
+ if (!t.files.length || data.gate !== "test" || data.pass !== false || data.skipped === true || data.commit !== commit
395
+ // a failed producer — an infra row, a dirty-tree refusal, a recovery the runner could not finish —
396
+ // never attributes anything, whatever files it lists
397
+ || data.infra === true || data.dirtyWorktree === true || data.recoveryBlocked !== undefined)
398
+ return false;
399
+ const attribution = repairSelectionDecision([{ event: "gate-result", taskId, data }], taskId, true);
400
+ if (attribution.reason !== "known-failing-files" || attribution.requiredFiles.length === 0)
401
+ return false;
402
+ const text = readableExcerpt(String(data.details ?? ""));
403
+ // D-854: validate, never filter. A collection failure has no module record, so failingFiles omits it
404
+ // and it lives only in the certificate's counts; any never-started file or reporter error, an unknown
405
+ // count, or no certificate at all leaves the attribution unproven.
406
+ // ponytail: a nonzero count never qualifies, even when each diagnostic names an outside file — the
407
+ // details interleave diagnostics with the stdout tail; map them only if outside-scope collection reds need it.
408
+ const certificates = [...text.matchAll(RUNNER_CERTIFICATE_RE)];
409
+ if (!certificates.length || certificates.some((m) => m[1] !== "0" || m[2] !== "0"))
410
+ return false;
411
+ const named = [...text.matchAll(ATTRIBUTED_FILE_RE), ...text.matchAll(NAMED_FAILURE_FILE_RE)].map((m) => m[1].replace(/^\.\//, ""));
412
+ if (named.some((file) => !attribution.requiredFiles.includes(file)))
413
+ return false; // contradictory
414
+ const inScope = filesGlob(t.files);
415
+ return attribution.requiredFiles.every((file) => !inScope(file));
416
+ }
417
+ /** C: every test red a round journaled on `commit` is an out-of-scope red (none means none). */
418
+ const allOutsideScopeReds = (t, rows, commit) => {
419
+ const reds = rows.filter((e) => e.event === "gate-result" && e.data.gate === "test" && e.data.pass === false
420
+ && e.data.skipped !== true && e.data.commit === commit);
421
+ return reds.length > 0 && reds.every((e) => outsideScopeRed(t, e.taskId ?? t.id, e.data, commit));
422
+ };
380
423
  const ATTRIBUTED_FILE_RE = /^\s*(?:FAIL|×|✗)\s+(?:\|[^|\n]*\|\s+)?(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\b/gm;
381
424
  /** The failing files a red attributes to itself: the runner's own list, else its FAIL headlines. */
382
425
  const attributedFailingFiles = (g) => [...new Set(Array.isArray(g.meta?.failingFiles) && g.meta.failingFiles.length
@@ -2203,10 +2246,13 @@ export async function runDaemon(repoRoot, opts = {}) {
2203
2246
  // is what bounds a seat that flakes once per task (or once per session) across the run. Seeded from the
2204
2247
  // journal's review-no-verdict rows — the same notes noteReviewEvent appends — so a resumed daemon keeps
2205
2248
  // the count; run-gates appends to it in place, exactly as it does demotedReviewers.
2249
+ // C (E1): a checkout-proof timeout never struck the live tally (run-gates), so a resume never seeds one.
2206
2250
  const reviewNoVerdicts = new Map();
2207
2251
  for (const event of journal.read()) {
2208
2252
  if (event.event !== "review-no-verdict" || event.data.noVerdict !== true || typeof event.data.reviewer !== "string")
2209
2253
  continue;
2254
+ if (proofTimeoutNote(event.data))
2255
+ continue;
2210
2256
  reviewNoVerdicts.set(event.data.reviewer, [...(reviewNoVerdicts.get(event.data.reviewer) ?? []), String(event.data.cause)]);
2211
2257
  }
2212
2258
  const reviewHistory = journal.read()
@@ -2930,6 +2976,18 @@ export async function runDaemon(repoRoot, opts = {}) {
2930
2976
  return red !== undefined
2931
2977
  && identicalGateFailures(journal.read(), t.id, "test", normalizeGateFailure(String(red.data.details))) < GATE_FINGERPRINT_CAP;
2932
2978
  };
2979
+ // C: an unchanged cached test red attributed wholly outside files[] is re-executed ONCE per journaled
2980
+ // subject — the worker cannot repair those files, so a copy of the red would only buy another round.
2981
+ const owesOutsideScopeRefresh = (t, priorRows, commit) => allOutsideScopeReds(t, priorRows, commit)
2982
+ && !journal.read().some((e) => e.taskId === t.id && e.event === "gate-fresh-forced"
2983
+ && e.data.reason === OUTSIDE_SCOPE_RED && e.data.commit === commit);
2984
+ // C: the forced re-execution's own round failed again only outside files[]: independent evidence the
2985
+ // worker cannot act on. It keeps its red row and parks gate-fail before any cap, consult or repair move.
2986
+ const freshOutsideScopeRed = (t, commit) => {
2987
+ const own = journal.read().filter((e) => e.taskId === t.id);
2988
+ return allOutsideScopeReds(t, own.slice(own.map((e) => e.event === "phase-start" && e.data.phase === "gates").lastIndexOf(true) + 1), commit);
2989
+ };
2990
+ const outsideScopePark = (t) => gateFailApprovalReason(t.id, "test: a fresh re-execution of the unchanged subject failed again only in files outside files[] — no repair can reach them");
2933
2991
  // OBS-1106 residual: before a timeout or wall-budget red accompanied by runner infra diagnostics is
2934
2992
  // charged, its attributed failing files are re-run ONCE, on the same checkout, under the suite
2935
2993
  // lease. Only a reproduced assertion is charged. A pass, an infra rerun, a rerun naming none of the
@@ -3297,11 +3355,15 @@ export async function runDaemon(repoRoot, opts = {}) {
3297
3355
  }
3298
3356
  // Keep one live list: recovery retries must see exclusions added by onGate during the round.
3299
3357
  const badReviewers = [...replayedReviewerExclusions];
3358
+ // C (E1): a checkout-proof timeout is the host being slow, not the seat failing — it is excluded for
3359
+ // the CURRENT round only (so an always-timeout pool still ends the round), never struck task-wide.
3360
+ let roundTimeouts = [];
3300
3361
  const noteReviewEvent = (e) => {
3301
3362
  journal.append(e.name, t.id, e.payload);
3302
3363
  if (e.name === "review-no-verdict" && typeof e.payload.reviewer === "string") {
3303
- if (!badReviewers.includes(e.payload.reviewer))
3304
- badReviewers.push(e.payload.reviewer);
3364
+ const strikes = proofTimeoutNote(e.payload) ? roundTimeouts : badReviewers;
3365
+ if (!strikes.includes(e.payload.reviewer))
3366
+ strikes.push(e.payload.reviewer);
3305
3367
  }
3306
3368
  // OBS-1196: a seat whose same-subject re-emission delivered a valid verdict was a delivery flake,
3307
3369
  // not a bad reviewer — it stays seatable for this task's later rounds.
@@ -3337,17 +3399,31 @@ export async function runDaemon(repoRoot, opts = {}) {
3337
3399
  gateRound: journal.read().filter((row) => row.taskId === task.id
3338
3400
  && row.event === "phase-start" && row.data.phase === "gates").length,
3339
3401
  };
3340
- const round = await runGates(task, ctx);
3402
+ roundTimeouts = [];
3403
+ // C: every gate-round boundary — a returned or a THROWN round — publishes the one row held behind
3404
+ // a still-pending sibling and forgets the pending set, before any park or task-failed row lands.
3405
+ const gateRound = async (subject) => {
3406
+ try {
3407
+ return await runGates(subject, { ...ctx, excludeReviewers: [...badReviewers, ...roundTimeouts] });
3408
+ }
3409
+ finally {
3410
+ settleParallelRound();
3411
+ }
3412
+ };
3413
+ const round = await gateRound(task);
3341
3414
  let review = round.results.find((g) => g.gate === "review");
3415
+ // The recovery pick carries every preserved author, exactly as reviewGate's own pick does.
3416
+ const authors = ctx.carriedAuthors?.length ? [...ctx.carriedAuthors] : [channelKey(ctx.author)];
3342
3417
  while (review?.meta?.noVerdict === true) {
3343
3418
  // Recovery must honor the gate's floor, including the seats that just failed to
3344
- // return a verdict; a below-floor alternative cannot replace the infra result.
3345
- const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers]);
3346
- const next = pickReviewer(ctx.author, ctx.channels, badReviewers, cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers);
3419
+ // return a verdict; a below-floor alternative cannot replace the infra result. This round's
3420
+ // proof timeouts count too — runGates folds every excluded seat into its own floor.
3421
+ const { floor } = gateReviewerFloor(task, ctx.cfg, ctx.author, ctx.channels, [...(ctx.priorReviewers ?? []), ...badReviewers, ...roundTimeouts]);
3422
+ const next = pickReviewer(ctx.author, ctx.channels, [...badReviewers, ...roundTimeouts], cfg.review.prefer ?? [], floor, reviewHistory, undefined, demotedReviewers, carriedAuthorVendors(ctx.channels, authors), authors);
3347
3423
  if (!next)
3348
3424
  break;
3349
3425
  journal.append("review-infra-retry", t.id, { reviewer: channelKey(next), cause: review.meta.cause });
3350
- const retried = await runGates({ ...task, gates: ["review"] }, ctx);
3426
+ const retried = await gateRound({ ...task, gates: ["review"] });
3351
3427
  const replacement = retried.results.find((g) => g.gate === "review");
3352
3428
  // A dirty tree or another pre-dispatch refusal must still fail closed.
3353
3429
  if (!replacement) {
@@ -3374,13 +3450,16 @@ export async function runDaemon(repoRoot, opts = {}) {
3374
3450
  gate: "review", flaked: rr.flaked, retried: rr.retried,
3375
3451
  ...(g.meta?.unparseable === true ? { secondUnparseable: true } : {}),
3376
3452
  });
3377
- badReviewers.push(rr.flaked);
3453
+ // C (E1): a seat re-routed after a checkout-proof timeout is excluded for this round only.
3454
+ if (!roundTimeouts.includes(rr.flaked))
3455
+ badReviewers.push(rr.flaked);
3378
3456
  }
3379
3457
  };
3380
3458
  // v1.85 T3 (ruling R4): every BLOCKING review/judge result lands its findings in the journal
3381
3459
  // structured — class + canonical path + stable symbol — so a retry, a consult or an auto-uphold
3382
3460
  // decision reads identity instead of re-parsing prose, and line-number churn is not a new finding.
3383
3461
  // One helper, both onGate sites (satisfied-gate resume + main attempt loop).
3462
+ // `journaledGates` narrows replayMeasurement to the gates an interrupted round already journaled (C).
3384
3463
  let gateSubject;
3385
3464
  const journalGateResult = (g) => {
3386
3465
  const blocking = g.meta?.infra !== true && gateFailed(g) && (g.gate === "review" || g.gate === "acceptance");
@@ -3416,7 +3495,7 @@ export async function runDaemon(repoRoot, opts = {}) {
3416
3495
  // for the worktree it ran in; absent only for a row that never went through the battery.
3417
3496
  ...(g.meta?.verification !== undefined ? { verification: g.meta.verification } : {}),
3418
3497
  ...(gateSubject ? { commit: gateSubject.commit, attempt: gateSubject.attempt } : {}),
3419
- ...(gateSubject?.replayMeasurement ? { replayMeasurement: true } : {}),
3498
+ ...(gateSubject?.replayMeasurement && (gateSubject.journaledGates?.includes(g.gate) ?? true) ? { replayMeasurement: true } : {}),
3420
3499
  ...(gateSubject?.replayedFromAttempt !== undefined ? { replayedFromAttempt: gateSubject.replayedFromAttempt } : {}),
3421
3500
  ...(g.meta?.skipped === true || noVerdictReview ? { skipped: true } : {}),
3422
3501
  // T9: an infra-only exit is journaled AS one. The operator reading a red `test` row has to
@@ -3530,10 +3609,17 @@ export async function runDaemon(repoRoot, opts = {}) {
3530
3609
  // ever held, and only while an earlier sibling is still in flight — a review that finishes first
3531
3610
  // waits for acceptance, never the reverse. That keeps T4's durability where it pays (the first
3532
3611
  // verdict to land is still published immediately) and bounds the exposure to one row for the
3533
- // remainder of one already-running gate. A gate that THROWS kills the round before merge, so a
3534
- // row held behind it is lost with the round it belonged to — not a verdict that could have merged.
3612
+ // remainder of one already-running gate. A gate that THROWS still ends its round: C publishes the
3613
+ // row held behind it at that boundary (settleParallelRound), so it is never lost or carried over.
3535
3614
  const parallelPending = new Set();
3536
3615
  let heldParallel;
3616
+ const settleParallelRound = () => {
3617
+ parallelPending.clear();
3618
+ const held = heldParallel;
3619
+ heldParallel = undefined;
3620
+ if (liveEngagement())
3621
+ held?.();
3622
+ };
3537
3623
  const notePhaseStart = (e) => {
3538
3624
  const active = activeGatePhases.get(t.id) ?? new Set();
3539
3625
  active.add(e.gate);
@@ -4096,6 +4182,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4096
4182
  journal.append("gate-provisioned", t.id, provisionedRow);
4097
4183
  const resumedTask = { ...t, gates: remainingGates };
4098
4184
  const operatorContext = approvalReviewContext(journal.read(), t.id);
4185
+ let outsideScopeForced = false;
4099
4186
  gateLoop: while (true) {
4100
4187
  fatalStop.signal.throwIfAborted();
4101
4188
  executionSignal()?.throwIfAborted();
@@ -4124,13 +4211,42 @@ export async function runDaemon(repoRoot, opts = {}) {
4124
4211
  const own = journal.read().filter((e) => e.taskId === t.id);
4125
4212
  return own.slice(own.map((e) => e.event === "phase-start" && e.data.phase === "gates").lastIndexOf(true) + 1);
4126
4213
  })();
4127
- const freshTimeout = !recheck && !satisfiedGate && ((harvestAuthor !== undefined && owesTimeoutRefresh(t, lastRound, gateSubject.commit))
4128
- || (lastRound.some((e) => e.event === "gate-fresh-forced" && e.data.commit === gateSubject.commit)
4129
- && !lastRound.some((e) => e.event === "gate-result" && e.data.gate === "test")));
4214
+ // C: a passing selected-test screen is never a forced round's conclusive test result (the
4215
+ // journal's own replay rule): its full suite is still owed, fresh, under the round's bypass.
4216
+ const measured = lastRound.filter((e) => e.event === "gate-result" && !(e.data.gate === "test"
4217
+ && Array.isArray(e.data.selectedTests) && e.data.fullSuite !== true && e.data.pass !== false));
4218
+ const forcedUnmeasured = (outside) => lastRound.some((e) => e.event === "gate-fresh-forced"
4219
+ && e.data.commit === gateSubject.commit && (e.data.reason === OUTSIDE_SCOPE_RED) === outside)
4220
+ && !measured.some((e) => e.data.gate === "test");
4221
+ // C: a crash after the forced re-execution journaled its red but before its park still owes that
4222
+ // park — restored from the journaled subject and result, never re-measured, replayed or repaired.
4223
+ // Infra adjudication stays authoritative, exactly as it is ahead of the live park.
4224
+ if (!recheck && !satisfiedGate && lastRound.some((e) => e.event === "gate-fresh-forced"
4225
+ && e.data.reason === OUTSIDE_SCOPE_RED && e.data.commit === gateSubject.commit)
4226
+ && allOutsideScopeReds(t, lastRound, gateSubject.commit)) {
4227
+ const restored = lastRound.filter((e) => e.event === "gate-result" && e.data.gate === "test" && e.data.pass === false)
4228
+ .map((e) => ({ gate: "test", pass: false, details: String(e.data.details ?? ""), meta: e.data }));
4229
+ if (await adjudicateInfraShapedRed(t, restored, wt, gateSubject.commit, rs?.attempts ?? 0, true, (reason) => park(t, reason, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode)))
4230
+ return;
4231
+ await park(t, outsideScopePark(t), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4232
+ return;
4233
+ }
4234
+ // C: the same out-of-scope re-execution the live loop owes, and a crash inside it still owes it. It
4235
+ // takes precedence over a timeout refresh: a qualifying red keeps its own bypass and park.
4236
+ outsideScopeForced = !recheck && !satisfiedGate && ((harvestAuthor !== undefined && owesOutsideScopeRefresh(t, lastRound, gateSubject.commit)) || forcedUnmeasured(true));
4237
+ const freshTimeout = !recheck && !satisfiedGate && !outsideScopeForced && ((harvestAuthor !== undefined && owesTimeoutRefresh(t, lastRound, gateSubject.commit)) || forcedUnmeasured(false));
4238
+ // C: a measurement this forced round EXECUTES fresh — its forced test above all — is independent
4239
+ // evidence, never a replay measurement; only a gate the interrupted round already journaled is.
4240
+ if (outsideScopeForced) {
4241
+ gateSubject = { ...gateSubject, journaledGates: measured.map((e) => String(e.data.gate)) };
4242
+ }
4130
4243
  journal.phaseStart(t.id, "gates");
4131
4244
  if (freshTimeout) {
4132
4245
  journal.append("gate-fresh-forced", t.id, { gate: "test", attempt: gateSubject.attempt, commit: gateSubject.commit, reason: "no-commit-timeout-red", resumed: true });
4133
4246
  }
4247
+ if (outsideScopeForced) {
4248
+ journal.append("gate-fresh-forced", t.id, { gate: "test", attempt: gateSubject.attempt, commit: gateSubject.commit, reason: OUTSIDE_SCOPE_RED, resumed: true });
4249
+ }
4134
4250
  const { results } = await withCommandContext(t.id, async () => runReviewRecovery(resumedTask, {
4135
4251
  carriedAuthors: await subjectAuthors(journal.read(), t.id, wt, taskBase),
4136
4252
  producer: producerNow(),
@@ -4155,7 +4271,7 @@ export async function runDaemon(repoRoot, opts = {}) {
4155
4271
  // flaking seat is re-asked on every task.
4156
4272
  reviewNoVerdicts,
4157
4273
  recheck, // OBS-1055: a recheck discards cached reds — the battery re-measures what the operator questioned
4158
- cachedRedBypass: freshTimeout ? "timeout-fresh" : undefined,
4274
+ cachedRedBypass: freshTimeout ? "timeout-fresh" : outsideScopeForced ? "out-of-scope-fresh" : undefined,
4159
4275
  onGate: async (e) => {
4160
4276
  if (!liveEngagement())
4161
4277
  return;
@@ -4220,6 +4336,10 @@ export async function runDaemon(repoRoot, opts = {}) {
4220
4336
  // assignment back with it.
4221
4337
  if (await adjudicateInfraShapedRed(t, results, wt, gateSubject.commit, rs?.attempts ?? 0, false, (reason) => park(t, reason, "infra", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode)))
4222
4338
  return;
4339
+ if (outsideScopeForced && freshOutsideScopeRed(t, gateSubject.commit)) {
4340
+ await park(t, outsideScopePark(t), "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
4341
+ return;
4342
+ }
4223
4343
  const classified = journal.classifiedDispatch(t.id);
4224
4344
  if (await dispositionScopeRed(t, results, classified?.assignment ?? gateAuthor, classified ? (rs?.attempts ?? 0) : Math.max(0, (rs?.attempts ?? 0) - 1), startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
4225
4345
  return;
@@ -6246,6 +6366,7 @@ export async function runDaemon(repoRoot, opts = {}) {
6246
6366
  };
6247
6367
  let results = [];
6248
6368
  let commits = [];
6369
+ let outsideScopeForced = false;
6249
6370
  gateLoop: while (true) {
6250
6371
  fatalStop.signal.throwIfAborted();
6251
6372
  executionSignal()?.throwIfAborted();
@@ -6261,12 +6382,18 @@ export async function runDaemon(repoRoot, opts = {}) {
6261
6382
  && e.data.attempt === attempt - 1);
6262
6383
  const previousRows = retryMode === "repair" ? lastRoundRows : [];
6263
6384
  // OBS-1106 residual: see owesTimeoutRefresh — the replay below and the verdict cache are both refused.
6264
- const freshTimeout = !explicitlyFundedAttempt && owesTimeoutRefresh(t, lastRoundRows, gateSubject.commit);
6385
+ // C: an unchanged red wholly outside files[] is re-executed once instead of copied (journal or cache);
6386
+ // it outranks the timeout refresh, so a timeout-shaped qualifying red keeps its own bypass and park.
6387
+ outsideScopeForced = !explicitlyFundedAttempt && owesOutsideScopeRefresh(t, lastRoundRows, gateSubject.commit);
6388
+ const freshTimeout = !explicitlyFundedAttempt && !outsideScopeForced && owesTimeoutRefresh(t, lastRoundRows, gateSubject.commit);
6265
6389
  journal.phaseStart(t.id, "gates");
6266
6390
  if (freshTimeout) {
6267
6391
  journal.append("gate-fresh-forced", t.id, { gate: "test", attempt, priorAttempt: attempt - 1, commit: gateSubject.commit, reason: "no-commit-timeout-red" });
6268
6392
  }
6269
- const replay = !explicitlyFundedAttempt && !freshTimeout && previousRows.length > 0
6393
+ if (outsideScopeForced) {
6394
+ journal.append("gate-fresh-forced", t.id, { gate: "test", attempt, priorAttempt: attempt - 1, commit: gateSubject.commit, reason: OUTSIDE_SCOPE_RED });
6395
+ }
6396
+ const replay = !explicitlyFundedAttempt && !freshTimeout && !outsideScopeForced && previousRows.length > 0
6270
6397
  && previousRows.every((e) => e.data.commit === gateSubject.commit)
6271
6398
  && previousRows.some((e) => e.data.pass === false && e.data.skipped !== true);
6272
6399
  if (replay) {
@@ -6299,7 +6426,7 @@ export async function runDaemon(repoRoot, opts = {}) {
6299
6426
  commands, baseline, channels: pools.review, judgeChannels: pools.judge, health, adapters, cfg, artifactDir: journal.dir,
6300
6427
  collateral: collateral.get(t.id) ?? [],
6301
6428
  selectTests: !testGateFailed,
6302
- cachedRedBypass: explicitlyFundedAttempt ? "operator-rerun" : freshTimeout ? "timeout-fresh" : undefined,
6429
+ cachedRedBypass: explicitlyFundedAttempt ? "operator-rerun" : freshTimeout ? "timeout-fresh" : outsideScopeForced ? "out-of-scope-fresh" : undefined,
6303
6430
  via: cfg.visibility.llm === "pane"
6304
6431
  ? {
6305
6432
  driver: trackedDriver,
@@ -6408,6 +6535,10 @@ export async function runDaemon(repoRoot, opts = {}) {
6408
6535
  }
6409
6536
  if (await adjudicateInfraShapedRed(t, results, wt, gateSubject.commit, attempt, gateSubject.replayedFromAttempt !== undefined, (reason) => park(t, reason, "infra", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode)))
6410
6537
  return;
6538
+ if (outsideScopeForced && freshOutsideScopeRed(t, gateSubject.commit)) {
6539
+ await park(t, outsideScopePark(t), "gate-fail", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
6540
+ return;
6541
+ }
6411
6542
  // OBS-547: who pays for this red is decided by the run's collateral prediction (see
6412
6543
  // dispositionScopeRed) — an authoring defect parks unchargeable before any accounting below.
6413
6544
  if (await dispositionScopeRed(t, results, assignment, attempt, startMs, gateFails, consults, tokens, metered, retryMode, await treeOrDiffPaths(taskBase, wt)))
package/dist/run/git.js CHANGED
@@ -9,6 +9,7 @@ import { StringDecoder } from "node:string_decoder";
9
9
  import { shq } from "../adapters/types.js";
10
10
  import { tickmarkrDir } from "../graph/graph.js";
11
11
  import { ROUTING_ENV_SEAMS } from "../route/router.js";
12
+ import { titleEnvironment } from "./title-environment.js";
12
13
  export { ROUTING_ENV_SEAMS };
13
14
  // OBS-110: gate/baseline/tip-verify children are vitest suites; without a fork cap, concurrent
14
15
  // full-suite gate runs fork a worker-per-core pool per worktree and saturate the operator box.
@@ -324,6 +325,14 @@ function executeShell(cmd, cwd, timeoutMs, login, options, observation) {
324
325
  // OBS-854: descendants can leave the checkout (nested fixture suites do), so cwd alone cannot
325
326
  // attribute them. Every daemon shell exports the daemon pid as their durable parentage marker.
326
327
  env[SUITE_PARENT_ENV] = String(process.pid);
328
+ // A2: on Darwin a Node child's title writes stay in JavaScript (title-environment.ts); runnerInputsHash
329
+ // hashes this same effective NODE_OPTIONS, so a verdict is only reused under the identical mode. Native
330
+ // mode is applied too: it drops a preload a nested shell inherited from its parent (unset when that was all).
331
+ const { nodeOptions } = titleEnvironment(env);
332
+ if (nodeOptions === undefined)
333
+ delete env.NODE_OPTIONS;
334
+ else
335
+ env.NODE_OPTIONS = nodeOptions;
327
336
  // T7: the capacity every result of this shell carries, read HERE — off the environment the child
328
337
  // is about to receive, after the precedence above has settled. An operator export is already in
329
338
  // `env`, so what gets recorded is the operator's number, which is the case a release was re-taken
@@ -650,7 +650,9 @@ export declare const rangePatch: (cwd: string, base: string, head: string) => st
650
650
  * these and is refused as ambiguous; containment never counts.
651
651
  * Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
652
652
  */
653
- export declare function integrationMapped(cwd: string, events: readonly JournalEvent[], o: OwedCheck, mergeBase: unknown, head: unknown): boolean;
653
+ export declare function integrationMapped(cwd: string, events: readonly JournalEvent[], o: OwedCheck, mergeBase: unknown, head: unknown, memo?: OwedProofMemo): boolean;
654
+ /** I2: a caller-scoped set of completed integration re-proofs (see integrationMapped); absent, every fold re-proves. */
655
+ export type OwedProofMemo = Set<string>;
654
656
  /**
655
657
  * Lifetime patch authors of base..head, read from the run: the daemon's subjectAuthors fold
656
658
  * (daemon.ts, not exported and not this task's to edit) restated synchronously so the waive row is
@@ -687,6 +689,8 @@ export type ChannelFact = {
687
689
  * The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
688
690
  * `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
689
691
  * It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
692
+ * `memo` (I2, optional): a store-scoped table reusing the immutable git re-proof of an integration mapping
693
+ * across folds; every artifact, hash, criteria and reviewer condition is still re-read on every fold.
690
694
  */
691
- export declare function foldOwedChecks(events: readonly JournalEvent[], cwd: string): OwedFold;
695
+ export declare function foldOwedChecks(events: readonly JournalEvent[], cwd: string, memo?: OwedProofMemo): OwedFold;
692
696
  export {};
@@ -2425,11 +2425,24 @@ const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
2425
2425
  * these and is refused as ambiguous; containment never counts.
2426
2426
  * Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
2427
2427
  */
2428
- export function integrationMapped(cwd, events, o, mergeBase, head) {
2428
+ export function integrationMapped(cwd, events, o, mergeBase, head, memo) {
2429
2429
  if (typeof mergeBase !== "string" || typeof head !== "string" || !COMMIT_ID.test(mergeBase) || !COMMIT_ID.test(head))
2430
2430
  return false;
2431
2431
  if (!events.some((e) => e.event === "merge" && e.taskId === o.taskId && e.data.commit === head))
2432
2432
  return false;
2433
+ // Only the git component is memoised, and only a completed proof: commit ids name immutable history,
2434
+ // so the key is the obligation's id and recorded range facts plus the claimed mergeBase..head. A refusal
2435
+ // or git failure (a shallow or partial object store can refuse a range it later proves) is never cached
2436
+ // and is re-proved next fold. The merge-row eligibility above is rechecked on every fold.
2437
+ const key = JSON.stringify([o.id, mergeBase, head, o.base, o.head, o.patch, o.patches]);
2438
+ if (memo?.has(key))
2439
+ return true;
2440
+ const proved = integrationProved(cwd, o, mergeBase, head);
2441
+ if (proved)
2442
+ memo?.add(key);
2443
+ return proved;
2444
+ }
2445
+ function integrationProved(cwd, o, mergeBase, head) {
2433
2446
  try {
2434
2447
  const parents = owedGit(cwd, ["show", "-s", "--format=%P", head]).trim().split(" ");
2435
2448
  if (parents.length !== 2 || parents[0] !== mergeBase)
@@ -2609,7 +2622,7 @@ const declaredVendors = (o) => {
2609
2622
  return vendors;
2610
2623
  };
2611
2624
  /** Why a discharge row no longer proves its obligation, re-read from its artifact; undefined when it does. */
2612
- function dischargeProblem(row, o, events, cwd) {
2625
+ function dischargeProblem(row, o, events, cwd, memo) {
2613
2626
  const d = row.data;
2614
2627
  if (row.taskId !== o.taskId)
2615
2628
  return "discharge names another task";
@@ -2617,7 +2630,7 @@ function dischargeProblem(row, o, events, cwd) {
2617
2630
  return "discharge proved other criteria";
2618
2631
  const exact = d.mapping === "exact" && d.mergeBase === o.base && d.head === o.head;
2619
2632
  // The integration mapping is re-proved from git on every fold, never taken from the row's claims.
2620
- const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head);
2633
+ const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head, memo);
2621
2634
  if (!exact && !integration)
2622
2635
  return "discharge range does not bind the obligation";
2623
2636
  let artifact;
@@ -2675,16 +2688,18 @@ function dischargeProblem(row, o, events, cwd) {
2675
2688
  * The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
2676
2689
  * `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
2677
2690
  * It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
2691
+ * `memo` (I2, optional): a store-scoped table reusing the immutable git re-proof of an integration mapping
2692
+ * across folds; every artifact, hash, criteria and reviewer condition is still re-read on every fold.
2678
2693
  */
2679
- export function foldOwedChecks(events, cwd) {
2694
+ export function foldOwedChecks(events, cwd, memo) {
2680
2695
  try {
2681
- return foldOwedChecksOrThrow(events, cwd);
2696
+ return foldOwedChecksOrThrow(events, cwd, memo);
2682
2697
  }
2683
2698
  catch (error) {
2684
2699
  return { known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason: `owed-check fold failed: ${String(error).split("\n")[0]}` }] };
2685
2700
  }
2686
2701
  }
2687
- function foldOwedChecksOrThrow(events, cwd) {
2702
+ function foldOwedChecksOrThrow(events, cwd, memo) {
2688
2703
  const accepted = new Map();
2689
2704
  const discharged = new Set();
2690
2705
  const unknown = [];
@@ -2703,7 +2718,7 @@ function foldOwedChecksOrThrow(events, cwd) {
2703
2718
  const ids = Array.isArray(e.data.ids) ? e.data.ids : [];
2704
2719
  const problems = ids.map((id) => {
2705
2720
  const o = accepted.get(String(id));
2706
- return o ? dischargeProblem(e, o, events, cwd) : "discharge names no recorded obligation";
2721
+ return o ? dischargeProblem(e, o, events, cwd, memo) : "discharge names no recorded obligation";
2707
2722
  });
2708
2723
  const problem = ids.length ? problems.find((p) => p !== undefined) : "discharge names no obligation";
2709
2724
  if (problem)
@@ -0,0 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { AuthHealth, LaunchServicesRecord } from "../adapters/types.js";
3
+ /**
4
+ * v2.6.5 T10 (A1, D-787/D-789): did launchservicesd restart since tickmarkr last looked?
5
+ *
6
+ * On 2026-09-30 a heavy local suite exhausted launchservicesd's Mach port space (0x1000600b); the daemon
7
+ * crashed, respawned with an empty app registry, and every GUI app launched before it read "not open
8
+ * anymore". The tell is the daemon's own identity: a young pid/start beside an old Dock and Finder.
9
+ * doctor and standalone verify read that identity with one bounded /bin/ps call, compare it with the
10
+ * one the FIRST doctor.json record holds (registry order — no new state file or adapter row), and
11
+ * persist one advisory on a change. Unreadable evidence is unknown: the prior identity is kept, never
12
+ * replaced by a guess. Advisory only — verdicts, retries and routing never read it.
13
+ */
14
+ export declare const LAUNCHSERVICES_PROBE_CEILING_MS = 5000;
15
+ export interface LaunchServicesIdentity {
16
+ pid: number;
17
+ start: string;
18
+ }
19
+ export type LaunchServicesObservation = {
20
+ status: "non-darwin";
21
+ } | {
22
+ status: "read";
23
+ identity: LaunchServicesIdentity;
24
+ } | {
25
+ status: "unreadable";
26
+ detail: string;
27
+ };
28
+ /** The closed identity table; "first" is the baseline row when no prior identity is held. */
29
+ export type LaunchServicesStatus = "first" | "unchanged" | "changed-pid" | "changed-start" | "unreadable" | "non-darwin" | "skipped";
30
+ /** Exactly one launchservicesd row, or null (absent, ambiguous or malformed output is unknown). */
31
+ export declare function parsePsIdentity(stdout: string): LaunchServicesIdentity | null;
32
+ export type ProbeTimer = (fire: () => void, ms: number) => () => void;
33
+ interface ProbeSeam {
34
+ platform: NodeJS.Platform;
35
+ spawn: typeof spawn;
36
+ setTimer: ProbeTimer;
37
+ signal?: AbortSignal;
38
+ }
39
+ export declare const setLaunchServicesProbeForTests: (s: Partial<ProbeSeam>) => void;
40
+ export declare const resetLaunchServicesProbeForTests: () => void;
41
+ export declare function observeLaunchServices(s?: ProbeSeam): Promise<LaunchServicesObservation>;
42
+ export declare function launchServicesAdvisory(prior: LaunchServicesIdentity, now: LaunchServicesIdentity): string;
43
+ /** Pure: the table row for one observation against the held record, and the record to hold next. */
44
+ export declare function classifyLaunchServices(prior: Partial<LaunchServicesRecord> | undefined, observed: LaunchServicesObservation): {
45
+ status: LaunchServicesStatus;
46
+ next?: LaunchServicesRecord;
47
+ line?: string;
48
+ };
49
+ /** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
50
+ * Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
51
+ export declare function recordLaunchServices(prior: Record<string, AuthHealth> | null, health: Record<string, AuthHealth>): Promise<{
52
+ status: LaunchServicesStatus;
53
+ health: Record<string, AuthHealth>;
54
+ line?: string;
55
+ }>;
56
+ export {};
@@ -0,0 +1,117 @@
1
+ import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ /**
4
+ * v2.6.5 T10 (A1, D-787/D-789): did launchservicesd restart since tickmarkr last looked?
5
+ *
6
+ * On 2026-09-30 a heavy local suite exhausted launchservicesd's Mach port space (0x1000600b); the daemon
7
+ * crashed, respawned with an empty app registry, and every GUI app launched before it read "not open
8
+ * anymore". The tell is the daemon's own identity: a young pid/start beside an old Dock and Finder.
9
+ * doctor and standalone verify read that identity with one bounded /bin/ps call, compare it with the
10
+ * one the FIRST doctor.json record holds (registry order — no new state file or adapter row), and
11
+ * persist one advisory on a change. Unreadable evidence is unknown: the prior identity is kept, never
12
+ * replaced by a guess. Advisory only — verdicts, retries and routing never read it.
13
+ */
14
+ export const LAUNCHSERVICES_PROBE_CEILING_MS = 5_000;
15
+ // lstart in the C locale: "Wed Sep 30 21:54:33 2026"; comm is the executable path on Darwin.
16
+ const PS_ARGS = ["-A", "-o", "pid=,lstart=,comm="];
17
+ const PS_LINE = /^\s*(\d+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+?)\s*$/;
18
+ const MAX_PS_BYTES = 4 * 1024 * 1024;
19
+ /** Exactly one launchservicesd row, or null (absent, ambiguous or malformed output is unknown). */
20
+ export function parsePsIdentity(stdout) {
21
+ const rows = stdout.split("\n").flatMap((line) => {
22
+ const m = PS_LINE.exec(line);
23
+ return m && basename(m[3]) === "launchservicesd" ? [{ pid: Number(m[1]), start: m[2].replace(/\s+/g, " ") }] : [];
24
+ });
25
+ return rows.length === 1 ? rows[0] : null;
26
+ }
27
+ const production = () => ({
28
+ platform: process.platform,
29
+ spawn,
30
+ setTimer: (fire, ms) => { const t = setTimeout(fire, ms); return () => clearTimeout(t); },
31
+ });
32
+ // ponytail: default-off under the test runner (the catalogRefreshAllowed precedent) so an unowned doctor
33
+ // test never takes a real host reading or changes its doctor.json bytes; tests opt in through the seam.
34
+ let seam;
35
+ export const setLaunchServicesProbeForTests = (s) => { seam = { ...production(), ...s }; };
36
+ export const resetLaunchServicesProbeForTests = () => { seam = undefined; };
37
+ const activeSeam = () => seam ?? (process.env.VITEST === "true" ? undefined : production());
38
+ /** One /bin/ps call that owns only its child, killed at the ceiling or on cancellation and reaped before it resolves. */
39
+ function readIdentity(s) {
40
+ return new Promise((resolve) => {
41
+ const unknown = (detail) => resolve({ status: "unreadable", detail });
42
+ if (s.signal?.aborted) {
43
+ unknown("probe cancelled");
44
+ return;
45
+ }
46
+ let child;
47
+ try {
48
+ child = s.spawn("/bin/ps", PS_ARGS, { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, LC_ALL: "C" } });
49
+ }
50
+ catch (e) {
51
+ unknown(`/bin/ps did not start (${e instanceof Error ? e.message : String(e)})`);
52
+ return;
53
+ }
54
+ let out = "";
55
+ let why;
56
+ const retire = (reason) => { why ??= reason; child.kill("SIGKILL"); };
57
+ const clear = s.setTimer(() => retire(`/bin/ps exceeded the ${LAUNCHSERVICES_PROBE_CEILING_MS} ms kill ceiling`), LAUNCHSERVICES_PROBE_CEILING_MS);
58
+ const cancel = () => retire("probe cancelled");
59
+ s.signal?.addEventListener("abort", cancel, { once: true });
60
+ child.stdout?.on("data", (chunk) => {
61
+ out += chunk.toString("utf8");
62
+ if (out.length > MAX_PS_BYTES)
63
+ retire("/bin/ps output exceeded its bound");
64
+ });
65
+ child.once("error", (e) => { why ??= `/bin/ps failed (${e.message})`; });
66
+ child.once("close", (code) => {
67
+ clear();
68
+ s.signal?.removeEventListener("abort", cancel);
69
+ if (why)
70
+ return unknown(why);
71
+ if (code !== 0)
72
+ return unknown(`/bin/ps exited ${code}`);
73
+ const identity = parsePsIdentity(out);
74
+ return identity ? resolve({ status: "read", identity }) : unknown("no single launchservicesd row in /bin/ps output");
75
+ });
76
+ });
77
+ }
78
+ export function observeLaunchServices(s = production()) {
79
+ return s.platform === "darwin" ? readIdentity(s) : Promise.resolve({ status: "non-darwin" });
80
+ }
81
+ // doctor.json is never schema-validated: a malformed held record is no identity at all.
82
+ const knownPrior = (prior) => typeof prior?.pid === "number" && typeof prior.start === "string";
83
+ export function launchServicesAdvisory(prior, now) {
84
+ return `launchservicesd restarted: pid ${prior.pid} → ${now.pid}, started ${prior.start} → ${now.start}. `
85
+ + `GUI apps launched before ${now.start} may read "not open anymore" (D-787: the daemon crashed and respawned with an empty app registry). `
86
+ + `Remedy: quit and relaunch each affected app, or log out / restart the Mac (a restart ends agent sessions and wipes /private/tmp); `
87
+ + `confirm with /usr/bin/log show (launchservicesd 0x1000600b near ${now.start}) and ~/Library/Logs/DiagnosticReports/ExcUserFault_node-*.ips (advisory — verdicts unchanged)`;
88
+ }
89
+ /** Pure: the table row for one observation against the held record, and the record to hold next. */
90
+ export function classifyLaunchServices(prior, observed) {
91
+ const kept = knownPrior(prior) ? prior : undefined;
92
+ if (observed.status === "non-darwin")
93
+ return { status: "non-darwin", next: kept };
94
+ if (observed.status === "unreadable") {
95
+ const held = kept ? `pid ${kept.pid} started ${kept.start}` : "none";
96
+ return { status: "unreadable", next: kept, line: `launchservicesd identity unknown — ${observed.detail}; prior identity (${held}) retained (advisory — verdicts unchanged)` };
97
+ }
98
+ const now = observed.identity;
99
+ if (!knownPrior(prior))
100
+ return { status: "first", next: now };
101
+ const status = prior.pid !== now.pid ? "changed-pid" : prior.start !== now.start ? "changed-start" : "unchanged";
102
+ if (status === "unchanged")
103
+ return { status, next: prior };
104
+ const advisory = launchServicesAdvisory(prior, now);
105
+ return { status, next: { ...now, advisory }, line: advisory };
106
+ }
107
+ /** Observe, classify against the first record of `prior`, and hold the result on the first record of `health`.
108
+ * Returns `health` itself when nothing changed, else a copy — the argument is never mutated. */
109
+ export async function recordLaunchServices(prior, health) {
110
+ const held = prior ? Object.values(prior)[0]?.launchServices : undefined;
111
+ const s = activeSeam();
112
+ const r = s ? classifyLaunchServices(held, await observeLaunchServices(s)) : { status: "skipped", next: knownPrior(held) ? held : undefined };
113
+ const holder = Object.keys(health)[0];
114
+ if (!holder || !r.next || JSON.stringify(health[holder].launchServices) === JSON.stringify(r.next))
115
+ return { ...r, health };
116
+ return { ...r, health: { ...health, [holder]: { ...health[holder], launchServices: r.next } } };
117
+ }