omp-conductor 0.16.0 → 0.16.2

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.
@@ -2005,7 +2005,7 @@ export async function setup(
2005
2005
  areaArg?: AmendAreaId,
2006
2006
  probes: SetupProbes = DEFAULT_PROBES,
2007
2007
  apply: SetupApplyDeps = DEFAULT_APPLY,
2008
- ): Promise<void> {
2008
+ ): Promise<boolean> {
2009
2009
  const path = configPath();
2010
2010
  // A config that exists but does not parse is a fault to report, never
2011
2011
  // something to quietly replace: overwriting it would delete every project it
@@ -2021,7 +2021,7 @@ export async function setup(
2021
2021
  } catch (err) {
2022
2022
  if (!(err instanceof Cancelled)) throw err;
2023
2023
  ui.notify("Setup cancelled — nothing was changed.", "info");
2024
- return;
2024
+ return false;
2025
2025
  }
2026
2026
  // `answers` is mutable below: the review loop re-asks one area at a time and
2027
2027
  // replaces only the fields that area owns, so everything else carries across.
@@ -2035,7 +2035,7 @@ export async function setup(
2035
2035
  `Run \`gh auth refresh -s repo,project\`, then run setup again.`,
2036
2036
  "error",
2037
2037
  );
2038
- return;
2038
+ return false;
2039
2039
  }
2040
2040
 
2041
2041
  // Drafted here — after the interview, before the plan — because the plan's single
@@ -2183,7 +2183,7 @@ export async function setup(
2183
2183
  .join(" ");
2184
2184
 
2185
2185
  let plan = await derive();
2186
- if (plan === undefined) return;
2186
+ if (plan === undefined) return false;
2187
2187
  ui.notify(planBlock(plan), "info");
2188
2188
  // -------- the consent gate (#417): a review loop, not a yes/no --------
2189
2189
  //
@@ -2206,12 +2206,12 @@ export async function setup(
2206
2206
  );
2207
2207
  if (choice === undefined) {
2208
2208
  ui.notify("Setup cancelled — nothing was changed, and the answers were discarded.", "info");
2209
- return;
2209
+ return false;
2210
2210
  }
2211
2211
  if (choice === REVIEW_APPLY) break;
2212
2212
  if (choice === REVIEW_EXIT) {
2213
2213
  ui.notify("Setup exited without applying — the answers were discarded and nothing was changed.", "info");
2214
- return;
2214
+ return false;
2215
2215
  }
2216
2216
  if (choice === REVIEW_REVIEW) {
2217
2217
  ui.notify(planBlock(plan), "info");
@@ -2259,7 +2259,7 @@ export async function setup(
2259
2259
  proseDrafted = true;
2260
2260
  }
2261
2261
  const redrafted = await derive();
2262
- if (redrafted === undefined) return;
2262
+ if (redrafted === undefined) return false;
2263
2263
  plan = redrafted;
2264
2264
  ui.notify(`Edited "${area.label}" — the plan below is recomputed from the new answers.`, "info");
2265
2265
  ui.notify(planBlock(plan), "info");
@@ -2302,7 +2302,7 @@ export async function setup(
2302
2302
  // enables the timer, which does not touch the running daemon.
2303
2303
  await offerCodeGraph(plan.project, ui, apply.graphInstall);
2304
2304
  ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2305
- return;
2305
+ return false;
2306
2306
  }
2307
2307
  // No live workers: still prefer an explicit reload when this run *added* a
2308
2308
  // neighbour — auto-restart would bounce every other project's heartbeat for
@@ -2342,7 +2342,7 @@ export async function setup(
2342
2342
  "warning",
2343
2343
  );
2344
2344
  ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2345
- return;
2345
+ return false;
2346
2346
  }
2347
2347
  }
2348
2348
  apply.resume(plan.project.name);
@@ -2409,4 +2409,5 @@ export async function setup(
2409
2409
  // to create/verify the herdr pane. Added projects especially — the CLI wrote
2410
2410
  // tick + config but cannot start a herdr agent (#319).
2411
2411
  ui.notify(formatHerdrHandoff(plan.project, plan.nextConfig), "info");
2412
+ return true;
2412
2413
  }
