omp-conductor 0.15.13 → 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.
Files changed (50) hide show
  1. package/REFERENCE.md +72 -2
  2. package/package.json +2 -1
  3. package/schema/config.schema.json +7 -0
  4. package/src/admission.ts +849 -0
  5. package/src/ask.ts +47 -0
  6. package/src/backups.ts +19 -7
  7. package/src/board.ts +1 -2
  8. package/src/briefs/orchestrator.md +62 -4
  9. package/src/cli.ts +26 -0
  10. package/src/commands/context.ts +3 -0
  11. package/src/commands/decision.ts +10 -1
  12. package/src/commands/doctor.ts +2 -0
  13. package/src/commands/message.ts +8 -1
  14. package/src/commands/restart.ts +15 -3
  15. package/src/commands/restore-db.ts +146 -0
  16. package/src/commands/stop.ts +24 -15
  17. package/src/commands/tail.ts +204 -44
  18. package/src/commands/unfreeze.ts +56 -0
  19. package/src/commands/watch.ts +77 -0
  20. package/src/config-schema.ts +13 -0
  21. package/src/config.ts +54 -0
  22. package/src/daemon.ts +255 -530
  23. package/src/dashboard/server.ts +2 -1
  24. package/src/decisions.ts +32 -7
  25. package/src/depends-on.ts +122 -0
  26. package/src/doctor.ts +297 -5
  27. package/src/escalate.ts +191 -19
  28. package/src/failure-class.ts +47 -0
  29. package/src/fleet.ts +168 -452
  30. package/src/gitops.ts +86 -1
  31. package/src/log.ts +40 -0
  32. package/src/model-fallback.ts +3 -2
  33. package/src/omp-settings.ts +114 -0
  34. package/src/omp.ts +39 -0
  35. package/src/orchestrator-tick.ts +7 -1
  36. package/src/reports.ts +124 -12
  37. package/src/session-host.ts +6 -0
  38. package/src/setup-wizard.ts +36 -0
  39. package/src/setup.ts +58 -1
  40. package/src/status-render.ts +445 -0
  41. package/src/stop-provenance.ts +53 -0
  42. package/src/store.ts +352 -11
  43. package/src/transcript.ts +1 -1
  44. package/src/types.ts +187 -4
  45. package/src/unblock.ts +1 -1
  46. package/src/upgrade-verify.ts +1 -1
  47. package/src/upgrade.ts +1 -2
  48. package/src/verbs/server.ts +25 -0
  49. package/src/worker.ts +358 -10
  50. package/src/worktree.ts +13 -1
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";
@@ -99,6 +101,15 @@ export interface WorkerOpts {
99
101
  * the harness to pick, which is what an unconfigured project wants.
100
102
  */
101
103
  model?: string;
104
+ /**
105
+ * Absolute path to the fleet-owned omp settings overlay (#537): the YAML the
106
+ * daemon materialised from the project's `ompSettings` map (plus the retry
107
+ * keys derived from `modelFallbacks`, which is where #539's staging lives)
108
+ * under the run's session directory. Forwarded to `createSession`, which
109
+ * loads it through `Settings.init({ configFiles: [<path>] })`. Absent, no
110
+ * settings are staged and dispatch is byte-for-byte what it is today.
111
+ */
112
+ ompSettingsFile?: string;
102
113
  /**
103
114
  * Effective per-shape release grants for this session. A worker is refused
104
115
  * every shape whatever they say — see {@link SessionRole} — so this is passed
@@ -169,6 +180,37 @@ export interface WorkerResult {
169
180
  turns: number;
170
181
  spendUsd: number;
171
182
  report: string;
183
+ /** In-session HTTP 429 responses the session recorded (stopReason "error",
184
+ * errorStatus 429), counted as the messages streamed in. A healthy run
185
+ * reports 0; a run the harness retried through a barrel of rate limits
186
+ * carries the number, which is what distinguishes provider-capacity (the
187
+ * provider was throttling all along) from an ordinary failure (#573). */
188
+ provider429Count: number;
189
+ /**
190
+ * The model that actually wrote this run's messages, read from
191
+ * `AssistantMessage.model` on the newest assistant `message_end`. Present
192
+ * even for a run that never failed over — it is the durable answer to "which
193
+ * model wrote this" (#535 slice 1, from the message field rather than the
194
+ * payload-free `model_changed` event). Absent only when no assistant message
195
+ * carried a model.
196
+ */
197
+ model?: string;
198
+ /** The provider that wrote them, read from the same `AssistantMessage.provider`. */
199
+ provider?: string;
200
+ /** Every within-run model fallback the harness applied
201
+ * (`retry_fallback_applied`), newest first. The `to` target is what a
202
+ * settlement report names when a run swapped providers mid-run. */
203
+ retryFallbacks: { from: string; to: string }[];
204
+ /** `retry_fallback_succeeded` events: within-run fallbacks the harness
205
+ * confirmed recovered on. */
206
+ retryFallbackSucceeded: number;
207
+ /** Assistant messages whose `retryRecovery.recovery === "model"` — the durable
208
+ * transcript record of a within-run model swap. */
209
+ modelRecoveries: number;
210
+ /** `auto_retry_start` events: in-session provider retries the harness ran. */
211
+ autoRetryCount: number;
212
+ /** `auto_compaction_start` events: in-session context compactions. */
213
+ autoCompactionCount: number;
172
214
  killedBy?: KilledBy;
173
215
  /** Present only when an operator terminally stopped this run. */
174
216
  stoppedReason?: string;
@@ -281,12 +323,27 @@ export async function runWorker(
281
323
  const schedule = deps.schedule ?? scheduleWallClock;
282
324
 
283
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
+ }
284
337
  try {
285
338
  session = await deps.createSession({
286
339
  cwd: o.cwd,
287
340
  ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
288
341
  ...(o.model === undefined ? {} : { model: o.model }),
289
342
  ...(o.resume === undefined ? {} : { resume: o.resume }),
343
+ // The fleet-owned omp settings overlay (#537): carry the staged overlay
344
+ // path to the session so it loads the project's omp settings. Absent,
345
+ // nothing is staged and the harness discovers settings as it does today.
346
+ ...(o.ompSettingsFile === undefined ? {} : { ompSettingsFile: o.ompSettingsFile }),
290
347
  // Prevention half of #24: as a worker, structured file tools cannot leave
291
348
  // this worktree, and no release grant can ever reach this session (#122).
292
349
  role: "worker",
@@ -308,6 +365,12 @@ export async function runWorker(
308
365
  state: "stopped",
309
366
  turns: 0,
310
367
  spendUsd: 0,
368
+ provider429Count: 0,
369
+ retryFallbacks: [],
370
+ retryFallbackSucceeded: 0,
371
+ modelRecoveries: 0,
372
+ autoRetryCount: 0,
373
+ autoCompactionCount: 0,
311
374
  report: "",
312
375
  stoppedReason: "daemon shutdown began before the worker session started",
313
376
  };
@@ -324,18 +387,80 @@ export async function runWorker(
324
387
  // transcript it actually opened, and any model downgrade it announced. Read at
325
388
  // return time so a session that materialises either late is still reported
326
389
  // honestly.
327
- const withSessionFacts = (result: WorkerResult): WorkerResult => {
390
+ // The within-run reliability surface this worker now records (#539): the
391
+ // resolved model/provider plus the fallback/retry/compaction events. Layered
392
+ // last, at return time, so every exit path reports the same shape without
393
+ // each spelling the metrics out by hand.
394
+ type ReliabilityKeys =
395
+ | "retryFallbacks"
396
+ | "retryFallbackSucceeded"
397
+ | "modelRecoveries"
398
+ | "autoRetryCount"
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;
405
+ const withSessionFacts = (
406
+ result: Omit<WorkerResult, ReliabilityKeys>,
407
+ ): Omit<WorkerResult, ReliabilityKeys> => {
328
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
+ }
329
422
  return {
330
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,
331
427
  ...(sessionFile === undefined ? {} : { sessionFile }),
332
428
  ...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
333
429
  };
334
430
  };
335
431
 
432
+ // The count fields always travel (0 for a clean run, so an absent field can
433
+ // never be misread); the resolved model/provider only when some assistant
434
+ // message actually carried them.
435
+ const withMetrics = (result: Omit<WorkerResult, ReliabilityKeys>): WorkerResult => ({
436
+ ...result,
437
+ ...(resolvedModel === undefined ? {} : { model: resolvedModel }),
438
+ ...(resolvedProvider === undefined ? {} : { provider: resolvedProvider }),
439
+ retryFallbacks,
440
+ retryFallbackSucceeded,
441
+ modelRecoveries,
442
+ autoRetryCount,
443
+ autoCompactionCount,
444
+ });
445
+
336
446
  let turns = 0;
337
447
  let spendUsd = 0;
448
+ let provider429Count = 0;
338
449
  let report = "";
450
+ // Which model/provider actually wrote the newest assistant message. Last
451
+ // assistant message wins: that is the durable answer even for a run that
452
+ // never failed over (#535 slice 1, read off the message field which is where
453
+ // the resolved model actually lives).
454
+ let resolvedModel: string | undefined;
455
+ let resolvedProvider: string | undefined;
456
+ // Harness reliability surface (#539): within-run provider failover and the
457
+ // retry/compaction activity that surrounds it, all of it events this worker
458
+ // does not yet subscribe to but that the run row and settlement report want.
459
+ let retryFallbacks: { from: string; to: string }[] = [];
460
+ let retryFallbackSucceeded = 0;
461
+ let modelRecoveries = 0;
462
+ let autoRetryCount = 0;
463
+ let autoCompactionCount = 0;
339
464
  // The newest COMPLETE `pushed-green` verdict this session emitted. Tracked
340
465
  // apart from `report` because `report` is deliberately the newest non-empty
341
466
  // text — a run cut off mid-sentence must still report what it said last —
@@ -497,6 +622,48 @@ export async function runWorker(
497
622
  spendUsd += cost;
498
623
  o.onSpend?.(spendUsd);
499
624
  }
625
+
626
+ // Count the provider rate limits the harness retried in-session (#573). A
627
+ // run that drowns in 429s records `stopReason:"error", errorStatus:429`
628
+ // dozens of times and never surfaces one as `lastError` — omp swallowed
629
+ // every retry — so `unknown` and the provider failover chain (#286) never
630
+ // see them. Counted here, alongside spend, so the classifier can tell
631
+ // "the provider was throttling the whole run" from an ordinary failure.
632
+ if (provider429FromMessage(message)) provider429Count += 1;
633
+
634
+ // The resolved model and provider live on the message, not on any event
635
+ // (#539). Last assistant message wins, which is the run's durable answer
636
+ // even when it never failed over.
637
+ const model = field(message, "model");
638
+ if (typeof model === "string" && model !== "") resolvedModel = model;
639
+ const provider = field(message, "provider");
640
+ if (typeof provider === "string" && provider !== "") resolvedProvider = provider;
641
+ // A within-run model swap the harness persisted into the transcript rather
642
+ // than only emitting as a transient event. Recovery kind "model" is the
643
+ // durable spelling of the same thing `retry_fallback_applied` says.
644
+ if (field(field(message, "retryRecovery"), "recovery") === "model") modelRecoveries += 1;
645
+ });
646
+
647
+ // The harness reliability events the run row and settlement report now want
648
+ // (#539): within-run model fallback, and the retry/compaction activity that
649
+ // surrounds a throttled provider. Each is a session event (AgentSessionEvent)
650
+ // carrying the fields this worker reads — a fallback's from→to pair, the
651
+ // confirmed recoveries, and the auto-retry/compaction attempt counts.
652
+ session.on("retry_fallback_applied", (event) => {
653
+ const from = field(event, "from");
654
+ const to = field(event, "to");
655
+ if (typeof from === "string" && typeof to === "string") {
656
+ retryFallbacks = [{ from, to }, ...retryFallbacks];
657
+ }
658
+ });
659
+ session.on("retry_fallback_succeeded", () => {
660
+ retryFallbackSucceeded += 1;
661
+ });
662
+ session.on("auto_retry_start", () => {
663
+ autoRetryCount += 1;
664
+ });
665
+ session.on("auto_compaction_start", () => {
666
+ autoCompactionCount += 1;
500
667
  });
501
668
 
502
669
  session.on("agent_end", (event) => {
@@ -561,12 +728,13 @@ export async function runWorker(
561
728
  // Our own abort surfaces here on some paths; that is a kill, not a crash.
562
729
  if (killedBy === undefined && stoppedReason === undefined) {
563
730
  const detail = cause instanceof Error ? cause.message : String(cause);
564
- return withSessionFacts({
731
+ return withMetrics(withSessionFacts({
565
732
  state: "failed",
566
733
  turns,
567
734
  spendUsd,
735
+ provider429Count,
568
736
  report: report === "" ? detail : report,
569
- });
737
+ }));
570
738
  }
571
739
  } finally {
572
740
  done = true;
@@ -581,14 +749,15 @@ export async function runWorker(
581
749
  }
582
750
 
583
751
  if (stoppedReason !== undefined) {
584
- return withSessionFacts({
752
+ return withMetrics(withSessionFacts({
585
753
  state: "stopped",
586
754
  turns,
587
755
  spendUsd,
756
+ provider429Count,
588
757
  report,
589
758
  stoppedReason,
590
759
  ...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
591
- });
760
+ }));
592
761
  }
593
762
 
594
763
  if (killedBy !== undefined) {
@@ -596,28 +765,30 @@ export async function runWorker(
596
765
  // survive the kill. Without them `shouldContinueAfterTurnsCap` sees no
597
766
  // artifacts and charges an implementation attempt for a cap kill that had
598
767
  // real work to continue from.
599
- return withSessionFacts({
768
+ return withMetrics(withSessionFacts({
600
769
  state: "killed",
601
770
  turns,
602
771
  spendUsd,
772
+ provider429Count,
603
773
  report,
604
774
  killedBy,
605
775
  ...(claim === undefined ? {} : { prUrl: claim.prUrl, headSha: claim.headSha }),
606
- });
776
+ }));
607
777
  }
608
778
 
609
779
  // An explicit later verdict always wins: a worker that pushed green and then
610
780
  // stopped to ask a question means the question. The earlier claim is only
611
781
  // restored when the last thing said was not a verdict at all.
612
782
  if (claim !== undefined && !hasVerdictLine(report)) {
613
- return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
783
+ return withMetrics(withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, provider429Count, report }));
614
784
  }
615
- return withSessionFacts({
785
+ return withMetrics(withSessionFacts({
616
786
  ...deriveResult(report, o.repoSlug),
617
787
  turns,
618
788
  spendUsd,
789
+ provider429Count,
619
790
  report,
620
- });
791
+ }));
621
792
  }
622
793
 
623
794
  /**
@@ -645,6 +816,21 @@ export function costUsdFromMessage(message: unknown): number | undefined {
645
816
  return any ? sum : undefined;
646
817
  }
647
818
 
819
+ /**
820
+ * Is this assistant message a provider HTTP 429 the harness recorded mid-run?
821
+ *
822
+ * Live transcripts mark a rate-limited turn with `stopReason:"error"`,
823
+ * `errorStatus:429` and a message like `429 Provider returned error`, and the
824
+ * harness's in-session auto-retry usually swallows it — the run carries on and
825
+ * the 429 never surfaces as `lastError` (#573). Exported so a unit test can pin
826
+ * the signature without standing up a session; the count itself distinguishes a
827
+ * run that drowned in them from a run that hit one and recovered.
828
+ */
829
+ export function provider429FromMessage(message: unknown): boolean {
830
+ if (field(message, "stopReason") !== "error") return false;
831
+ return field(message, "errorStatus") === 429;
832
+ }
833
+
648
834
  /**
649
835
  * Read one property off an unvalidated harness event. The event union lives in
650
836
  * the peer dependency, so the worker narrows the handful of fields it reads
@@ -655,6 +841,168 @@ function field(source: unknown, key: string): unknown {
655
841
  return Reflect.get(source, key);
656
842
  }
657
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
+
658
1006
  /** Flatten an assistant message's content blocks to their plain text. */
659
1007
  /**
660
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.