omp-conductor 0.17.1 → 0.18.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 (65) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +71 -17
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +53 -1
  6. package/src/admission.ts +308 -76
  7. package/src/ask.ts +307 -10
  8. package/src/backups.ts +2 -2
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +43 -14
  11. package/src/briefs/to-spec.md +84 -0
  12. package/src/briefs/worker.md +37 -19
  13. package/src/cli.ts +2 -0
  14. package/src/command-help.ts +19 -1
  15. package/src/command-manifest.ts +27 -2
  16. package/src/commands/context.ts +1 -0
  17. package/src/commands/drain.ts +176 -0
  18. package/src/commands/extend.ts +6 -10
  19. package/src/commands/status.ts +5 -1
  20. package/src/commands/watch.ts +110 -3
  21. package/src/commands/worker.ts +9 -10
  22. package/src/config-schema.ts +57 -0
  23. package/src/config.ts +102 -2
  24. package/src/daemon.ts +1220 -1517
  25. package/src/dashboard/app.js +4 -1
  26. package/src/dashboard/server.ts +5 -2
  27. package/src/decisions.ts +279 -16
  28. package/src/depends-on.ts +261 -1
  29. package/src/diff-flags.ts +425 -1
  30. package/src/digest-schedule.ts +37 -0
  31. package/src/doctor.ts +52 -0
  32. package/src/escalate.ts +9 -3
  33. package/src/failure-class.ts +43 -4
  34. package/src/fleet.ts +166 -24
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +55 -8
  37. package/src/graph.ts +379 -69
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +567 -2
  40. package/src/lifecycle.ts +158 -6
  41. package/src/omp.ts +269 -20
  42. package/src/orchestrator-tick.ts +1489 -26
  43. package/src/orchestrator.ts +12 -0
  44. package/src/privileged.ts +1 -4
  45. package/src/release-policy.ts +503 -9
  46. package/src/routing.ts +11 -3
  47. package/src/session-host.ts +115 -5
  48. package/src/settlement.ts +1780 -0
  49. package/src/setup-host.ts +1205 -6
  50. package/src/setup-install.ts +119 -30
  51. package/src/setup-wizard.ts +88 -2
  52. package/src/setup.ts +119 -13
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +100 -11
  55. package/src/store.ts +519 -45
  56. package/src/to-spec.ts +387 -0
  57. package/src/tracker/github.ts +150 -14
  58. package/src/types.ts +470 -16
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +770 -40
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +239 -9
  65. package/src/worktree.ts +142 -18
package/src/diff-flags.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  * repository.
38
38
  */
39
39
 