package/src/transcript.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Read one property off an unvalidated transcript entry. */
2
- function prop(source: unknown, key: string): unknown {
2
+ export function prop(source: unknown, key: string): unknown {
3
3
  if (source === null || typeof source !== "object") return undefined;
4
4
  return Reflect.get(source, key);
5
5
  }
@@ -382,6 +382,19 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
382
382
  }
383
383
  }
384
384
 
385
+ /**
386
+ * Doctor findings that only a privileged `setup host` can clear — the systemd
387
+ * units (and the recovery pair they name), and the herdr pane-shell key. A
388
+ * release that re-renders any of those templates fails them on every host
389
+ * whose `setup host` has not been re-run, so they are not rollback-worthy
390
+ * regression evidence: the upgrade is deliberately unable to perform the
391
+ * privileged install, and rolling back over drift it cannot clear would
392
+ * strand the fleet on the previous release with the same outstanding `setup
393
+ * host` (#598). The verifier escalates them instead — the doctor check's
394
+ * detail and the verify report name the drift and its fix.
395
+ */
396
+ const PRIVILEGED_INSTALL_DRIFT = new Set(["systemd-unit", "systemd-recovery", "herdr-resume"]);
397
+
385
398
  /**
386
399
  * The independent checks the returning process runs. Each compares a live
387
400
  * fact with the journal's record of the world the install began in:
@@ -392,7 +405,9 @@ function resumeUpgradePause(deps: UpgradeVerifyDeps, request: PendingUpgradeRequ
392
405
  * same predicate the in-process upgrade used, so a fleet that was armed
393
406
  * stays armed and a live pane stays live;
394
407
  * - `health` — the daemon that came back answers `/healthz`;
395
- * - `doctor` — no failing finding.
408
+ * - `doctor` — no failing finding beyond the privileged-install drift
409
+ * ({@link PRIVILEGED_INSTALL_DRIFT}) only `setup host` can clear, which is
410
+ * escalated rather than rolled back.
396
411
  */
