omp-conductor 0.13.0 → 0.15.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.
- package/README.md +549 -234
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/availability.ts +165 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +72 -31
- package/src/briefs/policy.md +48 -36
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +356 -212
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1037 -679
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +644 -390
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +89 -22
- package/src/fleet.ts +351 -46
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +732 -56
- package/src/privileged.ts +264 -0
- package/src/reports.ts +203 -6
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/setup-wizard.ts +1946 -0
- package/src/setup.ts +457 -53
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +153 -14
- package/src/upgrade.ts +44 -10
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +40 -18
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +24 -7
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
- package/src/plugin.ts +0 -1495
package/src/verbs/server.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { createServer, type Server, type Socket } from "node:net";
|
|
|
43
43
|
|
|
44
44
|
import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
|
|
45
45
|
import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
|
|
46
|
-
import type
|
|
46
|
+
import { repoSlugFor, type readBaseChain as readBaseChainType } from "../gitops.ts";
|
|
47
47
|
import { releaseRefusal } from "../release-policy.ts";
|
|
48
48
|
import { LIVE_STATES } from "../store.ts";
|
|
49
49
|
import type {
|
|
@@ -61,6 +61,7 @@ import type {
|
|
|
61
61
|
VerbName,
|
|
62
62
|
VerbRefusal,
|
|
63
63
|
} from "../types.ts";
|
|
64
|
+
import { prUrlParts } from "../tracker/github.ts";
|
|
64
65
|
import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
|
|
65
66
|
import {
|
|
66
67
|
peerVerdict,
|
|
@@ -265,9 +266,9 @@ export interface ReleaseFacts {
|
|
|
265
266
|
openPrs: number;
|
|
266
267
|
/** Queue depth, or `undefined` when the tracker could not be read. */
|
|
267
268
|
queueDepth: number | undefined;
|
|
268
|
-
/**
|
|
269
|
+
/** Current live-head workflow verdict for the released routed repository. */
|
|
269
270
|
baseCheck?: RunRecord["baseCheck"];
|
|
270
|
-
/** Evidence attached to a
|
|
271
|
+
/** Evidence attached to a current red verdict. */
|
|
271
272
|
redBase?: string;
|
|
272
273
|
}
|
|
273
274
|
|
|
@@ -637,6 +638,20 @@ function runForPr(deps: VerbDeps, project: string, prUrl: string): RunRecord | u
|
|
|
637
638
|
.find((candidate) => candidate.prUrl === prUrl);
|
|
638
639
|
}
|
|
639
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Whether a PR URL names a repository this project routes.
|
|
643
|
+
*
|
|
644
|
+
* Orchestrator status / update-branch may act on open project PRs that no run
|
|
645
|
+
* opened (workflow-generated release pins, etc.). Scope is the routed clone
|
|
646
|
+
* slugs — not "any PR a run once recorded".
|
|
647
|
+
*/
|
|
648
|
+
function prInProjectRouting(project: ProjectConfig, prUrl: string): boolean {
|
|
649
|
+
const parts = prUrlParts(prUrl);
|
|
650
|
+
if (parts === undefined) return false;
|
|
651
|
+
const slug = `${parts.owner}/${parts.repo}`;
|
|
652
|
+
return Object.values(project.routing.repos).some((repo) => repoSlugFor(repo) === slug);
|
|
653
|
+
}
|
|
654
|
+
|
|
640
655
|
/** Refuse an orchestrator completion mutation that cannot prove a pre-pause run. */
|
|
641
656
|
function orchestratorPauseRefusal(
|
|
642
657
|
deps: VerbDeps,
|
|
@@ -688,8 +703,12 @@ async function prUpdateBranchVerb(
|
|
|
688
703
|
target = own;
|
|
689
704
|
} else {
|
|
690
705
|
target = runForPr(deps, project.name, prUrl);
|
|
691
|
-
if (target === undefined) {
|
|
692
|
-
return refuse(
|
|
706
|
+
if (target === undefined && !prInProjectRouting(project, prUrl)) {
|
|
707
|
+
return refuse(
|
|
708
|
+
"pr-not-this-run",
|
|
709
|
+
`refused: ${prUrl} is not a pull request in ${project.name}'s routed repositories ` +
|
|
710
|
+
`(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
|
|
711
|
+
);
|
|
693
712
|
}
|
|
694
713
|
}
|
|
695
714
|
|
|
@@ -709,15 +728,15 @@ async function prUpdateBranchVerb(
|
|
|
709
728
|
state === undefined
|
|
710
729
|
? `refused: the tracker could not say whether ${prUrl} is open. Refusing rather than acting on an unknown state.`
|
|
711
730
|
: `refused: ${prUrl} is ${state}, not open.`,
|
|
712
|
-
target
|
|
731
|
+
target?.issue,
|
|
713
732
|
);
|
|
714
733
|
}
|
|
715
734
|
|
|
716
735
|
const outcome = await deps.actions.updatePrBranch(prUrl);
|
|
717
736
|
if (!outcome.ok) {
|
|
718
|
-
return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target
|
|
737
|
+
return refuse("action-failed", `refused: gh could not update the branch:\n${outcome.stderr}`, target?.issue);
|
|
719
738
|
}
|
|
720
|
-
return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target
|
|
739
|
+
return allow(`updated ${prUrl} with its base branch. Re-check its checks before merging.`, outcome.sha, target?.issue);
|
|
721
740
|
}
|
|
722
741
|
|
|
723
742
|
async function prMergeVerb(
|
|
@@ -978,18 +997,17 @@ async function releaseVerb(
|
|
|
978
997
|
}
|
|
979
998
|
}
|
|
980
999
|
const wantsBase = policy.release.requires.includes("base-branch-green");
|
|
981
|
-
const
|
|
982
|
-
? deps.store.
|
|
1000
|
+
const health = wantsBase
|
|
1001
|
+
? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
|
|
983
1002
|
: undefined;
|
|
984
|
-
const redBase = latestBase?.settlementFlags?.find(
|
|
985
|
-
(flag) => flag.kind === "base-branch-red",
|
|
986
|
-
)?.detail;
|
|
987
1003
|
const unmet = releaseRequirementRefusal(policy.release.requires, {
|
|
988
1004
|
unsettledRuns: active.length,
|
|
989
1005
|
openPrs: active.filter((r) => r.prUrl !== undefined).length,
|
|
990
1006
|
queueDepth,
|
|
991
|
-
...(
|
|
992
|
-
...(
|
|
1007
|
+
...(health === undefined ? {} : { baseCheck: health.verdict }),
|
|
1008
|
+
...(health?.verdict === "red" && health.detail !== undefined
|
|
1009
|
+
? { redBase: health.detail }
|
|
1010
|
+
: {}),
|
|
993
1011
|
});
|
|
994
1012
|
if (unmet !== undefined) return refuse("release-not-granted", `refused: ${unmet}`);
|
|
995
1013
|
|
|
@@ -1062,11 +1080,15 @@ async function prStatusVerb(
|
|
|
1062
1080
|
return refuse("malformed-argument", "refused: conductor_pr_status needs prUrl when it has no run to infer one from.");
|
|
1063
1081
|
}
|
|
1064
1082
|
const target = runForPr(deps, project.name, asked);
|
|
1065
|
-
if (target === undefined) {
|
|
1066
|
-
return refuse(
|
|
1083
|
+
if (target === undefined && !prInProjectRouting(project, asked)) {
|
|
1084
|
+
return refuse(
|
|
1085
|
+
"pr-not-this-run",
|
|
1086
|
+
`refused: ${asked} is not a pull request in ${project.name}'s routed repositories ` +
|
|
1087
|
+
`(${Object.values(project.routing.repos).map(repoSlugFor).join(", ") || "none"}).`,
|
|
1088
|
+
);
|
|
1067
1089
|
}
|
|
1068
1090
|
prUrl = asked;
|
|
1069
|
-
issue = target
|
|
1091
|
+
issue = target?.issue;
|
|
1070
1092
|
}
|
|
1071
1093
|
|
|
1072
1094
|
const askedHead = args["headSha"];
|
package/src/wizard-ui.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The presentation surface the setup wizard ({@link setup-wizard.ts}) codes
|
|
3
|
+
* against. Nothing about it is terminal-specific: any driver that satisfies this
|
|
4
|
+
* exact shape can drive the wizard, and today that driver is the terminal
|
|
5
|
+
* `omp-conductor setup` verb (#309 deleted the in-session dialog).
|
|
6
|
+
*
|
|
7
|
+
* Three of the four methods are prompt-bearing. The load-bearing contracts,
|
|
8
|
+
* each of which the wizard already relies on and a future refactor would break
|
|
9
|
+
* silently:
|
|
10
|
+
*
|
|
11
|
+
* - `input` resolves the placeholder on an empty submit ("Enter accepts what
|
|
12
|
+
* you see"), so a re-run that Enters through a prompt re-affirms the shown
|
|
13
|
+
* default instead of blanking it.
|
|
14
|
+
* - `select` resolves the chosen option's **label**, never its index — the
|
|
15
|
+
* label *is* the config value for the closed-vocabulary questions, so a
|
|
16
|
+
* lookup table that disagreed with the vocabulary it was built from would
|
|
17
|
+
* corrupt the written config.
|
|
18
|
+
* - `select` honours `dialogOptions.initialIndex` so a bare Enter re-affirms
|
|
19
|
+
* the current setting (see the "cannot be a confirm" note in the wizard).
|
|
20
|
+
* - `confirm` is `y/N`, defaulting to no.
|
|
21
|
+
*
|
|
22
|
+
* Every prompt resolves `undefined` when the operator dismisses it (Ctrl-C /
|
|
23
|
+
* EOF), which every wizard caller already treats as the cancel path
|
|
24
|
+
* (`Cancelled`). The terminal UI guarantees the readline interface is closed on
|
|
25
|
+
* every exit path so the verb cannot hang the terminal.
|
|
26
|
+
*/
|
|
27
|
+
export interface WizardUi {
|
|
28
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
29
|
+
/**
|
|
30
|
+
* Yes/no. `undefined` means the operator *dismissed* the prompt rather than
|
|
31
|
+
* answering it, which is a different thing from "no": the wizard turns it into
|
|
32
|
+
* `Cancelled` and abandons the run. Collapsing the two is how Ctrl-C at a
|
|
33
|
+
* confirm used to record a silent "no" and carry on to the next question.
|
|
34
|
+
*/
|
|
35
|
+
confirm(title: string, message: string): Promise<boolean | undefined>;
|
|
36
|
+
/** Single-line text prompt. `undefined` dismisses it; an empty submit accepts
|
|
37
|
+
* the placeholder. */
|
|
38
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
39
|
+
/** Numbered single-choice list. Resolves the chosen option's label, or
|
|
40
|
+
* `undefined` when dismissed. */
|
|
41
|
+
select(
|
|
42
|
+
title: string,
|
|
43
|
+
options: { label: string; description?: string }[],
|
|
44
|
+
dialogOptions?: { initialIndex?: number },
|
|
45
|
+
): Promise<string | undefined>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
import { createInterface, type Interface } from "node:readline/promises";
|
|
49
|
+
import { stdin, stdout } from "node:process";
|
|
50
|
+
import type { Readable, Writable } from "node:stream";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A `WizardUi` over a real terminal — or a pipe, which is how the verb is
|
|
54
|
+
* scripted in tests — backed by `node:readline/promises`.
|
|
55
|
+
*
|
|
56
|
+
* **One interface for the whole wizard, closed once.** The first version of this
|
|
57
|
+
* created and closed a readline interface per prompt, on the theory that
|
|
58
|
+
* releasing stdin after each question could not leak. It cannot: closing a
|
|
59
|
+
* readline interface ends the shared stdin stream, so the *second* prompt of a
|
|
60
|
+
* piped run had no readable input left and the run hung indefinitely — the exact
|
|
61
|
+
* failure this comment used to claim it prevented. The interface therefore lives
|
|
62
|
+
* as long as the wizard, and {@link TerminalUi.close} releases it once, from the
|
|
63
|
+
* caller's `finally`.
|
|
64
|
+
*
|
|
65
|
+
* Dismissal is the interface's own `close`: `rl.question()` on an exhausted
|
|
66
|
+
* stream never settles on its own, so EOF (a script that ran out of answers) and
|
|
67
|
+
* Ctrl-C both close the interface, and the line reader turns that into the
|
|
68
|
+
* `undefined` every wizard caller reads as cancel.
|
|
69
|
+
*
|
|
70
|
+
* `io` exists so the contracts below can be tested in-process against a pipe
|
|
71
|
+
* rather than only through a spawned CLI; production callers pass nothing.
|
|
72
|
+
*/
|
|
73
|
+
export interface TerminalUi extends WizardUi {
|
|
74
|
+
/** Releases stdin. Idempotent; call it from a `finally`. */
|
|
75
|
+
close(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function terminalUi(io: { input?: Readable; output?: Writable } = {}): TerminalUi {
|
|
79
|
+
const input = io.input ?? stdin;
|
|
80
|
+
const output = io.output ?? stdout;
|
|
81
|
+
const rl: Interface = createInterface({ input, output });
|
|
82
|
+
let closed = false;
|
|
83
|
+
const close = (): void => {
|
|
84
|
+
if (closed) return;
|
|
85
|
+
closed = true;
|
|
86
|
+
rl.close();
|
|
87
|
+
};
|
|
88
|
+
// Ctrl-C is a cancel, not a signal to the whole process: closing the interface
|
|
89
|
+
// makes every pending and subsequent prompt resolve `undefined`, which the
|
|
90
|
+
// wizard already turns into `Cancelled`.
|
|
91
|
+
rl.once("SIGINT", close);
|
|
92
|
+
|
|
93
|
+
// Lines are buffered as they arrive rather than read on demand, because on a
|
|
94
|
+
// non-TTY stdin readline drains the whole pipe at once and emits `line` for
|
|
95
|
+
// every one of them immediately. `rl.question()` only captures the line that
|
|
96
|
+
// arrives *after* it is called, so a scripted run lost every answer that had
|
|
97
|
+
// already flown past and then hit EOF — three prompts resolving `undefined`
|
|
98
|
+
// and a wizard that cancelled itself. Buffering makes a pipe and a terminal
|
|
99
|
+
// behave the same: nothing is read before it is asked for, nothing is lost.
|
|
100
|
+
const buffered: string[] = [];
|
|
101
|
+
let waiting: ((value: string | undefined) => void) | undefined;
|
|
102
|
+
let ended = false;
|
|
103
|
+
const deliver = (value: string | undefined): boolean => {
|
|
104
|
+
const resolve = waiting;
|
|
105
|
+
if (resolve === undefined) return false;
|
|
106
|
+
waiting = undefined;
|
|
107
|
+
resolve(value);
|
|
108
|
+
return true;
|
|
109
|
+
};
|
|
110
|
+
rl.on("line", (text: string) => {
|
|
111
|
+
if (!deliver(text)) buffered.push(text);
|
|
112
|
+
});
|
|
113
|
+
rl.once("close", () => {
|
|
114
|
+
ended = true;
|
|
115
|
+
deliver(undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** One line, or `undefined` when the operator dismissed the prompt. */
|
|
119
|
+
const line = async (query: string): Promise<string | undefined> => {
|
|
120
|
+
if (closed && buffered.length === 0) return undefined;
|
|
121
|
+
output.write(query);
|
|
122
|
+
const ready = buffered.shift();
|
|
123
|
+
if (ready !== undefined) return ready;
|
|
124
|
+
if (ended) return undefined;
|
|
125
|
+
return await new Promise<string | undefined>((resolve) => {
|
|
126
|
+
waiting = resolve;
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
close,
|
|
132
|
+
notify(message) {
|
|
133
|
+
output.write(`${message}\n`);
|
|
134
|
+
},
|
|
135
|
+
async confirm(title, message) {
|
|
136
|
+
for (;;) {
|
|
137
|
+
const answer = await line(`${title} — ${message} (y/N) `);
|
|
138
|
+
// Dismissal is NOT "no". Ctrl-C or EOF here means the operator left, and
|
|
139
|
+
// reporting `false` made the interview march on to the next question with
|
|
140
|
+
// a silent "no" recorded — so `Ctrl-C abandons the run` was untrue for
|
|
141
|
+
// every yes/no prompt. The wizard turns this into `Cancelled`.
|
|
142
|
+
if (answer === undefined) return undefined;
|
|
143
|
+
const a = answer.trim().toLowerCase();
|
|
144
|
+
if (a === "y" || a === "yes") return true;
|
|
145
|
+
// Enter is still the safe answer: no, and the interview continues.
|
|
146
|
+
if (a === "n" || a === "no" || a === "") return false;
|
|
147
|
+
output.write("Please answer y or n.\n");
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
async input(title, placeholder) {
|
|
151
|
+
const query =
|
|
152
|
+
placeholder !== undefined && placeholder.length > 0 ? `${title} [${placeholder}]: ` : `${title}: `;
|
|
153
|
+
const answer = await line(query);
|
|
154
|
+
if (answer === undefined) return undefined;
|
|
155
|
+
// "Enter accepts what you see": an empty submit keeps the placeholder.
|
|
156
|
+
return answer.trim().length === 0 ? (placeholder ?? "") : answer;
|
|
157
|
+
},
|
|
158
|
+
async select(title, options, dialogOptions) {
|
|
159
|
+
output.write(`${title}\n`);
|
|
160
|
+
options.forEach((o, i) => {
|
|
161
|
+
output.write(` ${i + 1}. ${o.label}${o.description !== undefined ? ` — ${o.description}` : ""}\n`);
|
|
162
|
+
});
|
|
163
|
+
const initial = dialogOptions?.initialIndex ?? 0;
|
|
164
|
+
const defaultLabel = options[initial]?.label ?? "";
|
|
165
|
+
for (;;) {
|
|
166
|
+
const answer = await line(
|
|
167
|
+
`Select 1-${options.length} [${initial + 1} = ${defaultLabel}] (Enter keeps it, Ctrl-C cancels): `,
|
|
168
|
+
);
|
|
169
|
+
if (answer === undefined) return undefined;
|
|
170
|
+
const a = answer.trim();
|
|
171
|
+
if (a === "") return defaultLabel; // a bare Enter re-affirms the current row
|
|
172
|
+
const n = Number(a);
|
|
173
|
+
if (Number.isInteger(n)) {
|
|
174
|
+
const picked = options[n - 1];
|
|
175
|
+
if (picked !== undefined) return picked.label;
|
|
176
|
+
}
|
|
177
|
+
output.write(`Please enter a number between 1 and ${options.length}.\n`);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The test seam: a `WizardUi` driven by a script keyed by a substring of each
|
|
185
|
+
* prompt's title.
|
|
186
|
+
*
|
|
187
|
+
* **Anything unscripted takes the answer an operator gets by pressing Enter** —
|
|
188
|
+
* an empty `input` (which the wizard reads as "accept the shown default"), `no`
|
|
189
|
+
* on a `confirm`, and the row the caller put the cursor on for a `select`. That
|
|
190
|
+
* leniency is deliberate and is itself the thing under test: "Enter accepts what
|
|
191
|
+
* you see" is the contract the whole wizard is built on, so a test that has to
|
|
192
|
+
* script an answer is a test whose default moved. Throwing on an unscripted
|
|
193
|
+
* prompt would make that property untestable.
|
|
194
|
+
*
|
|
195
|
+
* Asking an *unintended* question is caught a stronger way instead: the suite
|
|
196
|
+
* pins the full ordered list of prompts, so a question that appears or vanishes
|
|
197
|
+
* fails on the transcript rather than on a missing script entry.
|
|
198
|
+
*
|
|
199
|
+
* - `input`: the scripted string, else `""` (Enter → the wizard's fallback).
|
|
200
|
+
* - `confirm`: the scripted boolean, else `false`. `null` dismisses the prompt
|
|
201
|
+
* (`undefined`), which the wizard reads as cancel — the same spelling
|
|
202
|
+
* `select` uses.
|
|
203
|
+
* - `select`: a number picks that option's **label**; a string matches an
|
|
204
|
+
* offered label by prefix; `null` dismisses the prompt (`undefined`, which
|
|
205
|
+
* the wizard reads as cancel); unscripted takes `initialIndex`'s label.
|
|
206
|
+
* - An array answers successive prompts sharing the same title; the last entry
|
|
207
|
+
* repeats.
|
|
208
|
+
*/
|
|
209
|
+
export interface ScriptedAnswers {
|
|
210
|
+
input?: Record<string, string>;
|
|
211
|
+
confirm?: Record<string, boolean | boolean[] | null>;
|
|
212
|
+
select?: Record<string, string | number | null>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function scriptedUi(script: ScriptedAnswers = {}): WizardUi {
|
|
216
|
+
const seen = new Map<string, number>();
|
|
217
|
+
const answer = <T>(table: Record<string, T> | undefined, title: string): T | undefined => {
|
|
218
|
+
for (const [key, value] of Object.entries(table ?? {})) {
|
|
219
|
+
if (!title.includes(key)) continue;
|
|
220
|
+
if (!Array.isArray(value)) return value;
|
|
221
|
+
const n = seen.get(key) ?? 0;
|
|
222
|
+
seen.set(key, n + 1);
|
|
223
|
+
return (value[Math.min(n, value.length - 1)] ?? value.at(-1)) as T;
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
notify() {
|
|
230
|
+
/* recorded by the caller's wrapper when a test asserts on notices */
|
|
231
|
+
},
|
|
232
|
+
async confirm(title) {
|
|
233
|
+
const scripted = answer(script.confirm, title);
|
|
234
|
+
if (scripted === null) return undefined;
|
|
235
|
+
return Array.isArray(scripted) ? false : (scripted ?? false);
|
|
236
|
+
},
|
|
237
|
+
async input(title) {
|
|
238
|
+
return answer(script.input, title) ?? "";
|
|
239
|
+
},
|
|
240
|
+
async select(title, options, dialogOptions) {
|
|
241
|
+
const chosen = answer(script.select, title);
|
|
242
|
+
if (chosen === null) return undefined;
|
|
243
|
+
if (typeof chosen === "number") return options[chosen]?.label;
|
|
244
|
+
if (typeof chosen === "string") return options.find((o) => o.label.startsWith(chosen))?.label ?? chosen;
|
|
245
|
+
// Enter: the row the caller put the cursor on.
|
|
246
|
+
return options[dialogOptions?.initialIndex ?? 0]?.label;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -22,6 +22,8 @@ const BLOCKED_PATTERN = /^state:\s*blocked\s*$/im;
|
|
|
22
22
|
|
|
23
23
|
/** Any explicit verdict line, whatever it claims. */
|
|
24
24
|
const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
|
|
25
|
+
/** GitHub PR URLs in unstructured prose; capture their canonical `owner/repo`. */
|
|
26
|
+
const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/\d+\b/gi;
|
|
25
27
|
|
|
26
28
|
/** `{{KEY}}` placeholders in a brief template. */
|
|
27
29
|
const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
@@ -63,6 +65,8 @@ export interface WorkerOpts {
|
|
|
63
65
|
brief: string;
|
|
64
66
|
cwd: string;
|
|
65
67
|
caps: Caps;
|
|
68
|
+
/** Canonical `owner/repo` identity used to scope prose-only PR URLs. */
|
|
69
|
+
repoSlug?: string;
|
|
66
70
|
/**
|
|
67
71
|
* Directory the harness writes this run's transcript into — a directory, not
|
|
68
72
|
* a file. The SDK takes no `sessionFile` input, so naming a path here would
|
|
@@ -179,15 +183,23 @@ export function renderBrief(template: string, vars: Record<string, string>): str
|
|
|
179
183
|
* asks the tracker to verify those facts independently. Missing or malformed
|
|
180
184
|
* evidence fails closed.
|
|
181
185
|
*/
|
|
182
|
-
export function deriveResult(report: string): {
|
|
186
|
+
export function deriveResult(report: string, repoSlug?: string): {
|
|
183
187
|
state: RunState;
|
|
184
188
|
prUrl?: string;
|
|
185
189
|
headSha?: string;
|
|
186
190
|
} {
|
|
187
|
-
const
|
|
191
|
+
const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
|
|
188
192
|
const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
|
|
189
|
-
if (PUSHED_GREEN_PATTERN.test(report) &&
|
|
190
|
-
return { state: "pushed-green", prUrl, headSha };
|
|
193
|
+
if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
|
|
194
|
+
return { state: "pushed-green", prUrl: structuredPrUrl, headSha };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let prUrl = structuredPrUrl;
|
|
198
|
+
if (prUrl === undefined && repoSlug !== undefined) {
|
|
199
|
+
const expectedRepo = repoSlug.toLowerCase();
|
|
200
|
+
for (const match of report.matchAll(GITHUB_PR_URL_PATTERN)) {
|
|
201
|
+
if (match[1]?.toLowerCase() === expectedRepo) prUrl = match[0];
|
|
202
|
+
}
|
|
191
203
|
}
|
|
192
204
|
|
|
193
205
|
const state: RunState = BLOCKED_PATTERN.test(report) ? "blocked" : "failed";
|
|
@@ -424,7 +436,7 @@ export async function runWorker(
|
|
|
424
436
|
const text = reportText(field(message, "content"));
|
|
425
437
|
if (text !== "") {
|
|
426
438
|
report = text;
|
|
427
|
-
const stated = deriveResult(text);
|
|
439
|
+
const stated = deriveResult(text, o.repoSlug);
|
|
428
440
|
if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
|
|
429
441
|
claim = { prUrl: stated.prUrl, headSha: stated.headSha };
|
|
430
442
|
}
|
|
@@ -554,7 +566,7 @@ export async function runWorker(
|
|
|
554
566
|
return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
|
|
555
567
|
}
|
|
556
568
|
return withSessionFacts({
|
|
557
|
-
...deriveResult(report),
|
|
569
|
+
...deriveResult(report, o.repoSlug),
|
|
558
570
|
turns,
|
|
559
571
|
spendUsd,
|
|
560
572
|
report,
|
|
@@ -597,7 +609,12 @@ function field(source: unknown, key: string): unknown {
|
|
|
597
609
|
}
|
|
598
610
|
|
|
599
611
|
/** Flatten an assistant message's content blocks to their plain text. */
|
|
600
|
-
|
|
612
|
+
/**
|
|
613
|
+
* The newest assistant text, flattened out of whatever block shape the harness
|
|
614
|
+
* sent. Exported for ./setup-probe.ts, which collects a probe's answer the same
|
|
615
|
+
* way a worker's report is collected — one spelling, so the two cannot drift.
|
|
616
|
+
*/
|
|
617
|
+
export function reportText(content: unknown): string {
|
|
601
618
|
if (typeof content === "string") return content.trim();
|
|
602
619
|
const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
|
|
603
620
|
const parts: string[] = [];
|