omp-conductor 0.14.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 +336 -169
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +9 -5
- package/src/briefs/policy.md +4 -4
- 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 +235 -199
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1046 -796
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +442 -374
- package/src/escalate.ts +43 -3
- package/src/fleet.ts +317 -43
- 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 +298 -39
- package/src/privileged.ts +264 -0
- package/src/reports.ts +1 -1
- 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/{plugin.ts → setup-wizard.ts} +790 -465
- package/src/setup.ts +264 -20
- package/src/types.ts +2 -2
- package/src/upgrade.ts +44 -10
- package/src/verbs/server.ts +32 -9
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +6 -1
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
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
|
@@ -609,7 +609,12 @@ function field(source: unknown, key: string): unknown {
|
|
|
609
609
|
}
|
|
610
610
|
|
|
611
611
|
/** Flatten an assistant message's content blocks to their plain text. */
|
|
612
|
-
|
|
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 {
|
|
613
618
|
if (typeof content === "string") return content.trim();
|
|
614
619
|
const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
|
|
615
620
|
const parts: string[] = [];
|