397
412
  export async function runUpgradeChecks(
398
413
  deps: UpgradeVerifyDeps,
@@ -423,10 +438,17 @@ export async function runUpgradeChecks(
423
438
 
424
439
  const doctor = await deps.doctor(deps.projectName);
425
440
  const failing = doctor.findings.filter((finding) => finding.status === "fail");
441
+ const drift = failing.filter((finding) => PRIVILEGED_INSTALL_DRIFT.has(finding.id));
442
+ const regression = failing.filter((finding) => !PRIVILEGED_INSTALL_DRIFT.has(finding.id));
426
443
  checks.push({
427
444
  name: "doctor",
428
- ok: failing.length === 0,
429
- detail: failing.length === 0 ? `status ${doctor.status}` : `${failing[0]!.id}: ${failing[0]!.summary}`,
445
+ ok: regression.length === 0,
446
+ detail:
447
+ regression.length > 0
448
+ ? `${regression[0]!.id}: ${regression[0]!.summary}`
449
+ : drift.length > 0
450
+ ? `status ${doctor.status}; ${drift.map((f) => f.id).join(", ")} — privileged-install drift, run \`omp-conductor setup host\` from the fleet account`
451
+ : `status ${doctor.status}`,
430
452
  });
431
453
  }
432
454
 
package/src/upgrade.ts CHANGED
@@ -14,9 +14,9 @@ import { livingDaemon, restartDaemon } from "./lifecycle.ts";
14
14
  import { configBackupDir, configPath, findProject, loadConfig, resolveCaps, stateDir, writeConfigRaw } from "./config.ts";
15
15
  import { renderBriefForProject } from "./setup.ts";
16
16
  import {
17
- STAGED_SERVICE_NAME,
18
17
  planHostRuntime,
19
- writeHostRuntime,
18
+ totalConfiguredWorkers,
19
+ type HostRuntimePlan,
20
20
  } from "./setup-host.ts";
21
21
  import {
22
22
  appendJournal,
@@ -95,6 +95,17 @@ export interface UpgradeDeps {
95
95
  sleep(ms: number): Promise<void>;
96
96
  env: NodeJS.ProcessEnv;
97
97
  log(message: string): void;
98
+ /**
99
+ * The host runtime this version's package renders, compared against what is
100
+ * actually installed: the daemon and herdr units, the recovery unit and its
101
+ * playbook at {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell
102
+ * config and the herdr-conductor `config.env`. `upgrade` prints
103
+ * {@link HostRuntimePlan.drift} after a release that re-rendered those
104
+ * files, so the operator is told that `setup host` is owed before a later
105
+ * `doctor` has to discover it. Read-only: the plan renders and compares,
106
+ * it never writes a host file.
107
+ */
108
+ hostRuntime(): HostRuntimePlan;
98
109
  /**
99
110
  * The durable journal sink for the detached fleet installer (#486). Wired
100
111
  * to the state-dir journal by `upgrade-install`/`upgrade-rollback`; absent
@@ -135,6 +146,13 @@ export const DEFAULT_DEPS: UpgradeDeps = {
135
146
  restartDaemon: async () => {
136
147
  await restartDaemon({});
137
148
  },
149
+ // The bare host-global plan — the same render the advisory's `setup host`
150
+ // command would install: no per-project tail, the recovery unit encoding no
151
+ // one project's name, and FLEET_CWDS derived from every configured project.
152
+ hostRuntime: () => {
153
+ const cfg = loadConfig();
154
+ return planHostRuntime(undefined, cfg.defaults, telegramStateDir(), undefined, totalConfiguredWorkers(cfg));
155
+ },
138
156
  sleep: Bun.sleep,
139
157
  env: process.env,
140
158
  log: (message) => process.stdout.write(`${message}\n`),
@@ -821,6 +839,37 @@ export async function rollbackUpgrade(
821
839
  if (failures.length > 0) throw new Error(failures.join("; "));
822
840
  }
823
841
 
842
+ /**
843
+ * Name the host-runtime destinations this version's render no longer matches,
844
+ * after a successful upgrade — the installed units, the recovery playbook at
845
+ * {@link RECOVER_SCRIPT_INSTALL_PATH}, the herdr pane-shell config and the
846
+ * herdr-conductor `config.env`. A release that re-renders any of those files
847
+ * silently invalidates the installed copies; telling the operator here — with
848
+ * the exact command — is what stops the drift from surviving until a later
849
+ * `doctor` finds it (#598).
850
+ *
851
+ * The plan *is* the comparison: {@link UpgradeDeps.hostRuntime} renders every
852
+ * destination against disk, so a current host prints nothing and a drifted one
853
+ * names exactly the files only a privileged `setup host` can refresh. This
854
+ * never mutates a host file and never fails the upgrade — an unreadable host
855
+ * state is a warning line, not a reason to roll a successful install back.
856
+ */
857
+ function logHostRuntimeDrift(deps: UpgradeDeps): void {
858
+ let plan: HostRuntimePlan;
859
+ try {
860
+ plan = deps.hostRuntime();
861
+ } catch (err) {
862
+ deps.log(
863
+ `host runtime: could not compare against this version's render — ${err instanceof Error ? err.message : String(err)}`,
864
+ );
865
+ return;
866
+ }
867
+ if (plan.drift.length === 0) return;
868
+ deps.log("host runtime:");
869
+ for (const path of plan.drift) deps.log(` ${path} differs from this version's render`);
870
+ deps.log("fix: run `omp-conductor setup host` from the fleet account to re-install the host units");
871
+ }
872
+
824
873
  export async function upgradeConductor(
825
874
  options: UpgradeOptions = {},
826
875
  overrides: Partial<UpgradeDeps> = {},
@@ -871,6 +920,11 @@ export async function upgradeConductor(
871
920
  detail: "all three surfaces and every brief were already pinned to the target release",
872
921
  });
873
922
  }
923
+ // Packages are current, but the host units they render may be behind: the
924
+ // same drift this advisory names after an install applies when a release
925
+ // re-rendered a template since the operator's last `setup host`. Silent on
926
+ // a host whose runtime matches (#598).
927
+ logHostRuntimeDrift(deps);
874
928
  return {
875
929
  previousVersion: surfaces.cliVersion,
876
930
  ...release,
@@ -1118,6 +1172,11 @@ export async function upgradeConductor(
1118
1172
  throw new Error(`upgrade failed: ${failure}; previous installation restored; dispatch remains paused`);
1119
1173
  }
1120
1174
 
1175
+ // The install landed; name any host-runtime destination this version no
1176
+ // longer renders identically, so the operator knows `setup host` is owed
1177
+ // before a later `doctor` reports it as drift (#598).
1178
+ logHostRuntimeDrift(deps);
1179
+
1121
1180
  return {
1122
1181
  previousVersion: surfaces.cliVersion,
1123
1182
  ...release,
package/src/worker.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * sliding into a merge queue.
11
11
  */
12
12
 
13
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
13
15
  import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
14
16
  import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
15
17
  import type { Caps, ResolvedGrants, RunState } from "./types.ts";
@@ -321,6 +323,17 @@ export async function runWorker(
321
323
  const schedule = deps.schedule ?? scheduleWallClock;
322
324
 
323
325
  let session: AgentSessionLike;
326
+ // The per-issue reviewer brief (#542): when the staged omp settings turn the
327
+ // advisor on, drop a WATCHDOG.md rendered from this brief's own acceptance
328
+ // criteria into the worktree before the session exists — the advisor's
329
+ // watchdog discovery runs at session start, rooted at cwd, and a file that
330
+ // appears afterwards is a reviewer flying blind. The managed exclude block
331
+ // ignores that exact path (never `.omp/`), so it cannot reach the PR diff or
332
+ // a salvage commit. Best-effort: a reviewer brief is advisory, and a staging
333
+ // failure must not cost the run.
334
+ if (o.resume === undefined && o.ompSettingsFile !== undefined) {
335
+ stageIssueWatchdog(o.cwd, o.brief, o.ompSettingsFile);
336
+ }
324
337
  try {
325
338
  session = await deps.createSession({
326
339
  cwd: o.cwd,
@@ -384,12 +397,33 @@ export async function runWorker(
384
397
  | "modelRecoveries"
385
398
  | "autoRetryCount"
386
399
  | "autoCompactionCount";
400
+ // Folded once, on whichever exit path actually runs: every return below
401
+ // passes through `withSessionFacts`, so the advisor's separately-recorded
402
+ // spend (#542) is added to exactly one result and reported to the daemon
403
+ // exactly once.
404
+ let advisorSpendFolded = false;
387
405
  const withSessionFacts = (
388
406
  result: Omit<WorkerResult, ReliabilityKeys>,
389
407
  ): Omit<WorkerResult, ReliabilityKeys> => {
390
408
  const { sessionFile, modelFallbackMessage } = session;
409
+ if (!advisorSpendFolded) {
410
+ advisorSpendFolded = true;
411
+ // Advisor turns live in their own `__advisor*.jsonl` and never surface
412
+ // as primary `message_end`s, so without this read the run's spend — and
413
+ // therefore `caps.dailySpendUsd` — silently under-counts real provider
414
+ // consumption (the #46 shape). Read at the very end: the advisor's
415
+ // review of the final turn may still be landing.
416
+ const extra = advisorSpendUsd(sessionFile);
417
+ if (extra > 0) {
418
+ spendUsd += extra;
419
+ o.onSpend?.(spendUsd);
420
+ }
421
+ }
391
422
  return {
392
423
  ...result,
424
+ // Override whatever the exit path spelled: its literal captured the
425
+ // pre-fold `spendUsd`, and the advisor fold happened after.
426
+ spendUsd,
393
427
  ...(sessionFile === undefined ? {} : { sessionFile }),
394
428
  ...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
395
429
  };
@@ -807,6 +841,168 @@ function field(source: unknown, key: string): unknown {
807
841
  return Reflect.get(source, key);
808
842
  }
809
843
 
844
+ /** Advisor transcripts are recorded under this reserved stem beside the session's. */
845
+ const ADVISOR_TRANSCRIPT_PREFIX = "__advisor";
846
+
847
+ /**
848
+ * Whether a staged omp-settings overlay (the file `materializeOmpSettings`
849
+ * writes under the run's session directory) turns the omp advisor on.
850
+ *
851
+ * The overlay is conductor's own YAML — written by `yaml.stringify` over a map
852
+ * this package controls — so this is deliberately not a general YAML parser:
853
+ * it scans for an `advisor:` mapping and asks whether one of its direct
854
+ * `enabled:` keys reads the literal `true`. Handles both the block form the
855
+ * overlay is written in (`advisor:\n enabled: true`) and an inline flow map
856
+ * (`advisor: { enabled: true }`); everything else — `enabled: false`, no
857
+ * `advisor` key, an absent overlay — answers false. The staged settings are
858
+ * what the harness itself resolves, so this is the same truth the session
859
+ * acts on, not a parallel decode. Exported so the staging decision in
860
+ * {@link runWorker} is pinned by a unit test.
861
+ */
862
+ export function overlayEnablesAdvisor(overlayText: string): boolean {
863
+ const lines = overlayText.split(/\r?\n/);
864
+ for (let i = 0; i < lines.length; i++) {
865
+ const match = /^(\s*)advisor\s*:\s*(.*)$/.exec(lines[i] ?? "");
866
+ if (match === null) continue;
867
+ if (/\benabled\s*:\s*true\b/.test(match[2] ?? "")) return true;
868
+ const indent = match[1] ?? "";
869
+ for (let j = i + 1; j < lines.length; j++) {
870
+ const line = lines[j];
871
+ if (line === undefined || line.trim() === "") continue;
872
+ // A line at or shallower than `advisor`'s own indent ends its block.
873
+ if (!line.startsWith(`${indent} `) && !line.startsWith(`${indent}\t`)) break;
874
+ if (/^\s*enabled\s*:\s*true\s*$/.test(line)) return true;
875
+ }
876
+ }
877
+ return false;
878
+ }
879
+
880
+ /** A heading that begins a brief's acceptance section, whatever the brief's casing. */
881
+ const ACCEPTANCE_HEADING = /^#{1,4}\s+acceptance\s+criteri(?:a|on)\s*$/im;
882
+
883
+ /**
884
+ * Render the per-issue reviewer brief (#542): the acceptance section of one
885
+ * worker brief, wrapped so the omp advisor checks the primary against *this
886
+ * run's own* criteria rather than generic taste. Returns `undefined` when the
887
+ * brief carries no acceptance section — nothing useful to review against.
888
+ *
889
+ * The section runs from the first heading mentioning acceptance criteria to
890
+ * the next heading of any level (or the end of the brief). Exported so a unit
891
+ * test can pin the rendering without standing up a session.
892
+ */
893
+ export function renderIssueWatchdog(brief: string): string | undefined {
894
+ const lines = brief.split(/\r?\n/);
895
+ let start = -1;
896
+ for (let i = 0; i < lines.length; i++) {
897
+ if (ACCEPTANCE_HEADING.test(lines[i] ?? "")) {
898
+ start = i + 1;
899
+ break;
900
+ }
901
+ }
902
+ if (start === -1) return undefined;
903
+ const body: string[] = [];
904
+ for (let i = start; i < lines.length; i++) {
905
+ const line = lines[i];
906
+ if (line === undefined || /^#{1,4}\s/.test(line)) break;
907
+ body.push(line);
908
+ }
909
+ const trimmed = body.join("\n").replace(/\n{3,}/g, "\n\n").trim();
910
+ if (trimmed === "") return undefined;
911
+ return (
912
+ "# Issue watchdog (rendered from this run's brief)\n\n" +
913
+ "This run's own acceptance criteria. Review the worker against these — not generic taste: " +
914
+ "a criterion is met only when the transcript or workspace shows it actually verified, and a " +
915
+ "worker claiming completion without evidence is a concern at least.\n\n" +
916
+ "## Acceptance criteria\n\n" +
917
+ `${trimmed}\n`
918
+ );
919
+ }
920
+
921
+ /**
922
+ * Stage the per-issue `WATCHDOG.md` into the worktree when the staged omp
923
+ * settings turn the advisor on (#542).
924
+ *
925
+ * The omp advisor's watchdog discovery is cwd-rooted, so per-issue guidance
926
+ * cannot ride the out-of-tree settings overlay: it must live inside the
927
+ * worktree, at `<cwd>/.omp/WATCHDOG.md`, which the managed exclude block
928
+ * ignores by that exact path (never the `.omp/` directory) so it can never
929
+ * reach the diff a worker ships or a salvage commit. Best-effort on purpose:
930
+ * a reviewer brief is advisory, and a worktree too broken to take it will fail
931
+ * the run on its own terms. Exported so the runWorker staging decision is
932
+ * testable without a session.
933
+ */
934
+ export function stageIssueWatchdog(cwd: string, brief: string, overlayFile: string): boolean {
935
+ let overlayText: string;
936
+ try {
937
+ overlayText = readFileSync(overlayFile, "utf8");
938
+ } catch {
939
+ return false;
940
+ }
941
+ if (!overlayEnablesAdvisor(overlayText)) return false;
942
+ const watchdog = renderIssueWatchdog(brief);
943
+ if (watchdog === undefined) return false;
944
+ try {
945
+ const dir = join(cwd, ".omp");
946
+ mkdirSync(dir, { recursive: true });
947
+ writeFileSync(join(dir, "WATCHDOG.md"), watchdog);
948
+ return true;
949
+ } catch {
950
+ return false;
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Total USD cost of one run's advisor turns (#542).
956
+ *
957
+ * Advisor turns are recorded to a separate `__advisor*.jsonl` beside the
958
+ * session transcript — the primary session never sees them as `message_end`s —
959
+ * so without this a run with an advisor under-counts `spendUsd` and the daily
960
+ * cap is theater, the #46 failure mode. Reads every advisor transcript in the
961
+ * session's advisor directory and sums the same `usage.cost` blocks
962
+ * {@link costUsdFromMessage} reads off primary messages: one spelling, so the
963
+ * two cannot drift. Unreadable or absent transcripts bill nothing — the run
964
+ * with no advisor stays a zero, and a corrupt advisor log must not crash the
965
+ * settlement.
966
+ */
967
+ export function advisorSpendUsd(sessionFile: string | undefined): number {
968
+ if (sessionFile === undefined || !sessionFile.endsWith(".jsonl")) return 0;
969
+ // The harness records advisor transcripts in the directory named after the
970
+ // primary transcript stem (`<dir>/<stem>/__advisor*.jsonl`); slicing the
971
+ // suffix is exactly what the harness's own cost loader does.
972
+ const dir = sessionFile.slice(0, -".jsonl".length);
973
+ let names: string[];
974
+ try {
975
+ names = readdirSync(dir);
976
+ } catch {
977
+ return 0;
978
+ }
979
+ let total = 0;
980
+ for (const name of names) {
981
+ if (!name.startsWith(ADVISOR_TRANSCRIPT_PREFIX) || !name.endsWith(".jsonl")) continue;
982
+ let text: string;
983
+ try {
984
+ text = readFileSync(join(dir, name), "utf8");
985
+ } catch {
986
+ continue;
987
+ }
988
+ for (const line of text.split("\n")) {
989
+ if (line.trim() === "") continue;
990
+ let entry: unknown;
991
+ try {
992
+ entry = JSON.parse(line);
993
+ } catch {
994
+ continue;
995
+ }
996
+ if (field(entry, "type") !== "message") continue;
997
+ const message = field(entry, "message");
998
+ if (field(message, "role") !== "assistant") continue;
999
+ const cost = costUsdFromMessage(message);
1000
+ if (cost !== undefined) total += cost;
1001
+ }
1002
+ }
1003
+ return total;
1004
+ }
1005
+
810
1006
  /** Flatten an assistant message's content blocks to their plain text. */
811
1007
  /**
812
1008
  * The newest assistant text, flattened out of whatever block shape the harness
package/src/worktree.ts CHANGED
@@ -156,8 +156,20 @@ const EXCLUDE_END = "# <<< omp-conductor";
156
156
  * the 2026-08-07 incident's exact shape (`.scratch82/env.sh`). Broader
157
157
  * conventions belong in a repo's own `.gitignore`, where its operator chooses
158
158
  * them, rather than being imposed by whatever dispatcher happens to be driving.
159
+ *
160
+ * The advisor watchdog (`#542`) is the deliberate exception, and the exact-path
161
+ * spelling is the whole point: with the advisor on, the dispatcher writes a
162
+ * per-issue `<worktree>/.omp/WATCHDOG.md` from the brief's acceptance criteria
163
+ * so the mid-run reviewer checks the worker against its own issue — and that
164
+ * file must never reach the diff a worker ships or the salvage commit. The
165
+ * standing rule says an ignored *new* file is invisible to salvage, so a name
166
+ * that could plausibly be a deliverable must never appear; `.omp/WATCHDOG.md`
167
+ * is the narrowest name that can. Only this exact file is excluded, never the
168
+ * `.omp/` directory — a directory-wide ignore would hide any future
169
+ * deliverable an operator legitimately places under `.omp/` (the very trap the
170
+ * `.scratch*` history describes), and the blind spot stays one path wide.
159
171
  */
160
- const LOCAL_EXCLUDE = [".scratch*/"];
172
+ const LOCAL_EXCLUDE = [".scratch*/", ".omp/WATCHDOG.md"];
161
173
 
162
174
  /**
163
175
  * Adds the managed block to an `info/exclude`, preserving everything else.
@@ -273,7 +273,7 @@ fresh_rollback_snapshot() {
273
273
  newest=$(ls -t "$BACKUP_ROOT"/config.json.pre-upgrade-* 2>/dev/null | head -n 1 || true)
274
274
  [[ -n $newest ]] || return 1
275
275
  now=$(epoch_now)
276
- ts=$(stat -c %Y "$newest" 2>/dev/null || true)
276
+ ts=$(stat -c %Y "$newest" 2>/dev/null || stat -f %m "$newest" 2>/dev/null || true)
277
277
  [[ -n $ts && $((now - ts)) -le $ROLLBACK_AGE_S ]] || return 1
278
278
  printf '%s\n' "$newest"
279
279
  }
@@ -126,14 +126,14 @@ unit_state() { # <case-dir> <unit> <state|absent>
126
126
  }
127
127
 
128
128
  # A pre-upgrade snapshot `upgrade` durably keeps (configBackupDir()):
129
- # config.json.pre-upgrade-<ts>. The test pass the age in seconds.
129
+ # config.json.pre-upgrade-<ts>. The test passes the age in seconds.
130
130
  write_snapshot() { # <case-dir> <age-seconds>
131
131
  local d="$1" age="${2:-0}" now
132
132
  now=$(date +%s)
133
133
  local snap="$d/state/backups/config/config.json.pre-upgrade-$now"
134
134
  printf '%s\n' '{"preUpgrade":true}' >"$snap"
135
135
  if (( age > 0 )); then
136
- touch -d "@$(( now - age ))" "$snap"
136
+ perl -e 'utime $ARGV[1], $ARGV[1], $ARGV[0] or die "utime: $!"' "$snap" "$(( now - age ))"
137
137
  fi
138
138
  printf '%s\n' "$snap"
139
139
  }