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.
- package/README.md +34 -0
- package/REFERENCE.md +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- 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
|
package/src/digest-schedule.ts
CHANGED
|
@@ -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
|
|
410
|
+
export function telegramStateDir(): string {
|
|
409
411
|
const override = process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
410
|
-
|
|
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");
|