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
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repo-reading half of onboarding: a short, confined omp session that reads a
|
|
3
|
+
* routing repo and answers one question about it.
|
|
4
|
+
*
|
|
5
|
+
* Ported (#307) from the onboarding skill that #309 then deleted, which held
|
|
6
|
+
* judgment no code had — read each repo's CI workflows, `package.json` scripts and
|
|
7
|
+
* `Makefile`/`justfile` and *propose* the real pre-push gates; write the project
|
|
8
|
+
* context; turn a release intent into the repo's own machinery. This is where its
|
|
9
|
+
* reading half went; the operator-only questions went to `setup-wizard.ts`.
|
|
10
|
+
*
|
|
11
|
+
* **A probe proposes; it never decides.** Every answer is a seed the operator
|
|
12
|
+
* edits or a preview they confirm. Its capability is cut to match:
|
|
13
|
+
*
|
|
14
|
+
* - `readOnly: true` — the session is given **no shell, no editors and no verbs**
|
|
15
|
+
* (`confinement.ts`'s allowlist gate). It is not a sandbox and does not contain
|
|
16
|
+
* the *process*; it removes the tools, and unknown tools arrive denied. Because
|
|
17
|
+
* a probe has no externally started shape, unlike the orchestrator of #143,
|
|
18
|
+
* every probe session this package starts carries it.
|
|
19
|
+
* - `role: "worker"`, so structured reads are also scoped to the checkout;
|
|
20
|
+
* - **no `verbSocketPath`**, so the conductor verbs fail closed: a probe has no
|
|
21
|
+
* route to a label, a merge or a release;
|
|
22
|
+
* - the checkout is a throwaway `git clone --depth 1` in a tmpdir, removed after;
|
|
23
|
+
* - a 30-turn cap and a 10-minute wall clock, then it is disposed.
|
|
24
|
+
*
|
|
25
|
+
* What this is **not**: a boundary against the host. The probe runs as this
|
|
26
|
+
* process's own uid, so it reads what the operator can read. That is the same
|
|
27
|
+
* posture the repo already takes for workers, and the reason a probe is pointed at
|
|
28
|
+
* repositories the operator has themselves configured.
|
|
29
|
+
*
|
|
30
|
+
* And **setup never fails because a probe did**: a crash, a cap kill, output that
|
|
31
|
+
* does not parse, or no omp peer at all each become one warning plus an empty
|
|
32
|
+
* seed. An operator gets the interview they came for either way.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
import { z } from "zod";
|
|
39
|
+
import { createSession, disposeSession, type AgentSessionLike } from "./omp.ts";
|
|
40
|
+
import { reportText } from "./worker.ts";
|
|
41
|
+
import type { WizardUi } from "./wizard-ui.ts";
|
|
42
|
+
|
|
43
|
+
/** The shipped prompt templates, beside the briefs they help write. */
|
|
44
|
+
export type ProbeName = "gates" | "project-context" | "release-procedure";
|
|
45
|
+
|
|
46
|
+
/** A probe's answer, or why there is none. Never a throw: see the module note. */
|
|
47
|
+
export type ProbeResult =
|
|
48
|
+
| { kind: "ok"; text: string }
|
|
49
|
+
| { kind: "unavailable"; reason: string };
|
|
50
|
+
|
|
51
|
+
export interface ProbeTarget {
|
|
52
|
+
/** Routing key, for the prompt and the operator-facing warning. */
|
|
53
|
+
name: string;
|
|
54
|
+
cloneUrl: string;
|
|
55
|
+
defaultBranch: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where each repo is cloned inside the probe workspace, and under what name the
|
|
60
|
+
* prompt refers to it.
|
|
61
|
+
*
|
|
62
|
+
* Generated, never the routing key. A key is validated as non-empty and nothing
|
|
63
|
+
* more, so `../api` — or an `owner/repo` typo — would make `git clone` write
|
|
64
|
+
* outside the throwaway root, where the cleanup that removes it does not reach.
|
|
65
|
+
* The key survives as *data* in the mapping the prompt carries, which is the only
|
|
66
|
+
* place it is needed.
|
|
67
|
+
*/
|
|
68
|
+
export function probeLayout(targets: readonly ProbeTarget[]): { target: ProbeTarget; dir: string }[] {
|
|
69
|
+
return targets.map((target, index) => ({ target, dir: `repo-${index}` }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The routing-key-to-directory mapping a multi-repo prompt needs.
|
|
74
|
+
*
|
|
75
|
+
* Without it the probe can read every checkout but cannot say which routing label
|
|
76
|
+
* owns which — and the repo map is the section's whole purpose.
|
|
77
|
+
*/
|
|
78
|
+
export function probeRepoMap(targets: readonly ProbeTarget[], labelPrefix: string): string {
|
|
79
|
+
return probeLayout(targets)
|
|
80
|
+
.map(({ target, dir }) => `- \`${labelPrefix}${target.name}\` — ./${dir}/ (branch ${target.defaultBranch})`)
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ProbeDeps {
|
|
85
|
+
/** Injected so tests exercise parsing and failure handling with no model. */
|
|
86
|
+
run?: (input: { prompt: string; cwd: string }) => Promise<ProbeResult>;
|
|
87
|
+
/** Injected so tests never clone. Returns the checkout, or why it failed. */
|
|
88
|
+
clone?: (target: ProbeTarget, into: string) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
89
|
+
now?: () => number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One turn beyond this and the probe is killed: a probe that needs 30 turns to
|
|
93
|
+
* read three files is not converging on an answer. */
|
|
94
|
+
export const PROBE_MAX_TURNS = 30;
|
|
95
|
+
/** Wall clock, because a stuck tool call burns no turns at all. */
|
|
96
|
+
export const PROBE_WALL_CLOCK_MS = 10 * 60_000;
|
|
97
|
+
|
|
98
|
+
/** Where the shipped templates live once installed. */
|
|
99
|
+
function templatePath(name: ProbeName): string {
|
|
100
|
+
return join(import.meta.dir, "briefs", "probes", `${name}.md`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Renders a shipped template, substituting `{{KEY}}` placeholders.
|
|
105
|
+
*
|
|
106
|
+
* An unreplaced placeholder is left visible rather than blanked: a prompt that
|
|
107
|
+
* silently lost its stated inputs is one the probe answers by inventing them,
|
|
108
|
+
* which is the single failure these templates are written to prevent.
|
|
109
|
+
*/
|
|
110
|
+
export function renderProbePrompt(name: ProbeName, values: Readonly<Record<string, string>>): string {
|
|
111
|
+
let text = readFileSync(templatePath(name), "utf8");
|
|
112
|
+
for (const [key, value] of Object.entries(values)) {
|
|
113
|
+
text = text.replaceAll(`{{${key}}}`, value);
|
|
114
|
+
}
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Runs one probe against a fresh shallow clone of `target`.
|
|
120
|
+
*
|
|
121
|
+
* Returns `unavailable` for every fault — no peer, clone refused, session throw,
|
|
122
|
+
* cap kill, empty answer — because the caller's contract is to warn and carry on.
|
|
123
|
+
*/
|
|
124
|
+
export async function runProbe(
|
|
125
|
+
name: ProbeName,
|
|
126
|
+
targets: readonly ProbeTarget[],
|
|
127
|
+
values: Readonly<Record<string, string>>,
|
|
128
|
+
deps: ProbeDeps = {},
|
|
129
|
+
): Promise<ProbeResult> {
|
|
130
|
+
if (targets.length === 0) return { kind: "unavailable", reason: "no repo to read" };
|
|
131
|
+
const prompt = renderProbePrompt(name, values);
|
|
132
|
+
const root = mkdtempSync(join(tmpdir(), "omp-probe-"));
|
|
133
|
+
try {
|
|
134
|
+
const clone = deps.clone ?? shallowClone;
|
|
135
|
+
const layout = probeLayout(targets);
|
|
136
|
+
for (const { target, dir } of layout) {
|
|
137
|
+
const cloned = await clone(target, join(root, dir));
|
|
138
|
+
// Every named repo, or none: a project-context draft that silently skipped
|
|
139
|
+
// the repo it could not clone would map ownership it never read.
|
|
140
|
+
if (!cloned.ok) return { kind: "unavailable", reason: cloned.reason };
|
|
141
|
+
}
|
|
142
|
+
// One repo runs *in* its checkout, so the answer's paths are repo-relative, as
|
|
143
|
+
// the gates template asks. Several run in the workspace above them, where each
|
|
144
|
+
// repo is a directory — the shape the ported skill's "read, per routing repo"
|
|
145
|
+
// instruction needs to be answerable in one pass.
|
|
146
|
+
const cwd = layout.length === 1 ? join(root, layout[0]?.dir ?? "") : root;
|
|
147
|
+
return await (deps.run ?? runInSession)({ prompt, cwd });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { kind: "unavailable", reason: err instanceof Error ? err.message : String(err) };
|
|
150
|
+
} finally {
|
|
151
|
+
// Throwaway by design, so it goes even on the error paths: a tmpdir per setup
|
|
152
|
+
// run would otherwise accumulate silently.
|
|
153
|
+
rmSync(root, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function shallowClone(
|
|
158
|
+
target: ProbeTarget,
|
|
159
|
+
into: string,
|
|
160
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
161
|
+
const proc = Bun.spawn(
|
|
162
|
+
["git", "clone", "--depth", "1", "--single-branch", "--branch", target.defaultBranch, target.cloneUrl, into],
|
|
163
|
+
{ stdout: "ignore", stderr: "pipe" },
|
|
164
|
+
);
|
|
165
|
+
const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
166
|
+
if (code === 0) return { ok: true };
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: `could not clone ${target.cloneUrl} (${target.defaultBranch}): ${stderr.trim().split("\n").at(-1) ?? `git exited ${code}`}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The probe's capability set, as one value so a test can hold it to account.
|
|
175
|
+
*
|
|
176
|
+
* Exported because this object *is* the trust boundary. `readOnly` removes the
|
|
177
|
+
* shell, the editors and every unknown tool; the absent `verbSocketPath` leaves the
|
|
178
|
+
* conductor verbs failing closed. A dropped field here would hand a
|
|
179
|
+
* repository-reading session real power, and nothing else about the probe would
|
|
180
|
+
* look any different.
|
|
181
|
+
*/
|
|
182
|
+
export function probeSessionOptions(cwd: string): { cwd: string; role: "worker"; readOnly: boolean } {
|
|
183
|
+
return { cwd, role: "worker", readOnly: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Runs the prompt to an answer. The caps are what stop a probe becoming a cost.
|
|
188
|
+
*
|
|
189
|
+
* Two orderings, both learnt from bugs:
|
|
190
|
+
*
|
|
191
|
+
* - The prompt is **raced against the caps**, never merely awaited. `worker.ts:336`
|
|
192
|
+
* says why: an aborted session may never reach a terminal turn, so a cap that
|
|
193
|
+
* disposed and then waited for `prompt` would hang — here, inside an operator's
|
|
194
|
+
* interview, with no output and nothing to press.
|
|
195
|
+
* - Teardown is **awaited before returning**, through one memoised promise. The
|
|
196
|
+
* caller deletes the clone the moment this resolves, and `omp.ts`'s disposer is
|
|
197
|
+
* bounded (5s grace, `SIGTERM`, 5s, `SIGKILL`), so waiting for it cannot hang and
|
|
198
|
+
* not waiting would race `rmSync` against a child still reading the checkout.
|
|
199
|
+
* Memoised because the cap path and the `finally` both ask for it, and two
|
|
200
|
+
* concurrent disposals would signal the child twice.
|
|
201
|
+
*/
|
|
202
|
+
export async function runInSession(
|
|
203
|
+
input: { prompt: string; cwd: string },
|
|
204
|
+
start: (opts: ReturnType<typeof probeSessionOptions>) => Promise<AgentSessionLike> = createSession,
|
|
205
|
+
// Injected alongside `start` because `disposeSession` resolves through a private
|
|
206
|
+
// registry only a real session is in, so a test could not otherwise observe that
|
|
207
|
+
// this function waits for teardown before letting its caller delete the checkout.
|
|
208
|
+
teardown: (session: AgentSessionLike) => Promise<void> = disposeSession,
|
|
209
|
+
): Promise<ProbeResult> {
|
|
210
|
+
const session = await start(probeSessionOptions(input.cwd));
|
|
211
|
+
let answer = "";
|
|
212
|
+
let turns = 0;
|
|
213
|
+
|
|
214
|
+
let disposal: Promise<void> | undefined;
|
|
215
|
+
const dispose = (): Promise<void> => (disposal ??= teardown(session));
|
|
216
|
+
|
|
217
|
+
// Resolves with the reason a cap fired. The prompt racing against it is what
|
|
218
|
+
// guarantees this function returns.
|
|
219
|
+
const capped = Promise.withResolvers<string>();
|
|
220
|
+
const stop = (why: string): void => {
|
|
221
|
+
capped.resolve(why);
|
|
222
|
+
// Started here so the child is already shutting down while the race unwinds;
|
|
223
|
+
// the `finally` below is what waits for it.
|
|
224
|
+
void dispose();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
session.on("turn_start", () => {
|
|
228
|
+
// `turn_start` once per turn, for the reason worker.ts uses it: one turn can
|
|
229
|
+
// emit several assistant `message_end`s and would burn the cap while behaving.
|
|
230
|
+
turns += 1;
|
|
231
|
+
if (turns > PROBE_MAX_TURNS) stop(`stopped after ${PROBE_MAX_TURNS} turns without an answer`);
|
|
232
|
+
});
|
|
233
|
+
session.on("message_end", (event: unknown) => {
|
|
234
|
+
// Narrowed rather than asserted: this is a harness value the compiler lost,
|
|
235
|
+
// and a fabricated shape here would read `undefined` in silence.
|
|
236
|
+
if (event === null || typeof event !== "object" || !("message" in event)) return;
|
|
237
|
+
const message = event.message;
|
|
238
|
+
if (message === null || typeof message !== "object") return;
|
|
239
|
+
if (!("role" in message) || message.role !== "assistant") return;
|
|
240
|
+
// Newest non-empty assistant text wins, exactly as a worker's report is
|
|
241
|
+
// collected — whether the probe finished cleanly or was cut off.
|
|
242
|
+
const text = reportText("content" in message ? message.content : undefined);
|
|
243
|
+
if (text !== "") answer = text;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const wall = setTimeout(() => stop("stopped after 10 minutes"), PROBE_WALL_CLOCK_MS);
|
|
247
|
+
let capReason: string | undefined;
|
|
248
|
+
try {
|
|
249
|
+
// A cap kill still keeps whatever the probe had already said: a draft cut off
|
|
250
|
+
// mid-sentence is the operator's to accept or decline, same as a complete one.
|
|
251
|
+
capReason = await Promise.race([
|
|
252
|
+
session.prompt(input.prompt).then(() => undefined),
|
|
253
|
+
capped.promise,
|
|
254
|
+
]);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
return { kind: "unavailable", reason: err instanceof Error ? err.message : String(err) };
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(wall);
|
|
259
|
+
// Before the caller removes the checkout this child is standing in.
|
|
260
|
+
await dispose();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (answer === "") return { kind: "unavailable", reason: capReason ?? "the probe produced no answer" };
|
|
264
|
+
return { kind: "ok", text: answer };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** One gate the probe proposes. Shape-checked, never trusted as configuration. */
|
|
268
|
+
export interface ProbedGate {
|
|
269
|
+
cmd: string;
|
|
270
|
+
cwd: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Pulls `{"gates":[{"cmd","cwd"}],"evidence"}` out of a probe's answer.
|
|
275
|
+
*
|
|
276
|
+
* Fenced-JSON rather than prose because a gate list is the one probe answer that
|
|
277
|
+
* feeds a *value* rather than a preview: it pre-fills the gates prompt, so it has
|
|
278
|
+
* to survive a parse. Anything unparseable is no seed at all — the operator then
|
|
279
|
+
* types the gates, which is exactly what they did before this existed.
|
|
280
|
+
*/
|
|
281
|
+
/**
|
|
282
|
+
* The shape a gates probe must answer in.
|
|
283
|
+
*
|
|
284
|
+
* A schema rather than hand-rolled field reads because this is outside-controlled
|
|
285
|
+
* input — a model's text, parsed as JSON — and it is the one probe answer that
|
|
286
|
+
* feeds a *value* rather than a preview. A missing `cwd` is the repo root, which
|
|
287
|
+
* is what an unqualified gate means.
|
|
288
|
+
*/
|
|
289
|
+
const ProbedGatesSchema = z.object({
|
|
290
|
+
gates: z.array(
|
|
291
|
+
z.object({
|
|
292
|
+
cmd: z.string().trim().min(1),
|
|
293
|
+
cwd: z.string().trim().min(1).default("."),
|
|
294
|
+
}),
|
|
295
|
+
),
|
|
296
|
+
evidence: z.string().trim().min(1).optional(),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Pulls `{"gates":[{"cmd","cwd"}],"evidence"}` out of a probe's answer.
|
|
301
|
+
*
|
|
302
|
+
* Fenced JSON, because a gate list pre-fills a prompt and therefore has to
|
|
303
|
+
* survive a parse. Anything unparseable is no seed at all — the operator then
|
|
304
|
+
* types the gates, which is exactly what they did before this existed.
|
|
305
|
+
*/
|
|
306
|
+
export function parseProbedGates(text: string): { gates: ProbedGate[]; evidence?: string } | undefined {
|
|
307
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
308
|
+
const body = (fenced?.[1] ?? text).trim();
|
|
309
|
+
let raw: unknown;
|
|
310
|
+
try {
|
|
311
|
+
raw = JSON.parse(body);
|
|
312
|
+
} catch {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
const parsed = ProbedGatesSchema.safeParse(raw);
|
|
316
|
+
if (!parsed.success) return undefined;
|
|
317
|
+
return {
|
|
318
|
+
gates: parsed.data.gates,
|
|
319
|
+
...(parsed.data.evidence === undefined ? {} : { evidence: parsed.data.evidence }),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Runs the gates probe and returns what the operator should see pre-filled.
|
|
325
|
+
*
|
|
326
|
+
* The warning is deliberately the operator's, not a log line: they are about to
|
|
327
|
+
* be shown a gates prompt, and whether it was seeded by a probe or left as the
|
|
328
|
+
* shipped default changes how carefully they should read it.
|
|
329
|
+
*/
|
|
330
|
+
export async function probeGates(
|
|
331
|
+
ui: WizardUi,
|
|
332
|
+
target: ProbeTarget,
|
|
333
|
+
deps: ProbeDeps = {},
|
|
334
|
+
): Promise<ProbedGate[]> {
|
|
335
|
+
const result = await runProbe("gates", [target], { REPO: target.name, BRANCH: target.defaultBranch }, deps);
|
|
336
|
+
if (result.kind !== "ok") {
|
|
337
|
+
ui.notify(`Could not read ${target.name}'s gates (${result.reason}) — type them yourself below.`, "warning");
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
const parsed = parseProbedGates(result.text);
|
|
341
|
+
if (parsed === undefined) {
|
|
342
|
+
ui.notify(`${target.name}'s gate probe did not answer in the required shape — type them yourself below.`, "warning");
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
if (parsed.gates.length === 0) {
|
|
346
|
+
// An honest "no gates" is a real answer the template asks for, and it is not
|
|
347
|
+
// the same as a failed probe: say so rather than implying a fault.
|
|
348
|
+
ui.notify(`Probe found no pre-push gates in ${target.name}${parsed.evidence === undefined ? "" : ` — ${parsed.evidence}`}.`, "info");
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
ui.notify(
|
|
352
|
+
[
|
|
353
|
+
`Proposed gates for ${target.name}, from its own CI:`,
|
|
354
|
+
...parsed.gates.map((g) => ` ${g.cmd}${g.cwd === "." ? "" : ` @ ${g.cwd}`}`),
|
|
355
|
+
...(parsed.evidence === undefined ? [] : [` evidence: ${parsed.evidence}`]),
|
|
356
|
+
"Edit them at the prompt below — they are a proposal, not a decision.",
|
|
357
|
+
].join("\n"),
|
|
358
|
+
"info",
|
|
359
|
+
);
|
|
360
|
+
return parsed.gates;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Strips one fenced block, which is how both prose templates are asked to answer. */
|
|
364
|
+
export function unfence(text: string): string {
|
|
365
|
+
const fenced = /```(?:markdown|md)?\s*([\s\S]*?)```/.exec(text);
|
|
366
|
+
return (fenced?.[1] ?? text).trim();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Runs a prose probe, shows what it wrote, and asks whether to keep it.
|
|
371
|
+
*
|
|
372
|
+
* The confirm is the whole point. A gate list is checkable at a glance; a
|
|
373
|
+
* paragraph about what a repo owns is not, and one an operator did not read is
|
|
374
|
+
* worse than a stub — the brief is re-read on every tick, so a plausible invention
|
|
375
|
+
* in it becomes an instruction the orchestrator follows all week.
|
|
376
|
+
*
|
|
377
|
+
* Declined, unavailable, or empty all return `undefined`: the caller writes the
|
|
378
|
+
* shipped stub, which says what is missing and how to fill it in.
|
|
379
|
+
*/
|
|
380
|
+
export async function probeProse(
|
|
381
|
+
ui: WizardUi,
|
|
382
|
+
name: Exclude<ProbeName, "gates">,
|
|
383
|
+
heading: string,
|
|
384
|
+
targets: readonly ProbeTarget[],
|
|
385
|
+
values: Readonly<Record<string, string>>,
|
|
386
|
+
deps: ProbeDeps = {},
|
|
387
|
+
): Promise<string | undefined> {
|
|
388
|
+
const names = targets.map((t) => t.name).join(", ");
|
|
389
|
+
ui.notify(`Reading ${names} to draft ${heading} — this takes a few minutes.`, "info");
|
|
390
|
+
const result = await runProbe(name, targets, values, deps);
|
|
391
|
+
if (result.kind !== "ok") {
|
|
392
|
+
ui.notify(`Could not draft ${heading} (${result.reason}) — the brief keeps its stub, which says what to write.`, "warning");
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
const text = unfence(result.text);
|
|
396
|
+
if (text.length === 0) {
|
|
397
|
+
ui.notify(`The ${heading} probe answered with nothing — the brief keeps its stub.`, "warning");
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
ui.notify([`Proposed ${heading}, from ${names}:`, "", text, ""].join("\n"), "info");
|
|
402
|
+
const keep = await ui.confirm(
|
|
403
|
+
`Keep this ${heading}?`,
|
|
404
|
+
"It becomes part of POLICY.md, which the orchestrator re-reads on every tick. You can edit that file afterwards. Declining keeps the stub.",
|
|
405
|
+
);
|
|
406
|
+
// A dismissed prompt is not a yes. `undefined` means the operator walked away.
|
|
407
|
+
if (keep !== true) {
|
|
408
|
+
ui.notify(`Discarded — the brief keeps its ${heading} stub.`, "info");
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
return text;
|
|
412
|
+
}
|