omp-conductor 0.17.0 → 0.18.0

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 (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
package/src/diff-flags.ts CHANGED
@@ -496,6 +496,14 @@ 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;
499
507
  }
500
508
 
501
509
  /**
@@ -513,6 +521,7 @@ export interface SettlementAudit {
513
521
  export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
514
522
  const flags: SettlementFlag[] = [];
515
523
  detectWeakening(audit, flags);
524
+ detectClaimedProof(audit, flags);
516
525
  return flags;
517
526
  }
518
527
 
@@ -671,6 +680,347 @@ function scanHunks(
671
680
  }
672
681
  }
673
682
 
683
+ // ------------------------------------------------------- claimed proof commands
684
+
685
+ /**
686
+ * The proof claims a PR body makes, checked against the run's transcript.
687
+ *
688
+ * A worker's Verified section is what the orchestrator merges on, and until
689
+ * this check nothing compared its claims against what the session actually ran
690
+ * (#582). The audit is the same family as the test-weakening rules: advisory,
691
+ * never a state change, and resolving ambiguity towards silence. Every PR
692
+ * body carries backticked prose, so the false-positive pressure here is real —
693
+ * an extractor that reads any backtick span in the narrative as a claim would
694
+ * flag every run within a day.
695
+ *
696
+ * Three tolerances make the matcher honest rather than decorative:
697
+ *
698
+ * - Claims are extracted only from a verified section — a `## Verified`-style
699
+ * heading or an inline `Verified:` label — never from the narrative. This is
700
+ * the second named fake of the issue (one-heading parsing), asserted for
701
+ * both shapes below.
702
+ * - Matching is on meaningful shell tokens, not string equality: a claimed
703
+ * `bun test src/worker.test.ts` is satisfied by a transcript invocation of
704
+ * `cd …/omp && bun test src/worker.test.ts src/session-host.test.ts`,
705
+ * because a `cd` pair, shell operators and wrappers are dropped from both
706
+ * sides and the claim's tokens must then be a subset of one invocation's.
707
+ * Flag order is thereby free, which string equality could never be.
708
+ * - A claimed command whose invocation the transcript shows was refused by the
709
+ * shared-host guard is `claimed-proof-blocked`, not `claimed-proof-missing`:
710
+ * the worker *tried* and the guard stopped it (the honest #566/#570 shape,
711
+ * whose own words are "CI owns them"), which is not the same finding as a
712
+ * claim with no attempt at all.
713
+ */
714
+
715
+ /** The verified-section markers a claim must live under: a heading whose text
716
+ * contains a `veri*`/`proof` root (`## Verified`, `## Verification`,
717
+ * `## How verified`, `## Verification & proof`), or an inline
718
+ * `Verified:` / `**Verified:**` label row. `## Tests` is deliberately not
719
+ * one — a section describing the diff's test impact is narrative, not proof. */
720
+ const VERIFIED_MARKER =
721
+ /^(?:#{1,6}\s+.*\b(?:verif|proof)\w*\b.*$|^\s*(?:\*\*)?(?:Verif(?:ied|ication)|Proof)(?:\*\*)?\s*[:.])/im;
722
+
723
+ /**
724
+ * The slice of a PR body that can carry proof claims: from the first
725
+ * verified-section marker to the end of its block. Claims live under backticks,
726
+ * so the region is what keeps narrative code spans out of the audit — a
727
+ * backticked `retry.modelFallback: true` in a design explanation is commentary,
728
+ * never a command somebody claimed to run.
729
+ */
730
+ function claimRegion(body: string): string {
731
+ const marker = VERIFIED_MARKER.exec(body);
732
+ if (marker === null) return "";
733
+ const start = marker.index;
734
+ const rest = body.slice(start);
735
+ // The next-heading scan starts after the marker's own match, or `## Verified`
736
+ // would cut its own block to nothing — the marker line is a heading too.
737
+ const after = rest.slice(marker[0].length);
738
+ const next = /^#{1,6}\s+/m.exec(after);
739
+ return next === null ? rest : rest.slice(0, marker[0].length + next.index);
740
+ }
741
+
742
+ /** One backticked span that reads as a whole command: at least two shell words
743
+ * and a first word that is an executable (no path separator, or a `./` one).
744
+ * A span that names a single file or a config key is not a command claim. */
745
+ function claimedCommand(span: string): boolean {
746
+ const words = span.split(/\s+/).filter((word) => word.length > 0);
747
+ if (words.length < 2) return false;
748
+ const first = words[0] ?? "";
749
+ return first.startsWith("./") || !first.includes("/");
750
+ }
751
+
752
+ /**
753
+ * The commands a PR body claims as proof, each deduplicated by its exact span.
754
+ * Only spans inside the verified region count, and only command-shaped ones
755
+ * — the two filters are what keep `## Verified`-island prose out of the audit.
756
+ */
757
+ function claimedProofCommands(body: string): string[] {
758
+ const region = claimRegion(body);
759
+ const claims: string[] = [];
760
+ const seen = new Set<string>();
761
+ for (const match of region.matchAll(/`([^`\n]+)`/g)) {
762
+ const span = (match[1] ?? "").trim();
763
+ if (span === "" || seen.has(span) || !claimedCommand(span)) continue;
764
+ seen.add(span);
765
+ claims.push(span);
766
+ }
767
+ return claims;
768
+ }
769
+
770
+ /**
771
+ * Split a shell command into its meaningful words: whitespace-separated, quote
772
+ * characters removed so `bun test "src/my file.ts"` stays one word. Deliberately
773
+ * not a shell parser — the wide net is fine because the comparison is a subset
774
+ * in one direction only (see {@link satisfiedBy}).
775
+ */
776
+ function shellWords(command: string): string[] {
777
+ const words: string[] = [];
778
+ let current = "";
779
+ let quote: string | undefined;
780
+ for (const ch of command) {
781
+ if (quote !== undefined) {
782
+ if (ch === quote) quote = undefined;
783
+ else current += ch;
784
+ continue;
785
+ }
786
+ if (ch === '"' || ch === "'") {
787
+ quote = ch;
788
+ continue;
789
+ }
790
+ if (/\s/.test(ch)) {
791
+ if (current !== "") {
792
+ words.push(current);
793
+ current = "";
794
+ }
795
+ continue;
796
+ }
797
+ current += ch;
798
+ }
799
+ if (current !== "") words.push(current);
800
+ return words;
801
+ }
802
+
803
+ /** A token that is shell plumbing rather than a command word: `&&`, `|`,
804
+ * `;`, redirects, grouping — anything made only of the metacharacter
805
+ * alphabet. `2>&1` is kept (it carries a number), so stripping never eats a
806
+ * word that discriminates one command from another. */
807
+ const SHELL_PUNCT = /^[&|;<>()$!`]+$/;
808
+
809
+ /**
810
+ * The meaningful tokens of one command: shell punctuation dropped, and a
811
+ * `cd <dir>` pair consumed as binding context rather than a command word —
812
+ * the tolerated-variant half of the acceptance criteria. Wrapper verbs
813
+ * (`timeout 300`, `env -i`) survive tokenisation because a *transcript* side
814
+ * carrying them never hurts a subset check; only a claimed side spelling them
815
+ * would, which is a shape this fleet's bodies do not produce.
816
+ */
817
+ function commandTokens(command: string): string[] {
818
+ const words = shellWords(command);
819
+ const tokens: string[] = [];
820
+ for (let i = 0; i < words.length; i++) {
821
+ const word = words[i] ?? "";
822
+ if (word === "cd") {
823
+ i++; // its target is binding context too
824
+ continue;
825
+ }
826
+ if (SHELL_PUNCT.test(word)) continue;
827
+ tokens.push(word);
828
+ }
829
+ return tokens;
830
+ }
831
+
832
+ /**
833
+ * The shell-executing tools whose invocations can satisfy a claimed command.
834
+ * The shared-host guard's own invocation prefixes, minus `source` — a dot
835
+ * script has no command text of its own worth matching.
836
+ */
837
+ const SHELL_TOOLS = new Set(["bash", "sh", "zsh"]);
838
+
839
+ /**
840
+ * Whether a shell command ran in parse-only mode — a leading `bash -n`,
841
+ * `sh -n` after the `cd`-stripping, before the first non-flag token. This is
842
+ * the one how-vs-whether distinction the incident itself turned on: the
843
+ * shared-host guard explicitly permits `bash -n` on a guarded script while
844
+ * executing it is refused, and the *path token appears in both forms*, so a
845
+ * subset match alone would let a parse invocation satisfy a claim of having
846
+ * run the suite. The shared-host refusal wording makes the same explicit
847
+ * ("parsing never executes").
848
+ */
849
+ function parseOnly(tokens: string[]): boolean {
850
+ const first = tokens[0];
851
+ if (first === undefined || !SHELL_TOOLS.has(first)) return false;
852
+ return tokens.slice(1).includes("-n");
853
+ }
854
+
855
+ /** Whether one claimed command is satisfied by one transcript invocation: the
856
+ * claim's tokens are a subset (order-free, multiplicity-checked) of the
857
+ * invocation's. Subset rather than subsequence is what makes differing flag
858
+ * order harmless; multiplicity is what keeps `bun test a a` from being
859
+ * satisfied by `bun test a`.
860
+ *
861
+ * The one semantic guard on top of the subset: a claim that the worker *ran*
862
+ * a command is never satisfied by an invocation that only parsed it
863
+ * (`bash -n`), for the reason {@link parseOnly} states. A parse claim is
864
+ * still satisfied by a parse invocation. */
865
+ function satisfiedBy(claimTokens: string[], invocationTokens: string[]): boolean {
866
+ if (claimTokens.length === 0 || invocationTokens.length === 0) return false;
867
+ if (parseOnly(invocationTokens) && !parseOnly(claimTokens)) return false;
868
+ const remaining = [...invocationTokens];
869
+ for (const token of claimTokens) {
870
+ const at = remaining.indexOf(token);
871
+ if (at < 0) return false;
872
+ remaining.splice(at, 1);
873
+ }
874
+ return true;
875
+ }
876
+
877
+ interface TranscriptCall {
878
+ /** The tool-call id, present when the record carries one — links an
879
+ * invocation to its tool result for the refused-detection below. */
880
+ id?: string;
881
+ /** The command text of the invocation, or undefined when the record's shape
882
+ * carries none (a synthetic/aborted call, a record this parser does not
883
+ * recognise). */
884
+ command?: string;
885
+ }
886
+
887
+ /** The command text a content block or top-level record carries, whatever the
888
+ * harness's spelling for it — `input` (tool_use), `arguments` (toolCall) or a
889
+ * bare `command` — and undefined when the shape is unrecognised. */
890
+ function commandField(record: { readonly [key: string]: unknown }): string | undefined {
891
+ for (const key of ["input", "arguments"] as const) {
892
+ const field = record[key];
893
+ if (field !== null && typeof field === "object") {
894
+ // The harness's unvalidated transcript shapes; read as unknowns, like the
895
+ // daemon's other transcript readers.
896
+ const args = field as { readonly [key: string]: unknown };
897
+ const command = args["command"];
898
+ if (typeof command === "string") return command;
899
+ }
900
+ }
901
+ const direct = record["command"];
902
+ return typeof direct === "string" ? direct : undefined;
903
+ }
904
+
905
+ /**
906
+ * Every shell invocation a transcript records, plus every tool-result that a
907
+ * shared-host guard refusal produced. Defensive, in the module header's sense:
908
+ * the transcript is the harness's file, so both shapes this package already
909
+ * reads (a top-level `{toolName}` event and a `tool_use`/`toolCall` content
910
+ * block inside an assistant message) are recognised, and an unknown shape is
911
+ * skipped rather than TypeErroring the audit.
912
+ */
913
+ export function transcriptCalls(text: string): { calls: TranscriptCall[]; refusals: Set<string> } {
914
+ const calls: TranscriptCall[] = [];
915
+ const refusals = new Set<string>();
916
+ for (const line of text.split("\n")) {
917
+ if (line.length === 0) continue;
918
+ let row: unknown;
919
+ try {
920
+ row = JSON.parse(line) as unknown;
921
+ } catch {
922
+ continue;
923
+ }
924
+ if (row === null || typeof row !== "object") continue;
925
+ const rec = row as { readonly [key: string]: unknown };
926
+
927
+ const toolName = rec["toolName"];
928
+ if (typeof toolName === "string") {
929
+ if (SHELL_TOOLS.has(toolName)) {
930
+ const id = rec["id"];
931
+ const command = commandField(rec);
932
+ calls.push({
933
+ ...(typeof id === "string" ? { id } : {}),
934
+ ...(command === undefined ? {} : { command }),
935
+ });
936
+ }
937
+ continue;
938
+ }
939
+ if (rec["type"] !== "message") continue;
940
+ const message = rec["message"];
941
+ if (message === null || typeof message !== "object") continue;
942
+ const m = message as { readonly [key: string]: unknown };
943
+
944
+ const content = m["content"];
945
+ if (Array.isArray(content) && m["role"] === "assistant") {
946
+ for (const part of content) {
947
+ if (part === null || typeof part !== "object") continue;
948
+ const p = part as { readonly [key: string]: unknown };
949
+ const name = p["name"];
950
+ if (typeof name !== "string" || !SHELL_TOOLS.has(name)) continue;
951
+ const id = p["id"];
952
+ const command = commandField(p);
953
+ calls.push({
954
+ ...(typeof id === "string" ? { id } : {}),
955
+ ...(command === undefined ? {} : { command }),
956
+ });
957
+ }
958
+ continue;
959
+ }
960
+
961
+ // A shared-host refusal arrives as a tool result carrying the guard's
962
+ // own marker. Keyed by the tool-call id so the refused invocation is the
963
+ // one identified, never its neighbour.
964
+ if (m["role"] === "toolResult") {
965
+ const toolCallId = m["toolCallId"];
966
+ if (typeof toolCallId !== "string") continue;
967
+ const parts = Array.isArray(content) ? content : [];
968
+ const refused = parts.some((part): boolean => {
969
+ if (part === null || typeof part !== "object") return false;
970
+ const block = part as { readonly [key: string]: unknown };
971
+ const text = block["text"];
972
+ return typeof text === "string" && text.includes("sharedHostPolicy");
973
+ });
974
+ if (refused) refusals.add(toolCallId);
975
+ }
976
+ }
977
+ return { calls, refusals };
978
+ }
979
+
980
+ /**
981
+ * The claimed-proof half of the settlement audit: one finding per claimed
982
+ * command whose transcript is silent, and a distinct finding (the honest
983
+ * shape) for one the guard refused. The presence direction — a claim satisfied
984
+ * by any invocation — produces nothing, which is the whole point of the flag
985
+ * existing: silence is what a true Verified section earns.
986
+ */
987
+ function detectClaimedProof(audit: SettlementAudit, flags: SettlementFlag[]): void {
988
+ if (audit.prBody === undefined || audit.transcript === undefined) return;
989
+ const claims = claimedProofCommands(audit.prBody);
990
+ if (claims.length === 0) return;
991
+ const { calls, refusals } = transcriptCalls(audit.transcript);
992
+ const invocations = calls
993
+ .filter((call) => call.command !== undefined)
994
+ .map((call) => {
995
+ const tracked = call as { id?: string; command: string };
996
+ return { ...tracked, tokens: commandTokens(tracked.command) };
997
+ });
998
+
999
+ for (const claim of claims) {
1000
+ const claimTokens = commandTokens(claim);
1001
+ if (claimTokens.length === 0) continue;
1002
+ const matched = invocations.filter((call) => satisfiedBy(claimTokens, call.tokens));
1003
+ if (matched.length > 0) {
1004
+ // A claimed command the guard refused is an attempted-and-blocked report,
1005
+ // never a fabricated one: the worker's own words were "CI owns them",
1006
+ // and a reviewer should be pointing at the guard, not the worker.
1007
+ if (matched.some((call) => call.id !== undefined && refusals.has(call.id))) {
1008
+ flags.push({
1009
+ kind: "claimed-proof-blocked",
1010
+ file: "(transcript)",
1011
+ detail: `\`${evidence(claim)}\` was attempted, but the shared-host guard refused it (CI owns it)`,
1012
+ });
1013
+ }
1014
+ continue;
1015
+ }
1016
+ flags.push({
1017
+ kind: "claimed-proof-missing",
1018
+ file: "(transcript)",
1019
+ detail: `\`${evidence(claim)}\` is claimed as proof, but no command matching it appears in the run's transcript`,
1020
+ });
1021
+ }
1022
+ }
1023
+
674
1024
  // -------------------------------------------------------------------- printing
675
1025
 
676
1026
  /** 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
  *