40
- import type { PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
40
+ import type { FileLane, PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
41
41
 
42
42
  // ------------------------------------------------------------------ diff parse
43
43
 
@@ -496,6 +496,23 @@ export interface SettlementAudit {
496
496
  /** The dispatching issue's title and body — the attribution source. */
497
497
  issueText: string;
498
498
  diff: PrDiff;
499
+ /** The pull request body, or undefined when the tracker could not read one.
500
+ * The claims audited below live here, so absent means no proof commands are
501
+ * extracted — the flag family stays silent, never cries wolf on nothing. */
502
+ prBody?: string;
503
+ /** The run's session transcript (the raw JSONL), or undefined when the run
504
+ * had none or it could not be read. Absent means no claimed command can be
505
+ * checked — same silence, for the same reason. */
506
+ transcript?: string;
507
+ /**
508
+ * The effective file lane admission resolved for this run at dispatch
509
+ * (`effectiveLane` — a pre-dispatch comment declaration supersedes an older
510
+ * body one) and persisted on the row (#744/#758): the exact snapshot the
511
+ * gate enforced and the worker brief rendered. Absent means the issue was
512
+ * admitted with no declaration (fail open) — never "empty lane" — and no
513
+ * diff can then be outside it.
514
+ */
515
+ lane?: FileLane;
499
516
  }
500
517
 
501
518
  /**
@@ -513,6 +530,8 @@ export interface SettlementAudit {
513
530
  export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
514
531
  const flags: SettlementFlag[] = [];
515
532
  detectWeakening(audit, flags);
533
+ detectClaimedProof(audit, flags);
534
+ detectLaneEscape(audit, flags);
516
535
  return flags;
517
536
  }
518
537
 
@@ -529,6 +548,70 @@ export const UNREADABLE_TREE_FLAG: SettlementFlag = {
529
548
  "the settlement could not read the PR's diff, so no `changed:` file list could be derived",
530
549
  };
531
550
 
551
+ // ---------------------------------------------------------- declared file lane
552
+
553
+ /**
554
+ * Whether a diff path counts as inside the declared lane: an explicitly
555
+ * declared path, or the co-located test of one — `foo.ts` vouches for
556
+ * `foo.test.ts`, which is the "obviously intended" case. Other test shapes
557
+ * (`.spec.ts`, pytest's `test_` prefix) are not vouched for: the rule is the
558
+ * shape the fleet actually uses, and a lane that wants a differently-shaped
559
+ * sibling declares it. Deliberately one-directional: a lane that declares a
560
+ * *test* file does not vouch for its source, because declaring the test alone
561
+ * is a narrower promise and widening it silently is exactly what this flag
562
+ * exists to name. A containing directory never vouches for its contents
563
+ * either — the lane grammar names files, and a lane that means "everything
564
+ * under `src/`" fails open exactly as an undeclared one would if it cannot
565
+ * name them.
566
+ */
567
+ function withinLane(path: string, declared: readonly string[]): boolean {
568
+ if (declared.includes(path)) return true;
569
+ for (const d of declared) {
570
+ if (coLocatedTest(d) === path) return true;
571
+ }
572
+ return false;
573
+ }
574
+
575
+ /** `omp/src/verbs/server.ts` → `omp/src/verbs/server.test.ts`; undefined when
576
+ * the declared path has no file extension to splice before, since the
577
+ * co-located-test shape is defined by an extension. */
578
+ function coLocatedTest(declared: string): string | undefined {
579
+ const dot = declared.lastIndexOf(".");
580
+ const slash = declared.lastIndexOf("/");
581
+ if (dot <= slash + 1) return undefined;
582
+ return `${declared.slice(0, dot)}.test${declared.slice(dot)}`;
583
+ }
584
+
585
+ /**
586
+ * A diff that escapes its issue's declared file lane (#739).
587
+ *
588
+ * The lane is the effective declaration admission resolved at dispatch —
589
+ * `effectiveLane(body, comments)`, so a pre-dispatch comment beats an older
590
+ * body declaration — and the flag uses that resolved snapshot, never a re-parse
591
+ * of the body. The finding names every delivered file outside it, which is the
592
+ * part a reviewer is worst placed to notice: the diff's own file list is the
593
+ * only surface that shows the escape, and reading PR file lists by hand is
594
+ * exactly what nothing else in the loop does.
595
+ *
596
+ * Fail-open, like admission: an issue with no lane declaration has nothing to
597
+ * escape, so no flag — a flag on every undeclared run would be noise within a
598
+ * day, worse than no flag. Advisory like every other flag here: a widened lane
599
+ * is often legitimate, and the orchestrator is the judge this exists to brief.
600
+ */
601
+ function detectLaneEscape(audit: SettlementAudit, flags: SettlementFlag[]): void {
602
+ const lane = audit.lane;
603
+ if (lane === undefined || lane.files.length === 0) return;
604
+ const outside = audit.diff.files
605
+ .map((f) => f.path)
606
+ .filter((path) => !withinLane(path, lane.files));
607
+ if (outside.length === 0) return;
608
+ flags.push({
609
+ kind: "lane-escape",
610
+ file: "(lane)",
611
+ detail: `PR diff touches files outside the declared file lane: ${outside.join(", ")}`,
612
+ });
613
+ }
614
+
532
615
  function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
533
616
  // A test file that left one path and arrived at another is a move, not a
534
617
  // deletion. `status: renamed` covers the renames git detected; the basename
@@ -671,6 +754,347 @@ function scanHunks(
671
754
  }
672
755
  }
673
756
 
757
+ // ------------------------------------------------------- claimed proof commands
758
+
759
+ /**
760
+ * The proof claims a PR body makes, checked against the run's transcript.
761
+ *
762
+ * A worker's Verified section is what the orchestrator merges on, and until
763
+ * this check nothing compared its claims against what the session actually ran
764
+ * (#582). The audit is the same family as the test-weakening rules: advisory,
765
+ * never a state change, and resolving ambiguity towards silence. Every PR
766
+ * body carries backticked prose, so the false-positive pressure here is real —
767
+ * an extractor that reads any backtick span in the narrative as a claim would
768
+ * flag every run within a day.
769
+ *
770
+ * Three tolerances make the matcher honest rather than decorative:
771
+ *
772
+ * - Claims are extracted only from a verified section — a `## Verified`-style
773
+ * heading or an inline `Verified:` label — never from the narrative. This is
774
+ * the second named fake of the issue (one-heading parsing), asserted for
775
+ * both shapes below.
776
+ * - Matching is on meaningful shell tokens, not string equality: a claimed
777
+ * `bun test src/worker.test.ts` is satisfied by a transcript invocation of
778
+ * `cd …/omp && bun test src/worker.test.ts src/session-host.test.ts`,
779
+ * because a `cd` pair, shell operators and wrappers are dropped from both
780
+ * sides and the claim's tokens must then be a subset of one invocation's.
781
+ * Flag order is thereby free, which string equality could never be.
782
+ * - A claimed command whose invocation the transcript shows was refused by the
783
+ * shared-host guard is `claimed-proof-blocked`, not `claimed-proof-missing`:
784
+ * the worker *tried* and the guard stopped it (the honest #566/#570 shape,
785
+ * whose own words are "CI owns them"), which is not the same finding as a
786
+ * claim with no attempt at all.
787
+ */
788
+
789
+ /** The verified-section markers a claim must live under: a heading whose text
790
+ * contains a `veri*`/`proof` root (`## Verified`, `## Verification`,
791
+ * `## How verified`, `## Verification & proof`), or an inline
792
+ * `Verified:` / `**Verified:**` label row. `## Tests` is deliberately not
793
+ * one — a section describing the diff's test impact is narrative, not proof. */
794
+ const VERIFIED_MARKER =
795
+ /^(?:#{1,6}\s+.*\b(?:verif|proof)\w*\b.*$|^\s*(?:\*\*)?(?:Verif(?:ied|ication)|Proof)(?:\*\*)?\s*[:.])/im;
796
+
797
+ /**
798
+ * The slice of a PR body that can carry proof claims: from the first
799
+ * verified-section marker to the end of its block. Claims live under backticks,
800
+ * so the region is what keeps narrative code spans out of the audit — a
801
+ * backticked `retry.modelFallback: true` in a design explanation is commentary,
802
+ * never a command somebody claimed to run.
803
+ */
804
+ function claimRegion(body: string): string {
805
+ const marker = VERIFIED_MARKER.exec(body);
806
+ if (marker === null) return "";
807
+ const start = marker.index;
808
+ const rest = body.slice(start);
809
+ // The next-heading scan starts after the marker's own match, or `## Verified`
810
+ // would cut its own block to nothing — the marker line is a heading too.
811
+ const after = rest.slice(marker[0].length);
812
+ const next = /^#{1,6}\s+/m.exec(after);
813
+ return next === null ? rest : rest.slice(0, marker[0].length + next.index);
814
+ }
815
+
816
+ /** One backticked span that reads as a whole command: at least two shell words
817
+ * and a first word that is an executable (no path separator, or a `./` one).
818
+ * A span that names a single file or a config key is not a command claim. */
819
+ function claimedCommand(span: string): boolean {
820
+ const words = span.split(/\s+/).filter((word) => word.length > 0);
821
+ if (words.length < 2) return false;
822
+ const first = words[0] ?? "";
823
+ return first.startsWith("./") || !first.includes("/");
824
+ }
825
+
826
+ /**
827
+ * The commands a PR body claims as proof, each deduplicated by its exact span.
828
+ * Only spans inside the verified region count, and only command-shaped ones
829
+ * — the two filters are what keep `## Verified`-island prose out of the audit.
830
+ */
831
+ function claimedProofCommands(body: string): string[] {
832
+ const region = claimRegion(body);
833
+ const claims: string[] = [];
834
+ const seen = new Set<string>();
835
+ for (const match of region.matchAll(/`([^`\n]+)`/g)) {
836
+ const span = (match[1] ?? "").trim();
837
+ if (span === "" || seen.has(span) || !claimedCommand(span)) continue;
838
+ seen.add(span);
839
+ claims.push(span);
840
+ }
841
+ return claims;
842
+ }
843
+
844
+ /**
845
+ * Split a shell command into its meaningful words: whitespace-separated, quote
846
+ * characters removed so `bun test "src/my file.ts"` stays one word. Deliberately
847
+ * not a shell parser — the wide net is fine because the comparison is a subset
848
+ * in one direction only (see {@link satisfiedBy}).
849
+ */
850
+ function shellWords(command: string): string[] {
851
+ const words: string[] = [];
852
+ let current = "";
853
+ let quote: string | undefined;
854
+ for (const ch of command) {
855
+ if (quote !== undefined) {
856
+ if (ch === quote) quote = undefined;
857
+ else current += ch;
858
+ continue;
859
+ }
860
+ if (ch === '"' || ch === "'") {
861
+ quote = ch;
862
+ continue;
863
+ }
864
+ if (/\s/.test(ch)) {
865
+ if (current !== "") {
866
+ words.push(current);
867
+ current = "";
868
+ }
869
+ continue;
870
+ }
871
+ current += ch;
872
+ }
873
+ if (current !== "") words.push(current);
874
+ return words;
875
+ }
876
+
877
+ /** A token that is shell plumbing rather than a command word: `&&`, `|`,
878
+ * `;`, redirects, grouping — anything made only of the metacharacter
879
+ * alphabet. `2>&1` is kept (it carries a number), so stripping never eats a
880
+ * word that discriminates one command from another. */
881
+ const SHELL_PUNCT = /^[&|;<>()$!`]+$/;
882
+
883
+ /**
884
+ * The meaningful tokens of one command: shell punctuation dropped, and a
885
+ * `cd <dir>` pair consumed as binding context rather than a command word —
886
+ * the tolerated-variant half of the acceptance criteria. Wrapper verbs
887
+ * (`timeout 300`, `env -i`) survive tokenisation because a *transcript* side
888
+ * carrying them never hurts a subset check; only a claimed side spelling them
889
+ * would, which is a shape this fleet's bodies do not produce.
890
+ */
891
+ function commandTokens(command: string): string[] {
892
+ const words = shellWords(command);
893
+ const tokens: string[] = [];
894
+ for (let i = 0; i < words.length; i++) {
895
+ const word = words[i] ?? "";
896
+ if (word === "cd") {
897
+ i++; // its target is binding context too
898
+ continue;
899
+ }
900
+ if (SHELL_PUNCT.test(word)) continue;
901
+ tokens.push(word);
902
+ }
903
+ return tokens;
904
+ }
905
+
906
+ /**
907
+ * The shell-executing tools whose invocations can satisfy a claimed command.
908
+ * The shared-host guard's own invocation prefixes, minus `source` — a dot
909
+ * script has no command text of its own worth matching.
910
+ */
911
+ const SHELL_TOOLS = new Set(["bash", "sh", "zsh"]);
912
+
913
+ /**
914
+ * Whether a shell command ran in parse-only mode — a leading `bash -n`,
915
+ * `sh -n` after the `cd`-stripping, before the first non-flag token. This is
916
+ * the one how-vs-whether distinction the incident itself turned on: the
917
+ * shared-host guard explicitly permits `bash -n` on a guarded script while
918
+ * executing it is refused, and the *path token appears in both forms*, so a
919
+ * subset match alone would let a parse invocation satisfy a claim of having
920
+ * run the suite. The shared-host refusal wording makes the same explicit
921
+ * ("parsing never executes").
922
+ */
923
+ function parseOnly(tokens: string[]): boolean {
924
+ const first = tokens[0];
925
+ if (first === undefined || !SHELL_TOOLS.has(first)) return false;
926
+ return tokens.slice(1).includes("-n");
927
+ }
928
+
929
+ /** Whether one claimed command is satisfied by one transcript invocation: the
930
+ * claim's tokens are a subset (order-free, multiplicity-checked) of the
931
+ * invocation's. Subset rather than subsequence is what makes differing flag
932
+ * order harmless; multiplicity is what keeps `bun test a a` from being
933
+ * satisfied by `bun test a`.
934
+ *
935
+ * The one semantic guard on top of the subset: a claim that the worker *ran*
936
+ * a command is never satisfied by an invocation that only parsed it
937
+ * (`bash -n`), for the reason {@link parseOnly} states. A parse claim is
938
+ * still satisfied by a parse invocation. */
939
+ function satisfiedBy(claimTokens: string[], invocationTokens: string[]): boolean {
940
+ if (claimTokens.length === 0 || invocationTokens.length === 0) return false;
941
+ if (parseOnly(invocationTokens) && !parseOnly(claimTokens)) return false;
942
+ const remaining = [...invocationTokens];
943
+ for (const token of claimTokens) {
944
+ const at = remaining.indexOf(token);
945
+ if (at < 0) return false;
946
+ remaining.splice(at, 1);
947
+ }
948
+ return true;
949
+ }
950
+
951
+ interface TranscriptCall {
952
+ /** The tool-call id, present when the record carries one — links an
953
+ * invocation to its tool result for the refused-detection below. */
954
+ id?: string;
955
+ /** The command text of the invocation, or undefined when the record's shape
956
+ * carries none (a synthetic/aborted call, a record this parser does not
957
+ * recognise). */
958
+ command?: string;
959
+ }
960
+
961
+ /** The command text a content block or top-level record carries, whatever the
962
+ * harness's spelling for it — `input` (tool_use), `arguments` (toolCall) or a
963
+ * bare `command` — and undefined when the shape is unrecognised. */
964
+ function commandField(record: { readonly [key: string]: unknown }): string | undefined {
965
+ for (const key of ["input", "arguments"] as const) {
966
+ const field = record[key];
967
+ if (field !== null && typeof field === "object") {
968
+ // The harness's unvalidated transcript shapes; read as unknowns, like the
969
+ // daemon's other transcript readers.
970
+ const args = field as { readonly [key: string]: unknown };
971
+ const command = args["command"];
972
+ if (typeof command === "string") return command;
973
+ }
974
+ }
975
+ const direct = record["command"];
976
+ return typeof direct === "string" ? direct : undefined;
977
+ }
978
+
979
+ /**
980
+ * Every shell invocation a transcript records, plus every tool-result that a
981
+ * shared-host guard refusal produced. Defensive, in the module header's sense:
982
+ * the transcript is the harness's file, so both shapes this package already
983
+ * reads (a top-level `{toolName}` event and a `tool_use`/`toolCall` content
984
+ * block inside an assistant message) are recognised, and an unknown shape is
985
+ * skipped rather than TypeErroring the audit.
986
+ */
987
+ export function transcriptCalls(text: string): { calls: TranscriptCall[]; refusals: Set<string> } {
988
+ const calls: TranscriptCall[] = [];
989
+ const refusals = new Set<string>();
990
+ for (const line of text.split("\n")) {
991
+ if (line.length === 0) continue;
992
+ let row: unknown;
993
+ try {
994
+ row = JSON.parse(line) as unknown;
995
+ } catch {
996
+ continue;
997
+ }
998
+ if (row === null || typeof row !== "object") continue;
999
+ const rec = row as { readonly [key: string]: unknown };
1000
+
1001
+ const toolName = rec["toolName"];
1002
+ if (typeof toolName === "string") {
1003
+ if (SHELL_TOOLS.has(toolName)) {
1004
+ const id = rec["id"];
1005
+ const command = commandField(rec);
1006
+ calls.push({
1007
+ ...(typeof id === "string" ? { id } : {}),
1008
+ ...(command === undefined ? {} : { command }),
1009
+ });
1010
+ }
1011
+ continue;
1012
+ }
1013
+ if (rec["type"] !== "message") continue;
1014
+ const message = rec["message"];
1015
+ if (message === null || typeof message !== "object") continue;
1016
+ const m = message as { readonly [key: string]: unknown };
1017
+
1018
+ const content = m["content"];
1019
+ if (Array.isArray(content) && m["role"] === "assistant") {
1020
+ for (const part of content) {
1021
+ if (part === null || typeof part !== "object") continue;
1022
+ const p = part as { readonly [key: string]: unknown };
1023
+ const name = p["name"];
1024
+ if (typeof name !== "string" || !SHELL_TOOLS.has(name)) continue;
1025
+ const id = p["id"];
1026
+ const command = commandField(p);
1027
+ calls.push({
1028
+ ...(typeof id === "string" ? { id } : {}),
1029
+ ...(command === undefined ? {} : { command }),
1030
+ });
1031
+ }
1032
+ continue;
1033
+ }
1034
+
1035
+ // A shared-host refusal arrives as a tool result carrying the guard's
1036
+ // own marker. Keyed by the tool-call id so the refused invocation is the
1037
+ // one identified, never its neighbour.
1038
+ if (m["role"] === "toolResult") {
1039
+ const toolCallId = m["toolCallId"];
1040
+ if (typeof toolCallId !== "string") continue;
1041
+ const parts = Array.isArray(content) ? content : [];
1042
+ const refused = parts.some((part): boolean => {
1043
+ if (part === null || typeof part !== "object") return false;
1044
+ const block = part as { readonly [key: string]: unknown };
1045
+ const text = block["text"];
1046
+ return typeof text === "string" && text.includes("sharedHostPolicy");
1047
+ });
1048
+ if (refused) refusals.add(toolCallId);
1049
+ }
1050
+ }
1051
+ return { calls, refusals };
1052
+ }
1053
+
1054
+ /**
1055
+ * The claimed-proof half of the settlement audit: one finding per claimed
1056
+ * command whose transcript is silent, and a distinct finding (the honest
1057
+ * shape) for one the guard refused. The presence direction — a claim satisfied
1058
+ * by any invocation — produces nothing, which is the whole point of the flag
1059
+ * existing: silence is what a true Verified section earns.
1060
+ */
1061
+ function detectClaimedProof(audit: SettlementAudit, flags: SettlementFlag[]): void {
1062
+ if (audit.prBody === undefined || audit.transcript === undefined) return;
1063
+ const claims = claimedProofCommands(audit.prBody);
1064
+ if (claims.length === 0) return;
1065
+ const { calls, refusals } = transcriptCalls(audit.transcript);
1066
+ const invocations = calls
1067
+ .filter((call) => call.command !== undefined)
1068
+ .map((call) => {
1069
+ const tracked = call as { id?: string; command: string };
1070
+ return { ...tracked, tokens: commandTokens(tracked.command) };
1071
+ });
1072
+
1073
+ for (const claim of claims) {
1074
+ const claimTokens = commandTokens(claim);
1075
+ if (claimTokens.length === 0) continue;
1076
+ const matched = invocations.filter((call) => satisfiedBy(claimTokens, call.tokens));
1077
+ if (matched.length > 0) {
1078
+ // A claimed command the guard refused is an attempted-and-blocked report,
1079
+ // never a fabricated one: the worker's own words were "CI owns them",
1080
+ // and a reviewer should be pointing at the guard, not the worker.
1081
+ if (matched.some((call) => call.id !== undefined && refusals.has(call.id))) {
1082
+ flags.push({
1083
+ kind: "claimed-proof-blocked",
1084
+ file: "(transcript)",
1085
+ detail: `\`${evidence(claim)}\` was attempted, but the shared-host guard refused it (CI owns it)`,
1086
+ });
1087
+ }
1088
+ continue;
1089
+ }
1090
+ flags.push({
1091
+ kind: "claimed-proof-missing",
1092
+ file: "(transcript)",
1093
+ detail: `\`${evidence(claim)}\` is claimed as proof, but no command matching it appears in the run's transcript`,
1094
+ });
1095
+ }
1096
+ }
1097
+
674
1098
  // -------------------------------------------------------------------- printing
675
1099
 
676
1100
  /** Beyond this the report is a wall of text nobody reads; the count still tells
@@ -58,6 +58,43 @@ export function localDayKey(at: number, timezone?: string): string {
58
58
  return localDigestMinute(at, timezone).day;
59
59
  }
60
60
 
61
+ /** The durable day-marker key for the db-snapshot cadence (#289), mirroring
62
+ * the `digest:<day>` dedupe keys in the report outbox. */
63
+ export function dbSnapshotMarkerKey(dayKey: string): string {
64
+ return `db-snapshot:${dayKey}`;
65
+ }
66
+
67
+ /**
68
+ * Whether the conductor.db snapshot cadence is due now.
69
+ *
70
+ * The store is the ledger — the verb ledger, the decision rows and the run
71
+ * history have no other copy — so it is snapshotted once per day, aligned to
72
+ * the digest tick: a daily digest applies the exact {@link digestDue}
73
+ * predicate (same day boundary, same `at`, same timezone), so the snapshot
74
+ * lands on the same tick the digest goes out. A `none` or `per-tick` digest
75
+ * still leaves the ledger on a daily schedule — durability does not follow
76
+ * the reporting preference — bounded by the policy-zone day key (host zone
77
+ * when the policy carries none), with no `at`. The zone must be the
78
+ * policy's: the caller writes the `db-snapshot:<day>` marker with
79
+ * {@link localDayKey localDayKey(now, policy.digest.timezone)}, so a
80
+ * host-zone read here would disagree with it whenever the two zones fall on
81
+ * different days, and a `{cadence: "per-tick", timezone: "Europe/London"}`
82
+ * policy on a UTC host would re-snapshot on every tick during the offset
83
+ * window, pruning the retained set to nothing.
84
+ *
85
+ * Pure by construction, like {@link digestDue}: the last-run fact arrives as
86
+ * the day key (`db-snapshot:<day>` marker present for today ⟺ last run was
87
+ * today), never a clock the caller read an hour ago.
88
+ */
89
+ export function dbSnapshotDue(
90
+ policy: Pick<ReportingPolicy, "digest">,
91
+ lastSnapshotDayKey: string | undefined,
92
+ now: number,
93
+ ): boolean {
94
+ if (policy.digest.cadence === "daily") return digestDue(policy, lastSnapshotDayKey, now);
95
+ return localDayKey(now, policy.digest.timezone) !== lastSnapshotDayKey;
96
+ }
97
+
61
98
  /**
62
99
  * Whether the digest is due now.
63
100
  *
package/src/doctor.ts CHANGED
@@ -85,7 +85,10 @@ import {
85
85
  SYSTEMD_UNIT_DIR,
86
86
  tickCwdForProject,
87
87
  totalConfiguredWorkers,
88
+ workerAclHealth,
89
+ type WorkerAclHealth,
88
90
  } from "./setup-host.ts";
91
+ import { WORKER_ACCOUNT } from "./host.ts";
89
92
  import { checkTokenScopes, type ScopeCheck } from "./setup.ts";
90
93
  import { DB_SNAPSHOT_STEM, dbPath, LIVE_STATES } from "./store.ts";
91
94
  import { telegramReportSend, type ReportSend } from "./reports.ts";
@@ -142,6 +145,10 @@ const INCIDENTS = {
142
145
  spend:
143
146
  "spend telemetry was once absent, so the USD cap never fired — $0.00 spend is not proof of no spend",
144
147
  ghauth: "gh auth expired under a live daemon and every tracker call failed silently",
148
+ workerAcl:
149
+ "the #835 incident: a setup host granted the worker's path ACLs before restarting the fleet, an OMP startup chmod'd " +
150
+ "the agent config dir back to 0700 and rewrote the ACL mask, and the next two admitted workers died on EACCES " +
151
+ "before connecting — the named ACL entry was still there, only its effective permissions were gone",
145
152
  } as const;
146
153
 
147
154
  // ------------------------------------------------------------------ dependencies
@@ -268,6 +275,13 @@ export interface DoctorDeps {
268
275
  tickAgentName?: (project: ProjectConfig) => string | undefined;
269
276
  /** Clock, so a run is deterministic in tests. */
270
277
  now?: () => number;
278
+ /** The linked worker config paths' effective-ACL verdict for the dedicated
279
+ * worker account (#835): `checkable` paths judged, `missing` the ones that
280
+ * do not currently grant the worker's needed effective access. Read-only
281
+ * and injectable so no test ever touches the host's ACLs; the production
282
+ * wiring is the identity plan's own getfacl probe, so `doctor` and
283
+ * `setup host` cannot disagree about an ACL. */
284
+ workerAclHealth?: () => WorkerAclHealth;
271
285
  /** The one opt-in side effect: send one self-identified Telegram probe. */
272
286
  probeTelegram?: boolean;
273
287
  }
@@ -902,6 +916,37 @@ function ownershipProbe(probes: Probes): Finding {
902
916
  return failFinding("systemd-ownership", summary, fix);
903
917
  }
904
918
 
919
+ /**
920
+ * The dedicated worker account's path ACLs (#835): every linked worker config
921
+ * path must grant the worker its *effective* search/read permissions. The
922
+ * installed OMP harness chmods its agent config dir back to 0700 on every
923
+ * open, and a chmod rewrites the ACL mask — the named entry survives as
924
+ * `user:omp-worker:--x #effective:---` while the worker's access silently
925
+ * vanishes, which is exactly the incident this finding exists to catch
926
+ * (`${INCIDENTS.workerAcl}`). The verdict comes from the identity plan's own
927
+ * probe through the read-only {@link DoctorDeps.workerAclHealth} seam, so a
928
+ * pass here is a pass the plan agrees with and a fail names the paths whose
929
+ * mask is stripping the grant.
930
+ */
931
+ function workerAclProbeFinding(probes: Probes): Finding {
932
+ const health = probes.workerAclHealth();
933
+ if (health.checkable === 0) {
934
+ return passFinding("worker-acl", "no linked worker config paths on this host yet — nothing to check");
935
+ }
936
+ if (health.missing.length === 0) {
937
+ return passFinding(
938
+ "worker-acl",
939
+ `${health.checkable} linked worker config path(s) grant ${WORKER_ACCOUNT} effective search/read access`,
940
+ );
941
+ }
942
+ return failFinding(
943
+ "worker-acl",
944
+ `the ${WORKER_ACCOUNT} account's ACL is not effective on ${health.missing.join(", ")} — a named entry the ACL mask strips is ` +
945
+ "unreachable by the worker, and the next admitted worker dies on EACCES before connecting",
946
+ "re-run `omp-conductor setup host`: the transaction re-applies the ACL grants (mask included) after the final fleet restart",
947
+ );
948
+ }
949
+
905
950
  /** IANA timezone in reporting config — an invalid zone silently mis-schedules
906
951
  * the availability window and the daily digest (#273). */
907
952
  function timezoneProbe(project: ProjectConfig | undefined): Finding {
@@ -1645,6 +1690,10 @@ export async function runDoctor(projectName: string | undefined, opts: DoctorDep
1645
1690
  findings.push(unitProbe(probes, projects[0], cfg));
1646
1691
  findings.push(recoveryProbe(probes));
1647
1692
  findings.push(ownershipProbe(probes));
1693
+ // The worker's path ACLs are a host-wide fact like the unit ownership: one
1694
+ // shared daemon account, one shared worker identity, one shared set of
1695
+ // linked config paths. A finding once, never once per project.
1696
+ findings.push(workerAclProbeFinding(probes));
1648
1697
  // #541 seam checks, host-global: the live herdr config and the plugin's
1649
1698
  // config.env are single files on the host, not per-project facts.
1650
1699
  findings.push(herdrResumeProbe(probes));
@@ -1688,6 +1737,9 @@ export function defaultProbes(): Probes {
1688
1737
  readUnit: defaultReadUnit,
1689
1738
  stat: defaultStat,
1690
1739
  uidOf: defaultUidOf,
1740
+ // The same effective-ACL read the identity plan plans with — doctor and
1741
+ // setup cannot disagree about a worker grant (#835).
1742
+ workerAclHealth: () => workerAclHealth(join(homedir(), ".omp", "agent")),
1691
1743
  dbIntegrity: defaultDbIntegrity,
1692
1744
  snapshotDirState: defaultSnapshotDirState,
1693
1745
  sessionRootState: defaultSessionRootState,
package/src/escalate.ts CHANGED
@@ -403,11 +403,17 @@ function errText(e: unknown): string {
403
403
  *
404
404
  * Exported for the report outbox (#123), which pages over the same bot and must
405
405
  * resolve the token the same way. Two readers of one `.env` is fine; two
406
- * *implementations* of the parse below is how they drift.
406
+ * *implementations* of the parse below is how they drift. The state dir is the
407
+ * shared resolution both the token read and the interactive ask surface (#722)
408
+ * need, so it is one function rather than a second copy of the override.
407
409
  */
408
- export function readTelegramToken(): string | undefined {
410
+ export function telegramStateDir(): string {
409
411
  const override = process.env.OMP_TELEGRAM_STATE_DIR?.trim();
410
- const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
412
+ return override ? override : join(homedir(), ".omp", "agent", "telegram");
413
+ }
414
+
415
+ export function readTelegramToken(): string | undefined {
416
+ const dir = telegramStateDir();
411
417
  let raw: string;
412
418
  try {
413
419
  raw = readFileSync(join(dir, ".env"), "utf8");