omp-conductor 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fleet.ts CHANGED
@@ -26,10 +26,10 @@ import {
26
26
  } from "node:fs";
27
27
  import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
- import { dirname, join } from "node:path";
29
+ import { dirname, join, sep } from "node:path";
30
30
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
31
  import { clearArmChallenge, recordArmChallenge } from "./arm-challenge.ts";
32
- import { resolveProjectTopicId, sendTelegram } from "./escalate.ts";
32
+ import { resolveClaimedSessionFile, resolveProjectTopicId, sendTelegram } from "./escalate.ts";
33
33
  import { readPlanUsage, sharedUsageSource } from "./usage.ts";
34
34
  import { readApprovalSurface, readDaemonProfile } from "./approval-surface.ts";
35
35
  import { inspectBriefLayout } from "./brief-upgrade.ts";
@@ -287,13 +287,22 @@ export interface ArmResult {
287
287
  export interface ArmDeps {
288
288
  sendChallenge?: (token: string, owner: string, text: string, topicId?: number) => Promise<void>;
289
289
  /**
290
- * Waits for the challenge to appear as a user turn somewhere under the
291
- * session directory. The waiter owns transcript discovery not the caller —
290
+ * The orchestrator session file the live omp-telegram claim names for this
291
+ * project, or undefined when there is no (readable) claim. The default
292
+ * resolves the claim the same way the send does; tests inject a fixture.
293
+ * Absent → arm watches the cwd-derived session directory, the pre-claim
294
+ * behaviour.
295
+ */
296
+ claimedSessionFile?: () => string | undefined;
297
+ /**
298
+ * Waits for the challenge to appear as a user turn somewhere under the scan
299
+ * directories. The waiter owns transcript discovery — not the caller —
292
300
  * because the reply may land in a session that starts *after* the send, so a
293
301
  * path resolved before the challenge went out can be the wrong file by the
294
- * time the operator answers (#142).
302
+ * time the operator answers (#142), and because the claimed session file can
303
+ * sit in a different directory than the tick cwd implies (#600).
295
304
  */
296
- waitForUserTurn?: (dir: string, code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
305
+ waitForUserTurn?: (dirs: readonly string[], code: string, sentAt: number, timeoutMs: number) => Promise<boolean>;
297
306
  now?: () => number;
298
307
  sleep?: (ms: number) => Promise<void>;
299
308
  timeoutMs?: number;
@@ -332,10 +341,23 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
332
341
  );
333
342
  }
334
343
 
335
- const dir = sessionDirForCwd(tick.cwd);
336
- if (!existsSync(dir)) {
344
+ // One bot, one chat, and — once a host runs more than one fleet — more than
345
+ // one pane that can ask. The challenge names which one, or the operator is
346
+ // answering a question they cannot attribute.
347
+ const named = tick.config.project ?? projectName;
348
+
349
+ // Resolve the orchestrator's live session file *before* the challenge goes
350
+ // out — the same claim the send follows (#600). A pane resumed from a
351
+ // session created elsewhere (herdr pins it to the original transcript)
352
+ // writes a session file outside the directory the tick cwd implies, and a
353
+ // cwd-derived scan would poll the one place the reply is guaranteed not to
354
+ // be. A claim outside the session tree arm scans can never be answered, so
355
+ // that is a stop, not five minutes of polling.
356
+ const claimed = deps.claimedSessionFile !== undefined ? deps.claimedSessionFile() : claimedOrchestratorSessionFile(named);
357
+ const dirs = armSessionScanDirs(tick.cwd, claimed);
358
+ if (dirs.every((d) => !existsSync(d))) {
337
359
  throw new Error(
338
- `no orchestrator session directory at ${dir} — ` +
360
+ `no orchestrator session directory under ${dirs.join(" or ")} — ` +
339
361
  `the inbound proof is read from a user turn in a transcript there. Start the pane orchestrator, let it settle, then arm again`,
340
362
  );
341
363
  }
@@ -347,10 +369,6 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
347
369
  const arm = resolveArmState(path, tick.config.project ?? projectName);
348
370
  const alreadyArmed = arm.armed;
349
371
  const code = makeChallengeCode();
350
- // One bot, one chat, and — once a host runs more than one fleet — more than
351
- // one pane that can ask. The challenge names which one, or the operator is
352
- // answering a question they cannot attribute.
353
- const named = tick.config.project ?? projectName;
354
372
  const text =
355
373
  `Fleet arming check${named === undefined ? "" : ` — project ${named}`}. ` +
356
374
  `Reply to this chat with exactly:\n${code}\n` +
@@ -391,25 +409,37 @@ export async function armTicks(projectName?: string, deps: ArmDeps = {}): Promis
391
409
 
392
410
  const injected = deps.waitForUserTurn;
393
411
  const scan: SessionScan = injected
394
- ? { seen: await injected(dir, code, sentAt, timeoutMs), scanned: [], ignored: [] }
395
- : await waitForChallengeInSessions(dir, code, sentAt, timeoutMs, deps);
412
+ ? { seen: await injected(dirs, code, sentAt, timeoutMs), scanned: [], ignored: [] }
413
+ : await waitForChallengeInSessions(dirs, code, sentAt, timeoutMs, deps);
396
414
  if (!scan.seen) {
397
415
  // The proof missed its window: a lookalike reply must not stay classifiable
398
416
  // after arming gave up, so the record is cleared before the failure lands.
399
417
  clearArmChallenge(named);
418
+ // The claimed session file is named before the bridge/token/chat block: a
419
+ // rotation under the window is visible from this error alone, and on the
420
+ // host this guard exists for the claimed file is the one that held the
421
+ // answer (#600).
400
422
  const listing = [
401
- `session dir: ${dir}`,
423
+ ...(claimed === undefined
424
+ ? []
425
+ : [
426
+ `claimed orchestrator session file: ${claimed}` +
427
+ (dirs.includes(dirname(claimed))
428
+ ? " (watched)"
429
+ : ` (NOT under any watched dir — a reply there can never be seen)`),
430
+ ]),
431
+ `session dir: ${dirs.join(", ")}`,
402
432
  ...scan.scanned.map((f) => ` watched: ${f}`),
403
433
  ...scan.ignored.map((f) => ` ignored (stale, last written before the challenge): ${f}`),
404
434
  ].join("\n");
405
435
  throw new Error(
406
436
  `arm: the challenge never arrived as a user turn in time — NOT armed.\n` +
407
- `Inbound Telegram is not reaching the omp session. Check, in order:\n` +
437
+ listing +
438
+ `\nInbound Telegram is not reaching the omp session. Check, in order:\n` +
408
439
  ` * is the bridge polling? attach and run: /telegram status\n` +
409
440
  ` * is another process holding this bot token? Telegram allows exactly one\n` +
410
441
  ` getUpdates consumer and rejects the second with HTTP 409.\n` +
411
- ` * did you reply in the DM with the bot, not another chat?\n` +
412
- listing,
442
+ ` * did you reply in the DM with the bot, not another chat?\n`,
413
443
  );
414
444
  }
415
445
 
@@ -1605,6 +1635,56 @@ export function sessionDirForCwd(cwd: string): string {
1605
1635
  return join(home, ".omp", "agent", "sessions", slug.replaceAll("/", "-"));
1606
1636
  }
1607
1637
 
1638
+ /** The session tree arm scans: every transcript under it, whatever the cwd slug. */
1639
+ export function sessionsRoot(): string {
1640
+ return join(homedir(), ".omp", "agent", "sessions");
1641
+ }
1642
+
1643
+ /**
1644
+ * The orchestrator session file omp-telegram's live claim names for this
1645
+ * project — the transcript a resumed/restored pane actually writes, which can
1646
+ * live in a different session directory than the tick cwd implies (#600).
1647
+ * Never throws: no claim, or an unreadable config, means undefined and arm
1648
+ * falls back to the cwd-derived directory.
1649
+ */
1650
+ function claimedOrchestratorSessionFile(named: string | undefined): string | undefined {
1651
+ if (named === undefined) return undefined;
1652
+ try {
1653
+ return resolveClaimedSessionFile(findProject(loadConfig(), named));
1654
+ } catch {
1655
+ return undefined;
1656
+ }
1657
+ }
1658
+
1659
+ /**
1660
+ * The session directories `arm` watches for the reply — the cwd-derived one
1661
+ * plus, when the live claim's file lives elsewhere, that file's directory.
1662
+ *
1663
+ * The cwd-derived directory is where a *fresh* session started from the tick
1664
+ * cwd writes; the claimed file's directory is where the *restored* pane keeps
1665
+ * writing (herdr pins it to the original transcript). Watching both covers a
1666
+ * resumed session, a rotated one, and a session that starts after the send.
1667
+ *
1668
+ * A claim outside the session tree is a stop, not a slower failure: arm polls
1669
+ * only transcripts under {@link sessionsRoot}, so a claimed file elsewhere can
1670
+ * never satisfy the challenge, and five minutes of polling cannot discover a
1671
+ * file that is not in the search set. The throw names both paths.
1672
+ */
1673
+ function armSessionScanDirs(cwd: string, claimed: string | undefined): string[] {
1674
+ const cwdDir = sessionDirForCwd(cwd);
1675
+ if (claimed === undefined) return [cwdDir];
1676
+ const root = sessionsRoot();
1677
+ const claimDir = dirname(claimed);
1678
+ if (claimDir !== root && !claimDir.startsWith(join(root, sep))) {
1679
+ throw new Error(
1680
+ `arm: the orchestrator's claimed session file ${claimed} is outside the session tree arm scans (${root}) — ` +
1681
+ `no transcript there can satisfy the challenge. Resume the pane under ${root}, or start it from ${cwd}; ` +
1682
+ `arm would otherwise poll ${cwdDir} until the timer runs out`,
1683
+ );
1684
+ }
1685
+ return claimDir === cwdDir ? [cwdDir] : [cwdDir, claimDir];
1686
+ }
1687
+
1608
1688
  function makeChallengeCode(): string {
1609
1689
  const bytes = new Uint8Array(4);
1610
1690
  crypto.getRandomValues(bytes);
@@ -1631,20 +1711,23 @@ interface SessionScan {
1631
1711
  }
1632
1712
 
1633
1713
  /**
1634
- * Polls the session directory — re-read on every pass, never snapshotted —
1714
+ * Polls the session directories — re-read on every pass, never snapshotted —
1635
1715
  * until the challenge shows up as a user turn or the deadline passes.
1636
1716
  *
1637
1717
  * Discovery lives here because the reply can land in a transcript that does not
1638
1718
  * exist yet when the challenge is sent: a rotated session, or the first one of
1639
- * a pane started right after arming (#142). A waiter handed one path polls a
1640
- * file the answer will never be written to, and arming becomes impossible.
1719
+ * a pane started right after arming (#142). And the scan set can be two
1720
+ * directories when the live claim's session file lives outside the tick-cwd
1721
+ * directory: the restored pane keeps writing the claimed file, so its
1722
+ * directory is watched alongside the cwd-derived one (#600).
1641
1723
  *
1642
1724
  * Files untouched since just before the send are named, not parsed: an append
1643
1725
  * bumps mtime, so a transcript older than the challenge cannot hold the reply,
1644
- * and skipping it keeps a large stale session out of every 5 s pass.
1726
+ * and skipping it keeps a large stale session out of every 5 s pass. A missing
1727
+ * directory is not an error — it can vanish under a rotation and reappear.
1645
1728
  */
1646
1729
  async function waitForChallengeInSessions(
1647
- dir: string,
1730
+ dirs: readonly string[],
1648
1731
  code: string,
1649
1732
  sentAt: number,
1650
1733
  timeoutMs: number,
@@ -1656,28 +1739,30 @@ async function waitForChallengeInSessions(
1656
1739
  for (;;) {
1657
1740
  const scanned: string[] = [];
1658
1741
  const ignored: string[] = [];
1659
- let names: string[] = [];
1660
- try {
1661
- names = readdirSync(dir);
1662
- } catch {
1663
- /* the directory can go away under a rotation; the next pass re-reads it */
1664
- }
1665
- for (const name of names.sort()) {
1666
- if (!name.endsWith(".jsonl")) continue;
1667
- const path = join(dir, name);
1668
- let mtimeMs: number;
1742
+ for (const dir of dirs) {
1743
+ let names: string[] = [];
1669
1744
  try {
1670
- mtimeMs = statSync(path).mtimeMs;
1745
+ names = readdirSync(dir);
1671
1746
  } catch {
1672
- continue; /* race */
1747
+ /* the directory can go away under a rotation; the next pass re-reads it */
1673
1748
  }
1674
- const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
1675
- if (mtimeMs < sentAt - 1_000) {
1676
- ignored.push(label);
1677
- continue;
1749
+ for (const name of names.sort()) {
1750
+ if (!name.endsWith(".jsonl")) continue;
1751
+ const path = join(dir, name);
1752
+ let mtimeMs: number;
1753
+ try {
1754
+ mtimeMs = statSync(path).mtimeMs;
1755
+ } catch {
1756
+ continue; /* race */
1757
+ }
1758
+ const label = `${name} (mtime ${new Date(mtimeMs).toISOString()})`;
1759
+ if (mtimeMs < sentAt - 1_000) {
1760
+ ignored.push(label);
1761
+ continue;
1762
+ }
1763
+ scanned.push(label);
1764
+ if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
1678
1765
  }
1679
- scanned.push(label);
1680
- if (await transcriptHasUserCode(path, code)) return { seen: true, scanned, ignored };
1681
1766
  }
1682
1767
  if (now() >= deadline) return { seen: false, scanned, ignored };
1683
1768
  await sleep(5_000);
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
  }
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.