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,1946 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup wizard: prompts, per-area amend, and the one-writer apply sequence.
|
|
3
|
+
*
|
|
4
|
+
* Extracted (#305) so the interview lived behind the `WizardUi` interface rather
|
|
5
|
+
* than inside the omp plugin's dialog surface; #309 then deleted that surface, and
|
|
6
|
+
* the terminal `omp-conductor setup` verb is now its only caller. It is
|
|
7
|
+
* presentation only — it turns a `WizardUi` into questions and, behind one consent
|
|
8
|
+
* gate,
|
|
9
|
+
* applies the answers through ./setup.ts which is headless and tested. Every
|
|
10
|
+
* prompt's shape and default is a decision worth keeping, so none of them were
|
|
11
|
+
* reworded in the move.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { platform } from "node:os";
|
|
15
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
configPath,
|
|
18
|
+
expandHome,
|
|
19
|
+
findProject,
|
|
20
|
+
loadConfig,
|
|
21
|
+
resolveCaps,
|
|
22
|
+
saveConfig,
|
|
23
|
+
} from "./config.ts";
|
|
24
|
+
import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
|
|
25
|
+
import {
|
|
26
|
+
prepareConductor,
|
|
27
|
+
previewProject,
|
|
28
|
+
setPaused,
|
|
29
|
+
type QueuePreview,
|
|
30
|
+
} from "./daemon.ts";
|
|
31
|
+
import { armTicks, telegramStateDir } from "./fleet.ts";
|
|
32
|
+
import { restartDaemon } from "./lifecycle.ts";
|
|
33
|
+
import { defaultGraphRoot, graphRepos } from "./graph.ts";
|
|
34
|
+
import {
|
|
35
|
+
formatHostRuntimePlan,
|
|
36
|
+
planHostRuntime,
|
|
37
|
+
totalConfiguredWorkers,
|
|
38
|
+
runSetupSmoke,
|
|
39
|
+
SYSTEMD_UNIT_DIR,
|
|
40
|
+
writeHostRuntime,
|
|
41
|
+
} from "./setup-host.ts";
|
|
42
|
+
import { runGraphInstall, runHostInstall } from "./setup-install.ts";
|
|
43
|
+
import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts";
|
|
44
|
+
import {
|
|
45
|
+
AMEND_AREAS,
|
|
46
|
+
BASE_FRESHNESS_CHOICES,
|
|
47
|
+
BEHIND_BASE_CHOICES,
|
|
48
|
+
DRAFT_POLICY_CHOICES,
|
|
49
|
+
ORCHESTRATOR_BRIEF_NAME,
|
|
50
|
+
POLICY_BRIEF_NAME,
|
|
51
|
+
RELEASE_REQUIREMENT_CHOICES,
|
|
52
|
+
REPORT_SCOPE_CHOICES,
|
|
53
|
+
SETUP_DEFAULTS,
|
|
54
|
+
amendChoices,
|
|
55
|
+
answersFromProject,
|
|
56
|
+
briefPathForProject,
|
|
57
|
+
policyPathForProject,
|
|
58
|
+
buildConfig,
|
|
59
|
+
checkTokenScopes,
|
|
60
|
+
createMissingLabels,
|
|
61
|
+
defaultAnswers,
|
|
62
|
+
detectTelegram,
|
|
63
|
+
formatGates,
|
|
64
|
+
orchestratorBriefPath,
|
|
65
|
+
planLabels,
|
|
66
|
+
summariseAmend,
|
|
67
|
+
summarisePlan,
|
|
68
|
+
writeOrchestratorBrief,
|
|
69
|
+
type AmendAreaId,
|
|
70
|
+
type OperatorJudgment,
|
|
71
|
+
type SetupAnswers,
|
|
72
|
+
type ProbedProse,
|
|
73
|
+
} from "./setup.ts";
|
|
74
|
+
import {
|
|
75
|
+
BASE_FRESHNESS,
|
|
76
|
+
BEHIND_BASE_ACTIONS,
|
|
77
|
+
DEFAULT_CAPS,
|
|
78
|
+
DRAFT_POLICIES,
|
|
79
|
+
RELEASE_REQUIREMENTS,
|
|
80
|
+
INTERRUPT_CATEGORIES,
|
|
81
|
+
RELEASE_SHAPES,
|
|
82
|
+
WEEKDAYS,
|
|
83
|
+
type Caps,
|
|
84
|
+
type ConductorConfig,
|
|
85
|
+
type OrchestratorMode,
|
|
86
|
+
type InterruptCategory,
|
|
87
|
+
type Weekday,
|
|
88
|
+
type ProjectConfig,
|
|
89
|
+
type ProjectPolicy,
|
|
90
|
+
type ReleaseRequirement,
|
|
91
|
+
type ReportScopeChoice,
|
|
92
|
+
type ResolvedGrants,
|
|
93
|
+
} from "./types.ts";
|
|
94
|
+
import type { WizardUi } from "./wizard-ui.ts";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Dismissing any dialog abandons the whole wizard.
|
|
98
|
+
*
|
|
99
|
+
* Thrown rather than returned as a sentinel: the prompt sequence runs to a
|
|
100
|
+
* dozen questions, and a cancellation check after each one would bury the
|
|
101
|
+
* shape of the conversation under branching.
|
|
102
|
+
*/
|
|
103
|
+
class Cancelled extends Error {
|
|
104
|
+
constructor() {
|
|
105
|
+
super("setup cancelled");
|
|
106
|
+
this.name = "Cancelled";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The spelling `config.ts` validates tracker repos against. Checked here too
|
|
111
|
+
* so a typo is fixed in the dialog instead of in an error an hour later. */
|
|
112
|
+
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One text answer. The placeholder shows the default and an empty submission
|
|
116
|
+
* takes it, because the harness has no pre-filled input dialog — so "Enter
|
|
117
|
+
* accepts what you see" is the contract the whole wizard is built on.
|
|
118
|
+
*/
|
|
119
|
+
async function ask(ui: WizardUi, title: string, fallback: string): Promise<string> {
|
|
120
|
+
const raw = await ui.input(title, fallback.length > 0 ? fallback : undefined);
|
|
121
|
+
if (raw === undefined) throw new Cancelled();
|
|
122
|
+
const trimmed = raw.trim();
|
|
123
|
+
return trimmed.length > 0 ? trimmed : fallback;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One yes/no answer, with a dismissal treated as leaving rather than as "no".
|
|
128
|
+
*
|
|
129
|
+
* Every confirm in this wizard defaults to no, so "no" and "dismissed" look
|
|
130
|
+
* identical if the surface collapses them — and then Ctrl-C at a yes/no prompt
|
|
131
|
+
* recorded a silent no and walked on to the next question, which is not what
|
|
132
|
+
* abandoning a run means. `undefined` is the surface saying the operator left.
|
|
133
|
+
*/
|
|
134
|
+
async function askYesNo(ui: WizardUi, title: string, message: string): Promise<boolean> {
|
|
135
|
+
const answer = await ui.confirm(title, message);
|
|
136
|
+
if (answer === undefined) throw new Cancelled();
|
|
137
|
+
return answer;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Re-asks until the answer passes `check`, which returns the complaint or
|
|
142
|
+
* `undefined`. Bounded at three tries: a dialog that cannot be escaped is worse
|
|
143
|
+
* than one that gives up and leaves the config alone.
|
|
144
|
+
*/
|
|
145
|
+
async function askValid(
|
|
146
|
+
ui: WizardUi,
|
|
147
|
+
title: string,
|
|
148
|
+
fallback: string,
|
|
149
|
+
check: (value: string) => string | undefined,
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
152
|
+
const value = await ask(ui, title, fallback);
|
|
153
|
+
const problem = check(value);
|
|
154
|
+
if (problem === undefined) return value;
|
|
155
|
+
ui.notify(problem, "warning");
|
|
156
|
+
}
|
|
157
|
+
throw new Cancelled();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
/** A cap. Unparseable input keeps the current value rather than writing a NaN
|
|
162
|
+
* the validator would later reject — the operator sees why, immediately. */
|
|
163
|
+
async function askNumber(ui: WizardUi, title: string, fallback: number): Promise<number> {
|
|
164
|
+
const raw = await ask(ui, title, String(fallback));
|
|
165
|
+
const value = Number(raw);
|
|
166
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
167
|
+
ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning");
|
|
168
|
+
return fallback;
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Daily spend ceiling. Blank / "none" / "off" → null (no gate). Unparseable
|
|
175
|
+
* non-empty input keeps the current value. Distinct from askNumber so operators
|
|
176
|
+
* can turn the money brake off without writing a magic 0 (which is a hard stop).
|
|
177
|
+
*/
|
|
178
|
+
async function askSpendCap(
|
|
179
|
+
ui: WizardUi,
|
|
180
|
+
title: string,
|
|
181
|
+
fallback: number | null,
|
|
182
|
+
): Promise<number | null> {
|
|
183
|
+
const seed = fallback === null ? "" : String(fallback);
|
|
184
|
+
const raw = (await ask(ui, title, seed)).trim().toLowerCase();
|
|
185
|
+
if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null;
|
|
186
|
+
const value = Number(raw);
|
|
187
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
188
|
+
ui.notify(
|
|
189
|
+
`"${raw}" is not a non-negative number or blank — keeping ${fallback === null ? "no cap" : fallback}.`,
|
|
190
|
+
"warning",
|
|
191
|
+
);
|
|
192
|
+
return fallback;
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
|
|
199
|
+
* `bun run check, bun test @ server`. Shown through `formatGates`, the same
|
|
200
|
+
* spelling the amend menu reads a repo's current gates back with.
|
|
201
|
+
*
|
|
202
|
+
* ponytail: the ceiling is a command containing a comma or a literal " @ ",
|
|
203
|
+
* which this would split wrongly. Rare in a lint or test invocation, and the
|
|
204
|
+
* config file is hand-editable. Upgrade path is `ui.editor()`, a real
|
|
205
|
+
* multi-line buffer, once someone hits it.
|
|
206
|
+
*/
|
|
207
|
+
async function askGates(
|
|
208
|
+
ui: WizardUi,
|
|
209
|
+
repoName: string,
|
|
210
|
+
seed: { cmd: string; cwd: string }[],
|
|
211
|
+
probed: { cmd: string; cwd: string }[] = [],
|
|
212
|
+
): Promise<{ cmd: string; cwd: string }[]> {
|
|
213
|
+
// A proposal fills the prompt only where the operator has not already decided.
|
|
214
|
+
// Overwriting gates they typed with a model's reading would be the one place a
|
|
215
|
+
// probe stopped being a proposal; `probeGates` has already shown them what it
|
|
216
|
+
// found, so it is on screen either way.
|
|
217
|
+
const raw = await ask(
|
|
218
|
+
ui,
|
|
219
|
+
`Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
|
|
220
|
+
formatGates(seed.length > 0 ? seed : probed),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const gates: { cmd: string; cwd: string }[] = [];
|
|
224
|
+
for (const chunk of raw.split(",")) {
|
|
225
|
+
const entry = chunk.trim();
|
|
226
|
+
if (entry.length === 0) continue;
|
|
227
|
+
const at = entry.lastIndexOf(" @ ");
|
|
228
|
+
if (at === -1) gates.push({ cmd: entry, cwd: "." });
|
|
229
|
+
else gates.push({ cmd: entry.slice(0, at).trim(), cwd: entry.slice(at + 3).trim() });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (gates.length === 0) {
|
|
233
|
+
// Loud, because an unattended push with no gate is how a lint failure
|
|
234
|
+
// reaches the runners at 03:00 with nobody watching.
|
|
235
|
+
ui.notify(`No gates for ${repoName} — nothing will be verified before a push.`, "warning");
|
|
236
|
+
}
|
|
237
|
+
return gates;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* How loud the orchestrator should be. A list rather than a confirm: "report
|
|
242
|
+
* scope" has no natural yes, and phrasing it as one would bury which answer
|
|
243
|
+
* means silence. The cursor starts on the current setting so Enter re-affirms
|
|
244
|
+
* it, the same contract every other prompt here has.
|
|
245
|
+
*/
|
|
246
|
+
async function askReportScope(ui: WizardUi, current: ReportScopeChoice): Promise<ReportScopeChoice> {
|
|
247
|
+
const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
|
|
248
|
+
const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
|
|
249
|
+
const picked = await ui.select("What should the orchestrator report unprompted?", options, {
|
|
250
|
+
initialIndex: at === -1 ? 0 : at,
|
|
251
|
+
});
|
|
252
|
+
if (picked === undefined) throw new Cancelled();
|
|
253
|
+
|
|
254
|
+
const choice = REPORT_SCOPE_CHOICES.find((c) => c.label === picked);
|
|
255
|
+
if (choice === undefined) {
|
|
256
|
+
// The harness answers with a label we did not offer only if the dialog
|
|
257
|
+
// contract changed under us; keeping the current scope is the answer that
|
|
258
|
+
// changes nothing, and it is said out loud rather than assumed.
|
|
259
|
+
ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
|
|
260
|
+
return current;
|
|
261
|
+
}
|
|
262
|
+
return choice.scope;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* One value out of a closed vocabulary, described in the operator's words.
|
|
267
|
+
*
|
|
268
|
+
* A select rather than a confirm, and the cursor starts on the configured value
|
|
269
|
+
* so Enter re-affirms it — the contract every prompt here has. It cannot be a
|
|
270
|
+
* confirm: a precondition's safe answer is "keep requiring it", and this
|
|
271
|
+
* harness's confirms always start on no, so a re-run that Entered through them
|
|
272
|
+
* would quietly relax the gate it was meant to leave alone.
|
|
273
|
+
*
|
|
274
|
+
* The label *is* the config value, so the answer the harness hands back needs no
|
|
275
|
+
* lookup table that could disagree with the vocabulary it was built from.
|
|
276
|
+
*/
|
|
277
|
+
async function askLiteral<T extends string>(
|
|
278
|
+
ui: WizardUi,
|
|
279
|
+
title: string,
|
|
280
|
+
values: readonly T[],
|
|
281
|
+
described: { readonly [K in T]: string },
|
|
282
|
+
current: T,
|
|
283
|
+
): Promise<T> {
|
|
284
|
+
const at = values.findIndex((v) => v === current);
|
|
285
|
+
const picked = await ui.select(
|
|
286
|
+
title,
|
|
287
|
+
values.map((v) => ({ label: v, description: described[v] })),
|
|
288
|
+
{ initialIndex: at === -1 ? 0 : at },
|
|
289
|
+
);
|
|
290
|
+
if (picked === undefined) throw new Cancelled();
|
|
291
|
+
|
|
292
|
+
const hit = values.find((v) => v === picked);
|
|
293
|
+
if (hit === undefined) {
|
|
294
|
+
// The harness answered with a label we never offered, which only happens if
|
|
295
|
+
// the dialog contract changed under us. Keeping the current value is the
|
|
296
|
+
// answer that changes nothing, and it is said out loud rather than assumed.
|
|
297
|
+
ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
|
|
298
|
+
return current;
|
|
299
|
+
}
|
|
300
|
+
return hit;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** How an empty list is both shown and typed. A word, because a blank line in
|
|
304
|
+
* this wizard means "accept what you see", not "clear it". */
|
|
305
|
+
const EMPTY_LIST = "none";
|
|
306
|
+
|
|
307
|
+
/** A name list as the prompt shows it and reads it back — one spelling, so the
|
|
308
|
+
* pre-filled default and the value it round-trips to cannot drift. */
|
|
309
|
+
function formatNameList(names: readonly string[]): string {
|
|
310
|
+
return names.length === 0 ? EMPTY_LIST : names.join(", ");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseNameList(answer: string): string[] {
|
|
314
|
+
if (answer.trim().toLowerCase() === EMPTY_LIST) return [];
|
|
315
|
+
return answer
|
|
316
|
+
.split(",")
|
|
317
|
+
.map((name) => name.trim())
|
|
318
|
+
.filter((name) => name.length > 0);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Check names, artefacts, environments: open-ended lists this package cannot
|
|
322
|
+
* enumerate, so the only validation is the shape. */
|
|
323
|
+
async function askNameList(ui: WizardUi, title: string, seed: readonly string[]): Promise<string[]> {
|
|
324
|
+
return parseNameList(await ask(ui, title, formatNameList(seed)));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The `requires` set, typed rather than picked one confirm at a time.
|
|
329
|
+
*
|
|
330
|
+
* Validated in the dialog against the same array the loader validates against,
|
|
331
|
+
* and the complaint names every accepted value — an operator who mistyped a
|
|
332
|
+
* requirement they believed they had set would otherwise find out from a release
|
|
333
|
+
* that went ahead without it.
|
|
334
|
+
*/
|
|
335
|
+
async function askReleaseRequirements(
|
|
336
|
+
ui: WizardUi,
|
|
337
|
+
prior: readonly ReleaseRequirement[],
|
|
338
|
+
): Promise<ReleaseRequirement[]> {
|
|
339
|
+
const accepted = RELEASE_REQUIREMENTS.join(", ");
|
|
340
|
+
// The vocabulary, spelled out where it is being asked for. Built from the same
|
|
341
|
+
// data the validator reads, so a fifth requirement is offered here the moment
|
|
342
|
+
// it exists rather than staying invisible to everyone who did not read #129.
|
|
343
|
+
ui.notify(
|
|
344
|
+
RELEASE_REQUIREMENTS.map((r) => `${r} — ${RELEASE_REQUIREMENT_CHOICES[r]}`).join("\n"),
|
|
345
|
+
"info",
|
|
346
|
+
);
|
|
347
|
+
const answered = await askValid(
|
|
348
|
+
ui,
|
|
349
|
+
`Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`,
|
|
350
|
+
formatNameList(prior),
|
|
351
|
+
(value) => {
|
|
352
|
+
const unknown = parseNameList(value).filter((name) => !RELEASE_REQUIREMENTS.some((r) => r === name));
|
|
353
|
+
return unknown.length === 0 ? undefined : `Not a release requirement: ${unknown.join(", ")}. Accepted: ${accepted}.`;
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const chosen = new Set(parseNameList(answered));
|
|
358
|
+
// The vocabulary's order, not the operator's: two fleets that require the same
|
|
359
|
+
// three things must read identically in the plan and in a refusal.
|
|
360
|
+
return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The gating conditions #126's verbs read (#129).
|
|
365
|
+
*
|
|
366
|
+
* Asked here rather than left to a hand-edit because the whole point of moving
|
|
367
|
+
* them out of POLICY.md is that they are config: a condition an operator can
|
|
368
|
+
* only reach by opening `config.json` is one that stays at its default while
|
|
369
|
+
* their prose says something else, which is the drift this key ended.
|
|
370
|
+
*/
|
|
371
|
+
async function askPolicyPreconditions(ui: WizardUi, prior: ProjectPolicy): Promise<ProjectPolicy> {
|
|
372
|
+
const merge = {
|
|
373
|
+
requiredChecks: await askNameList(
|
|
374
|
+
ui,
|
|
375
|
+
`Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`,
|
|
376
|
+
prior.merge.requiredChecks,
|
|
377
|
+
),
|
|
378
|
+
baseFreshness: await askLiteral(
|
|
379
|
+
ui,
|
|
380
|
+
"Merge — must the PR be level with its base?",
|
|
381
|
+
BASE_FRESHNESS,
|
|
382
|
+
BASE_FRESHNESS_CHOICES,
|
|
383
|
+
prior.merge.baseFreshness,
|
|
384
|
+
),
|
|
385
|
+
drafts: await askLiteral(
|
|
386
|
+
ui,
|
|
387
|
+
"Merge — draft pull requests",
|
|
388
|
+
DRAFT_POLICIES,
|
|
389
|
+
DRAFT_POLICY_CHOICES,
|
|
390
|
+
prior.merge.drafts,
|
|
391
|
+
),
|
|
392
|
+
whenBehindBase: await askLiteral(
|
|
393
|
+
ui,
|
|
394
|
+
"Merge — a green PR that fell behind its base",
|
|
395
|
+
BEHIND_BASE_ACTIONS,
|
|
396
|
+
BEHIND_BASE_CHOICES,
|
|
397
|
+
prior.merge.whenBehindBase,
|
|
398
|
+
),
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const release = {
|
|
402
|
+
requires: await askReleaseRequirements(ui, prior.release.requires),
|
|
403
|
+
requiredChecks: await askNameList(
|
|
404
|
+
ui,
|
|
405
|
+
`Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
|
|
406
|
+
prior.release.requiredChecks,
|
|
407
|
+
),
|
|
408
|
+
artefacts: await askNameList(
|
|
409
|
+
ui,
|
|
410
|
+
`Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
|
|
411
|
+
prior.release.artefacts,
|
|
412
|
+
),
|
|
413
|
+
environments: await askNameList(
|
|
414
|
+
ui,
|
|
415
|
+
`Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
|
|
416
|
+
prior.release.environments,
|
|
417
|
+
),
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return { merge, release };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Who merges and who releases. Two confirms rather than one four-way list:
|
|
425
|
+
* these are independent grants — delegating merges is routine, delegating
|
|
426
|
+
* releases is not — and a menu of four combinations frames them as equally
|
|
427
|
+
* ordinary choices, which is exactly the framing a release grant must not get.
|
|
428
|
+
*
|
|
429
|
+
* Neither confirm can start on "yes", so a re-run that Enters through the
|
|
430
|
+
* wizard revokes rather than renews. That is the safe direction, and the
|
|
431
|
+
* current grant is named in the question so the revoke is never a surprise.
|
|
432
|
+
*/
|
|
433
|
+
async function askAuthority(
|
|
434
|
+
ui: WizardUi,
|
|
435
|
+
prior: ProjectConfig["authority"],
|
|
436
|
+
): Promise<ProjectConfig["authority"]> {
|
|
437
|
+
const merge = await askYesNo(
|
|
438
|
+
ui,
|
|
439
|
+
"Merge authority",
|
|
440
|
+
"Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " +
|
|
441
|
+
"re-checked against the base branch first. Default: humans merge" +
|
|
442
|
+
`${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
443
|
+
);
|
|
444
|
+
const release = await askYesNo(
|
|
445
|
+
ui,
|
|
446
|
+
"Release authority",
|
|
447
|
+
"Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
|
|
448
|
+
"procedure you write into its brief — and its brief forbids cutting one before you have. " +
|
|
449
|
+
"Default: humans release" +
|
|
450
|
+
`${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
451
|
+
);
|
|
452
|
+
return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* What each shape means to the operator being asked about it, in their words
|
|
457
|
+
* rather than the classifier's. Declared as data over the closed enum so a sixth
|
|
458
|
+
* shape cannot be added without a question to ask about it — an unasked shape
|
|
459
|
+
* would silently take the deny default and read as a decision afterwards.
|
|
460
|
+
*
|
|
461
|
+
* No fleet vocabulary here on purpose (#122): every one of these is an act the
|
|
462
|
+
* package can recognise anywhere, not a step in one project's release topology.
|
|
463
|
+
*/
|
|
464
|
+
const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]: string } = {
|
|
465
|
+
"git-tag": "create git tags (`git tag v1.2.3`)",
|
|
466
|
+
"git-push-tags": "push tags to the remote (`git push --follow-tags`)",
|
|
467
|
+
"package-publish": "publish packages (`npm publish` and equivalents)",
|
|
468
|
+
"github-release": "create GitHub releases (`gh release create`)",
|
|
469
|
+
deploy:
|
|
470
|
+
"deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
|
|
471
|
+
"This is the one grant that mutates a live environment rather than producing an artifact",
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The questions no probe can answer, because the answers are not in the repo.
|
|
476
|
+
*
|
|
477
|
+
* Ported from the onboarding skill, which is explicit about why each one has to
|
|
478
|
+
* be asked. Today's wizard asks none of them: `askAuthority` and
|
|
479
|
+
* `askReleaseGrants` record *who holds* each release shape and never the prose
|
|
480
|
+
* that makes a delegated release safe — and `src/briefs/policy.md` ships stubs
|
|
481
|
+
* sitting empty because of it.
|
|
482
|
+
*
|
|
483
|
+
* Asked before any prose probe runs, so intent always precedes machinery: a
|
|
484
|
+
* probe's job is to turn these words into the repo's own steps, never to invent
|
|
485
|
+
* what the operator wants.
|
|
486
|
+
*/
|
|
487
|
+
async function askJudgment(
|
|
488
|
+
ui: WizardUi,
|
|
489
|
+
grants: ResolvedGrants,
|
|
490
|
+
prior: OperatorJudgment,
|
|
491
|
+
): Promise<OperatorJudgment> {
|
|
492
|
+
// Always asked. A tracker shows what is open, never what matters, and an
|
|
493
|
+
// orchestrator that cannot rank work grooms by recency — which is how a stale
|
|
494
|
+
// issue outranks the thing being shipped this month.
|
|
495
|
+
const roadmap = await ask(
|
|
496
|
+
ui,
|
|
497
|
+
"Where does the roadmap live, and what is the current priority?",
|
|
498
|
+
prior.roadmap ?? "",
|
|
499
|
+
);
|
|
500
|
+
const judgment: OperatorJudgment = { ...prior, ...(roadmap.length === 0 ? {} : { roadmap }) };
|
|
501
|
+
|
|
502
|
+
const delegated = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator");
|
|
503
|
+
if (delegated.length === 0) {
|
|
504
|
+
// Humans release: there is no boundary to draw and no procedure to write, so
|
|
505
|
+
// asking would invite prose the config does not grant.
|
|
506
|
+
return judgment;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (delegated.length === RELEASE_SHAPES.length) {
|
|
510
|
+
// Full release. Push back once, concretely, then record what they decide —
|
|
511
|
+
// it is their fleet. Shown once, never per shape.
|
|
512
|
+
ui.notify(
|
|
513
|
+
[
|
|
514
|
+
"You have granted the orchestrator every release shape. Two things to weigh:",
|
|
515
|
+
"",
|
|
516
|
+
" Credentials. Full release means the session holds publish tokens, registry",
|
|
517
|
+
" credentials or deploy keys. Those sit in the environment of a session that runs",
|
|
518
|
+
" unattended for weeks — including a turn that went wrong.",
|
|
519
|
+
"",
|
|
520
|
+
" Rollback. An agent that can release owns the 03:00 rollback too, and a rollback",
|
|
521
|
+
" is a judgement call under time pressure with partial information: the exact",
|
|
522
|
+
" thing agents are worst at.",
|
|
523
|
+
"",
|
|
524
|
+
"Answer the next questions anyway if that is the fleet you want.",
|
|
525
|
+
].join("\n"),
|
|
526
|
+
"warning",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Not "can it release" — *where does its leg stop*. A boundary that cannot be
|
|
531
|
+
// said in one sentence is not a boundary, and a vague release mandate is what
|
|
532
|
+
// eventually publishes something at 03:00. The worked example is the skill's,
|
|
533
|
+
// so an operator has something to calibrate against.
|
|
534
|
+
judgment.boundary = await ask(
|
|
535
|
+
ui,
|
|
536
|
+
"Where does the orchestrator's leg END? One sentence " +
|
|
537
|
+
'(a real answer: "at the merged version pin — deploying it is operator territory")',
|
|
538
|
+
prior.boundary ?? "",
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
// All five, because the brief needs each and a missing one is a hole.
|
|
542
|
+
judgment.releaseWhat = await ask(ui, "Release — WHAT may be released, and from which branch?", prior.releaseWhat ?? "");
|
|
543
|
+
judgment.releaseWhen = await ask(
|
|
544
|
+
ui,
|
|
545
|
+
"Release — WHEN: batched how, after which named checks are green?",
|
|
546
|
+
prior.releaseWhen ?? "",
|
|
547
|
+
);
|
|
548
|
+
judgment.releaseProof = await ask(
|
|
549
|
+
ui,
|
|
550
|
+
"Release — WHAT PROOF must be held first (results actually read, not an impression)?",
|
|
551
|
+
prior.releaseProof ?? "",
|
|
552
|
+
);
|
|
553
|
+
judgment.releaseAsk = await ask(ui, "Release — what must still be ASKED, every time?", prior.releaseAsk ?? "");
|
|
554
|
+
judgment.releaseForbidden = await ask(
|
|
555
|
+
ui,
|
|
556
|
+
"Release — what stays permanently FORBIDDEN?",
|
|
557
|
+
prior.releaseForbidden ?? "force-push, secrets, production data",
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
// The batching unit, in the operator's own vocabulary. Without it the
|
|
561
|
+
// orchestrator either releases per merge — a stream of meaningless versions
|
|
562
|
+
// burning shared runners — or never releases at all. Distinct from
|
|
563
|
+
// `RELEASE_REQUIREMENTS`, which are mechanical preconditions, not a unit.
|
|
564
|
+
judgment.worthCutting = await ask(
|
|
565
|
+
ui,
|
|
566
|
+
"What is a release worth cutting? (a sprint, an epic's children all closed, N merged issues, urgency)",
|
|
567
|
+
prior.worthCutting ?? "",
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
judgment.rollbackOwner = await ask(ui, "Who owns the rollback?", prior.rollbackOwner ?? "");
|
|
571
|
+
if (namesAPerson(judgment.rollbackOwner)) {
|
|
572
|
+
// Honour the consequence rather than recording a contradiction: if a person
|
|
573
|
+
// rolls it back, that person owns the release, and the boundary belongs
|
|
574
|
+
// before the irreversible step whatever the authority answer sounded like.
|
|
575
|
+
judgment.rollbackMovesBoundary = await askYesNo(
|
|
576
|
+
ui,
|
|
577
|
+
"Rollback owner is a person",
|
|
578
|
+
`You named "${judgment.rollbackOwner}" as the rollback owner. That person already owns the release, ` +
|
|
579
|
+
"so the honest configuration puts the orchestrator's boundary *before* the irreversible step — " +
|
|
580
|
+
"it prepares and verifies, a human performs it. Record the boundary that way?",
|
|
581
|
+
);
|
|
582
|
+
if (judgment.rollbackMovesBoundary) {
|
|
583
|
+
judgment.boundary = await ask(
|
|
584
|
+
ui,
|
|
585
|
+
"Restate the boundary, ending before the irreversible step",
|
|
586
|
+
judgment.boundary ?? "",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return judgment;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Whether a rollback answer names a human rather than a mechanism.
|
|
595
|
+
*
|
|
596
|
+
* Deliberately crude: the point is to *raise* the consequence for the operator to
|
|
597
|
+
* accept or decline, and a false positive costs one extra question while a false
|
|
598
|
+
* negative silently records the contradiction the skill warns about.
|
|
599
|
+
*/
|
|
600
|
+
function namesAPerson(answer: string): boolean {
|
|
601
|
+
const mechanical = ["ci", "workflow", "pipeline", "automation", "the agent", "orchestrator", "nobody", "none", "n/a"];
|
|
602
|
+
const trimmed = answer.trim().toLowerCase();
|
|
603
|
+
if (trimmed.length === 0) return false;
|
|
604
|
+
return !mechanical.some((m) => trimmed === m || trimmed.startsWith(`${m} `));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The mechanical tool gate, one confirm per shape.
|
|
609
|
+
*
|
|
610
|
+
* One binary question used to cover all five, which is how #122 happened: an
|
|
611
|
+
* operator who meant "it may cut a release" also granted "it may deploy to
|
|
612
|
+
* production", because there was one switch for both. Asking five times is the
|
|
613
|
+
* point — each answer is a different blast radius.
|
|
614
|
+
*
|
|
615
|
+
* No confirm can start on "yes", so a re-run that Enters through the wizard
|
|
616
|
+
* revokes rather than renews. The current grant is named in the question, so
|
|
617
|
+
* that revoke is never a surprise.
|
|
618
|
+
*/
|
|
619
|
+
async function askReleaseGrants(
|
|
620
|
+
ui: WizardUi,
|
|
621
|
+
prior: ResolvedGrants,
|
|
622
|
+
): Promise<ResolvedGrants> {
|
|
623
|
+
const grants = { ...prior };
|
|
624
|
+
for (const shape of RELEASE_SHAPES) {
|
|
625
|
+
const open = await askYesNo(
|
|
626
|
+
ui,
|
|
627
|
+
`Release tool gate — ${shape}`,
|
|
628
|
+
`Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` +
|
|
629
|
+
"operator brief carries the procedure it must follow. A worker session is refused this " +
|
|
630
|
+
"whatever you answer. Default: no" +
|
|
631
|
+
`${prior[shape] === "orchestrator" ? " — currently granted, answer no to take it back" : ""}.`,
|
|
632
|
+
);
|
|
633
|
+
grants[shape] = open ? "orchestrator" : "human";
|
|
634
|
+
}
|
|
635
|
+
return grants;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Where the session that triages escalations lives. Phrased as a fact about the
|
|
640
|
+
* host rather than a preference, because that is what it is: answering yes when
|
|
641
|
+
* no such session exists leaves tier-1 escalations sitting in issue comments
|
|
642
|
+
* that nobody drains.
|
|
643
|
+
*/
|
|
644
|
+
async function askOrchestratorMode(ui: WizardUi, prior: OrchestratorMode): Promise<OrchestratorMode> {
|
|
645
|
+
const external = await askYesNo(
|
|
646
|
+
ui,
|
|
647
|
+
"Orchestrator session",
|
|
648
|
+
"Do you already run your own orchestrator session for this project — a visible TUI session, say? " +
|
|
649
|
+
"Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " +
|
|
650
|
+
"to drain. Default: no, the daemon runs one" +
|
|
651
|
+
`${prior === "external" ? " — currently external" : ""}.`,
|
|
652
|
+
);
|
|
653
|
+
return external ? "external" : "embedded";
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Whether workers get a code graph, and where its clones live.
|
|
658
|
+
*
|
|
659
|
+
* One confirm and at most one prompt, asked after the repos are known because
|
|
660
|
+
* the answer is derived per repo. A declined answer leaves the field off every
|
|
661
|
+
* repo, which is what keeps an existing fleet's briefs byte-identical.
|
|
662
|
+
*
|
|
663
|
+
* The root is validated as absolute here rather than at load time so the
|
|
664
|
+
* operator learns immediately: a relative path would be resolved against
|
|
665
|
+
* whichever cwd happened to read the config, and never against the directory
|
|
666
|
+
* that was indexed.
|
|
667
|
+
*/
|
|
668
|
+
async function askGraphRoot(
|
|
669
|
+
ui: WizardUi,
|
|
670
|
+
trackerRepo: string,
|
|
671
|
+
repoNames: string[],
|
|
672
|
+
prior: string | undefined,
|
|
673
|
+
): Promise<string | undefined> {
|
|
674
|
+
const wanted = await askYesNo(
|
|
675
|
+
ui,
|
|
676
|
+
"Code-graph discovery",
|
|
677
|
+
"Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
|
|
678
|
+
'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
|
|
679
|
+
"pinned to the default branch purely for indexing — never your own checkout" +
|
|
680
|
+
`${prior === undefined ? "" : `. Currently on, under ${prior}`}.`,
|
|
681
|
+
);
|
|
682
|
+
if (!wanted) return undefined;
|
|
683
|
+
|
|
684
|
+
return await askValid(
|
|
685
|
+
ui,
|
|
686
|
+
`Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
|
|
687
|
+
prior ?? defaultGraphRoot(trackerRepo),
|
|
688
|
+
(v) =>
|
|
689
|
+
isAbsolute(expandHome(v))
|
|
690
|
+
? undefined
|
|
691
|
+
: `"${v}" is not an absolute path — a worker reads this from its own worktree, so a relative one names the wrong directory.`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Whether to render the operator's own brief, and — separately — whether an
|
|
697
|
+
* existing one may be replaced. Two questions on purpose: that file is where a
|
|
698
|
+
* fleet's release and reporting policy ends up, so it is never overwritten by
|
|
699
|
+
* an operator who only meant to re-run setup.
|
|
700
|
+
*/
|
|
701
|
+
async function askOrchestratorBrief(ui: WizardUi, a: SetupAnswers): Promise<boolean> {
|
|
702
|
+
const path = orchestratorBriefPath(a);
|
|
703
|
+
const wanted = await askYesNo(
|
|
704
|
+
ui,
|
|
705
|
+
`Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`,
|
|
706
|
+
`Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` +
|
|
707
|
+
`(Releases, Project context, Reporting, Amendments — yours to edit via the Learning loop). ` +
|
|
708
|
+
`The conductor stops at green PRs either way.`,
|
|
709
|
+
);
|
|
710
|
+
if (!wanted) return false;
|
|
711
|
+
if (!existsSync(path)) return true;
|
|
712
|
+
|
|
713
|
+
return await askYesNo(
|
|
714
|
+
ui,
|
|
715
|
+
`Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`,
|
|
716
|
+
`${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The project these answers would replace, so a re-run pre-fills with what is
|
|
721
|
+
* already there instead of making the operator retype it. */
|
|
722
|
+
function priorProject(existing: ConductorConfig | undefined, name: string | undefined): ProjectConfig | undefined {
|
|
723
|
+
if (existing === undefined) return undefined;
|
|
724
|
+
if (name !== undefined) return existing.projects.find((p) => p.name === name);
|
|
725
|
+
return existing.projects.length === 1 ? existing.projects[0] : undefined;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* One area's questions, over the answers everything else is carried through in.
|
|
730
|
+
*
|
|
731
|
+
* Every asker takes the whole answer set and returns the whole answer set with
|
|
732
|
+
* only its own fields replaced. That is what lets the full interview fold them in
|
|
733
|
+
* order while an amend applies exactly one, with no second spelling of either the
|
|
734
|
+
* prompts or the defaults they pre-fill from: the value shown is always the value
|
|
735
|
+
* that would otherwise be carried through.
|
|
736
|
+
*/
|
|
737
|
+
type AreaAsker = (ui: WizardUi, a: SetupAnswers, probes: SetupProbes) => Promise<SetupAnswers>;
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* The repo-reading half of onboarding, injected rather than called directly.
|
|
741
|
+
*
|
|
742
|
+
* One seam, for three reasons: `--no-ai` becomes a value instead of a branch at
|
|
743
|
+
* every call site; the wizard's own tests keep running with no model and no clone;
|
|
744
|
+
* and a probe stays visibly optional — every asker below still works when it
|
|
745
|
+
* returns nothing, which is the contract that lets setup survive an unreachable
|
|
746
|
+
* peer, a private repo, or a model that answered in prose.
|
|
747
|
+
*/
|
|
748
|
+
export interface SetupProbes {
|
|
749
|
+
/** Proposes a repo's pre-push gates by reading its CI. `[]` seeds nothing. */
|
|
750
|
+
gates(ui: WizardUi, target: ProbeTarget): Promise<ProbedGate[]>;
|
|
751
|
+
/**
|
|
752
|
+
* Drafts the brief's prose halves, each previewed and confirmed before it is
|
|
753
|
+
* kept. `undefined` — declined, unavailable, `--no-ai` — keeps the shipped stub.
|
|
754
|
+
*/
|
|
755
|
+
prose(ui: WizardUi, a: SetupAnswers): Promise<ProbedProse>;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Reads each repo to propose answers. */
|
|
759
|
+
export const DEFAULT_PROBES: SetupProbes = {
|
|
760
|
+
gates: (ui, target) => probeGates(ui, target),
|
|
761
|
+
prose: (ui, a) => proseFromRepos(ui, a),
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
/** `--no-ai`, and every wizard test: nothing is read, nothing is proposed. */
|
|
765
|
+
export const NO_PROBES: SetupProbes = { gates: async () => [], prose: async () => ({}) };
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The brief's two prose halves, drafted against **every** configured repo.
|
|
769
|
+
*
|
|
770
|
+
* All of them, in one workspace, because both answers are cross-repo by nature and
|
|
771
|
+
* the skill this ports said so: "read, per routing repo" for the context, "per repo
|
|
772
|
+
* that can be released" for the procedure. A draft from one repo could not say
|
|
773
|
+
* which repo owns which concern, which repos ship together, or where the release
|
|
774
|
+
* machinery actually lives — it would have to guess, and a guess in a file the
|
|
775
|
+
* orchestrator re-reads every tick is an instruction it follows all week.
|
|
776
|
+
*
|
|
777
|
+
* One probe per section rather than per repo: `POLICY.md` has one Project context
|
|
778
|
+
* and one Releases procedure, and N drafts would have to be merged by hand.
|
|
779
|
+
*/
|
|
780
|
+
async function proseFromRepos(ui: WizardUi, a: SetupAnswers): Promise<ProbedProse> {
|
|
781
|
+
if (a.targetRepos.length === 0) return {};
|
|
782
|
+
const targets: ProbeTarget[] = a.targetRepos.map((r) => ({
|
|
783
|
+
name: r.name,
|
|
784
|
+
cloneUrl: r.cloneUrl,
|
|
785
|
+
defaultBranch: r.defaultBranch,
|
|
786
|
+
}));
|
|
787
|
+
const j = a.judgment ?? {};
|
|
788
|
+
|
|
789
|
+
const context = await probeProse(ui, "project-context", "the project context", targets, {
|
|
790
|
+
REPOS: probeRepoMap(targets, a.routingLabelPrefix),
|
|
791
|
+
ROADMAP: j.roadmap ?? "",
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Only when the operator actually delegated a release: with no stated intent
|
|
795
|
+
// there is nothing for the probe to express, and a procedure drafted from a
|
|
796
|
+
// repo's machinery alone is the invention the template forbids.
|
|
797
|
+
const delegated = [j.releaseWhat, j.releaseWhen, j.boundary].some(
|
|
798
|
+
(v) => v !== undefined && v.trim().length > 0,
|
|
799
|
+
);
|
|
800
|
+
const procedure = delegated
|
|
801
|
+
? await probeProse(ui, "release-procedure", "the release procedure", targets, {
|
|
802
|
+
// Without this the probe cannot follow its own instruction to name repos by
|
|
803
|
+
// routing key: the checkouts are generated `repo-N` directories, and an
|
|
804
|
+
// arbitrary key is not recoverable from one.
|
|
805
|
+
REPOS: probeRepoMap(targets, a.routingLabelPrefix),
|
|
806
|
+
BOUNDARY: j.boundary ?? "",
|
|
807
|
+
WHAT: j.releaseWhat ?? "",
|
|
808
|
+
WHEN: j.releaseWhen ?? "",
|
|
809
|
+
PROOF: j.releaseProof ?? "",
|
|
810
|
+
ASK: j.releaseAsk ?? "",
|
|
811
|
+
FORBIDDEN: j.releaseForbidden ?? "",
|
|
812
|
+
WORTH_CUTTING: j.worthCutting ?? "",
|
|
813
|
+
ROLLBACK_OWNER: j.rollbackOwner ?? "",
|
|
814
|
+
})
|
|
815
|
+
: undefined;
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
...(context === undefined ? {} : { projectContext: context }),
|
|
819
|
+
...(procedure === undefined ? {} : { releaseProcedure: procedure }),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Where work comes from and where it lands: tracker, labels, routing prefix, and
|
|
825
|
+
* every repo an issue can be routed to, each with its gates. One area because it
|
|
826
|
+
* is one fact — the identity of the queue — and changing any part of it without
|
|
827
|
+
* seeing the rest is how a routing prefix stops matching its labels.
|
|
828
|
+
*/
|
|
829
|
+
const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
|
|
830
|
+
const trackerRepo = await askValid(
|
|
831
|
+
ui,
|
|
832
|
+
"Tracker repo (owner/repo) — where ready issues live",
|
|
833
|
+
a.trackerRepo,
|
|
834
|
+
(v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const queueLabel = await ask(
|
|
838
|
+
ui,
|
|
839
|
+
"Queue label — the human sign-off that makes an issue claimable",
|
|
840
|
+
a.queueLabel,
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
// One confirm instead of three prompts: the namespaced defaults are right for
|
|
844
|
+
// almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
|
|
845
|
+
// its reputation.
|
|
846
|
+
const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
|
|
847
|
+
const customiseStates = await askYesNo(
|
|
848
|
+
ui,
|
|
849
|
+
"State labels",
|
|
850
|
+
`The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
|
|
851
|
+
`"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
|
|
852
|
+
);
|
|
853
|
+
if (customiseStates) {
|
|
854
|
+
stateLabels.inProgress = await ask(ui, "Label for a run in progress", stateLabels.inProgress);
|
|
855
|
+
stateLabels.blocked = await ask(ui, "Label for a run parked on a human", stateLabels.blocked);
|
|
856
|
+
stateLabels.failed = await ask(ui, "Label for a run that gave up", stateLabels.failed);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const routingLabelPrefix = await ask(
|
|
860
|
+
ui,
|
|
861
|
+
"Routing label prefix — an issue picks its checkout with <prefix><repo>",
|
|
862
|
+
a.routingLabelPrefix,
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
const targetRepos: SetupAnswers["targetRepos"] = [];
|
|
866
|
+
for (let i = 0; ; i++) {
|
|
867
|
+
const seed = a.targetRepos[i];
|
|
868
|
+
const name = await askValid(
|
|
869
|
+
ui,
|
|
870
|
+
`Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
|
|
871
|
+
seed?.name ?? "",
|
|
872
|
+
(v) => (v.length > 0 ? undefined : "A routing key is required, or no issue can reach this repo."),
|
|
873
|
+
);
|
|
874
|
+
const cloneUrl = await askValid(
|
|
875
|
+
ui,
|
|
876
|
+
`Clone URL for ${routingLabelPrefix}${name}`,
|
|
877
|
+
seed?.cloneUrl ?? "",
|
|
878
|
+
(v) => (v.length > 0 ? undefined : "A clone URL is required — the daemon mirrors it before every run."),
|
|
879
|
+
);
|
|
880
|
+
const defaultBranch = await ask(
|
|
881
|
+
ui,
|
|
882
|
+
`Default branch for ${name} — worktrees are cut from it and PRs target it`,
|
|
883
|
+
seed?.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
|
|
884
|
+
);
|
|
885
|
+
// Read before the prompt, so what the probe found is on screen while the
|
|
886
|
+
// operator reads the default. Skipped when this repo already has gates.
|
|
887
|
+
const existingGates = seed?.gates ?? [];
|
|
888
|
+
const probed = existingGates.length > 0 ? [] : await probes.gates(ui, { name, cloneUrl, defaultBranch });
|
|
889
|
+
targetRepos.push({ name, cloneUrl, defaultBranch, gates: await askGates(ui, name, existingGates, probed) });
|
|
890
|
+
|
|
891
|
+
const more = await askYesNo(
|
|
892
|
+
ui,
|
|
893
|
+
"Another repo?",
|
|
894
|
+
`${targetRepos.map((r) => r.name).join(", ")} configured. Add another checkout this project routes to?`,
|
|
895
|
+
);
|
|
896
|
+
if (!more) break;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return { ...a, trackerRepo, queueLabel, stateLabels, routingLabelPrefix, targetRepos };
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* The gates alone, repo by repo, with nothing else asked.
|
|
904
|
+
*
|
|
905
|
+
* The area that earns amend mode: a CI command changes far more often than a
|
|
906
|
+
* clone URL does, and re-typing four repos to correct one lint invocation is the
|
|
907
|
+
* reason an operator edits config.json by hand instead.
|
|
908
|
+
*/
|
|
909
|
+
const askGatesOnly: AreaAsker = async (ui, a, probes) => {
|
|
910
|
+
if (a.targetRepos.length === 0) {
|
|
911
|
+
ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning");
|
|
912
|
+
return a;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const targetRepos: SetupAnswers["targetRepos"] = [];
|
|
916
|
+
for (const r of a.targetRepos) {
|
|
917
|
+
// Re-read even though gates exist: this is the area an operator opens *because*
|
|
918
|
+
// CI changed, so a fresh proposal is the whole reason they came. Their current
|
|
919
|
+
// gates stay the default; the proposal is shown beside it.
|
|
920
|
+
const probed = await probes.gates(ui, { name: r.name, cloneUrl: r.cloneUrl, defaultBranch: r.defaultBranch });
|
|
921
|
+
targetRepos.push({ ...r, gates: await askGates(ui, r.name, r.gates, probed) });
|
|
922
|
+
}
|
|
923
|
+
return { ...a, targetRepos };
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Whether workers get a code graph, and where its clones live. Asked after the
|
|
928
|
+
* repos in the full interview because the answer is derived per repo.
|
|
929
|
+
*/
|
|
930
|
+
const askGraph: AreaAsker = async (ui, a) => {
|
|
931
|
+
const graphRoot = await askGraphRoot(
|
|
932
|
+
ui,
|
|
933
|
+
a.trackerRepo,
|
|
934
|
+
a.targetRepos.map((r) => r.name),
|
|
935
|
+
a.graphRoot,
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
const next: SetupAnswers = { ...a };
|
|
939
|
+
// Deleted rather than set to `undefined`: the absence of the key is what keeps
|
|
940
|
+
// a project that declines graphs identical to one written before they existed.
|
|
941
|
+
if (graphRoot === undefined) delete next.graphRoot;
|
|
942
|
+
else next.graphRoot = graphRoot;
|
|
943
|
+
return next;
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
/** The hard ceilings. One confirm first, because the shipped defaults are the
|
|
947
|
+
* answer for anyone who has not measured their own runners. */
|
|
948
|
+
const askCaps: AreaAsker = async (ui, a) => {
|
|
949
|
+
const caps: Partial<Caps> = { ...a.caps };
|
|
950
|
+
const spendLabel =
|
|
951
|
+
DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`;
|
|
952
|
+
// Workers are in-process omp sessions inside the daemon PID. On a host under
|
|
953
|
+
// 16 GiB the measured shape (2 workers + orchestrator) peaks at 3–4 GB and
|
|
954
|
+
// swaps on a 7.6 GB shared VPS (#51) — setup therefore defaults to 1 there.
|
|
955
|
+
const workersDefault =
|
|
956
|
+
caps.maxConcurrentWorkers ?? recommendedMaxWorkers(hostRamBytes());
|
|
957
|
+
const smallHostNote =
|
|
958
|
+
workersDefault < DEFAULT_CAPS.maxConcurrentWorkers
|
|
959
|
+
? ` This host looks under 16 GiB RAM, so the worker default is ${workersDefault} instead of ${DEFAULT_CAPS.maxConcurrentWorkers}.`
|
|
960
|
+
: "";
|
|
961
|
+
const tuneCaps = await askYesNo(
|
|
962
|
+
ui,
|
|
963
|
+
"Caps",
|
|
964
|
+
`Defaults: ${workersDefault} workers, ` +
|
|
965
|
+
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
|
|
966
|
+
`${DEFAULT_CAPS.workerMaxTurnsCeiling} max turns and ` +
|
|
967
|
+
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
968
|
+
`${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
|
|
969
|
+
`${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
|
|
970
|
+
);
|
|
971
|
+
if (!tuneCaps) {
|
|
972
|
+
if (
|
|
973
|
+
caps.maxConcurrentWorkers === undefined &&
|
|
974
|
+
workersDefault !== DEFAULT_CAPS.maxConcurrentWorkers
|
|
975
|
+
) {
|
|
976
|
+
caps.maxConcurrentWorkers = workersDefault;
|
|
977
|
+
}
|
|
978
|
+
return { ...a, caps };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Spelled out rather than looped: adding a cap should fail to compile here,
|
|
982
|
+
// not silently go unasked.
|
|
983
|
+
caps.maxConcurrentWorkers = await askNumber(
|
|
984
|
+
ui,
|
|
985
|
+
"Max concurrent workers",
|
|
986
|
+
workersDefault,
|
|
987
|
+
);
|
|
988
|
+
caps.dailySpendUsd = await askSpendCap(
|
|
989
|
+
ui,
|
|
990
|
+
"Spend ceiling per rolling day (USD) — blank = no spend cap",
|
|
991
|
+
caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
|
|
992
|
+
);
|
|
993
|
+
const workerMaxTurns = await askNumber(
|
|
994
|
+
ui,
|
|
995
|
+
"Turn ceiling per worker",
|
|
996
|
+
caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
|
|
997
|
+
);
|
|
998
|
+
caps.workerMaxTurns = workerMaxTurns;
|
|
999
|
+
const turnCeilingFallback = Math.max(
|
|
1000
|
+
workerMaxTurns,
|
|
1001
|
+
caps.workerMaxTurnsCeiling ??
|
|
1002
|
+
Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
|
|
1003
|
+
);
|
|
1004
|
+
const workerMaxTurnsCeiling = await askNumber(
|
|
1005
|
+
ui,
|
|
1006
|
+
"Maximum turn ceiling for one issue",
|
|
1007
|
+
turnCeilingFallback,
|
|
1008
|
+
);
|
|
1009
|
+
if (workerMaxTurnsCeiling < workerMaxTurns) {
|
|
1010
|
+
ui.notify(
|
|
1011
|
+
`Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
|
|
1012
|
+
`${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
|
|
1013
|
+
"warning",
|
|
1014
|
+
);
|
|
1015
|
+
caps.workerMaxTurnsCeiling = turnCeilingFallback;
|
|
1016
|
+
} else {
|
|
1017
|
+
caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
|
|
1018
|
+
}
|
|
1019
|
+
caps.workerWallClockMs = await askNumber(
|
|
1020
|
+
ui,
|
|
1021
|
+
"Wall-clock ceiling per worker (ms)",
|
|
1022
|
+
caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
|
|
1023
|
+
);
|
|
1024
|
+
caps.maxAttemptsPerIssue = await askNumber(
|
|
1025
|
+
ui,
|
|
1026
|
+
"Failed implementation attempts per issue before escalation",
|
|
1027
|
+
caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
|
|
1028
|
+
);
|
|
1029
|
+
caps.maxContinuationsPerIssue = await askNumber(
|
|
1030
|
+
ui,
|
|
1031
|
+
"Operational continuations per issue before escalation",
|
|
1032
|
+
caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue,
|
|
1033
|
+
);
|
|
1034
|
+
return { ...a, caps };
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/** Outside the caps block: a model is not a ceiling, and an operator who left
|
|
1038
|
+
* the caps alone may still want workers on a cheaper model. */
|
|
1039
|
+
const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
1040
|
+
const answered = await ask(ui, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
|
|
1041
|
+
const next: SetupAnswers = { ...a };
|
|
1042
|
+
if (answered.trim().length === 0) delete next.workerModel;
|
|
1043
|
+
else next.workerModel = answered.trim();
|
|
1044
|
+
return next;
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
/** The two ownership questions, then the mechanical gate one shape at a time:
|
|
1048
|
+
* together they are what decides what an unattended fleet may do unasked. */
|
|
1049
|
+
const askAuthorityArea: AreaAsker = async (ui, a) => {
|
|
1050
|
+
const authority = await askAuthority(ui, a.authority);
|
|
1051
|
+
const releaseGrants = await askReleaseGrants(ui, a.releaseGrants);
|
|
1052
|
+
// Asked here because it is the same decision one layer down: the grants say
|
|
1053
|
+
// who may act, and these say where that permission stops. Amending authority
|
|
1054
|
+
// therefore re-asks the boundary, which is the point — a grant widened without
|
|
1055
|
+
// restating the boundary is how a delegated release loses its end.
|
|
1056
|
+
const judgment = await askJudgment(ui, releaseGrants, a.judgment ?? {});
|
|
1057
|
+
return { ...a, authority, releaseGrants, judgment };
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
/** What a merge and a release must satisfy. Asked straight after the grants:
|
|
1061
|
+
* who may act, then under what conditions (#129). */
|
|
1062
|
+
const askPolicy: AreaAsker = async (ui, a) => ({ ...a, policy: await askPolicyPreconditions(ui, a.policy) });
|
|
1063
|
+
|
|
1064
|
+
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
1065
|
+
const askEscalation: AreaAsker = async (ui, a) => {
|
|
1066
|
+
const telegram = detectTelegram();
|
|
1067
|
+
let telegramChatId = a.telegramChatId;
|
|
1068
|
+
if (telegram.available && telegram.hasToken) {
|
|
1069
|
+
if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
|
|
1070
|
+
const usePaired = await askYesNo(
|
|
1071
|
+
ui,
|
|
1072
|
+
"Tier-2 escalations",
|
|
1073
|
+
`omp-telegram is paired with chat ${telegram.pairedOwnerId}. Page it when a run is stuck?`,
|
|
1074
|
+
);
|
|
1075
|
+
if (usePaired) telegramChatId = telegram.pairedOwnerId;
|
|
1076
|
+
} else {
|
|
1077
|
+
const answered = await ask(ui, "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "");
|
|
1078
|
+
telegramChatId = answered.length > 0 ? answered : undefined;
|
|
1079
|
+
}
|
|
1080
|
+
} else {
|
|
1081
|
+
// Not an error: tier 2 degrades to a comment, which is the documented fallback.
|
|
1082
|
+
ui.notify(
|
|
1083
|
+
`No usable omp-telegram install at ${telegram.stateDir} — tier-2 escalations will comment on the issue.`,
|
|
1084
|
+
"info",
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const telegramTopicId =
|
|
1089
|
+
telegramChatId === undefined
|
|
1090
|
+
? undefined
|
|
1091
|
+
: await askTelegramTopicId(ui, telegram.stateDir, a.telegramTopicId);
|
|
1092
|
+
|
|
1093
|
+
const fallbackToIssueComment = await askYesNo(
|
|
1094
|
+
ui,
|
|
1095
|
+
"Escalation fallback",
|
|
1096
|
+
"Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
const orchestratorMode = await askOrchestratorMode(ui, a.orchestratorMode);
|
|
1100
|
+
|
|
1101
|
+
const next: SetupAnswers = { ...a, fallbackToIssueComment, orchestratorMode };
|
|
1102
|
+
if (telegramChatId === undefined) delete next.telegramChatId;
|
|
1103
|
+
else next.telegramChatId = telegramChatId;
|
|
1104
|
+
if (telegramTopicId === undefined) delete next.telegramTopicId;
|
|
1105
|
+
else next.telegramTopicId = telegramTopicId;
|
|
1106
|
+
return next;
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Forum topic for tier-2 pages. Claimed threads from omp-telegram's
|
|
1111
|
+
* `threads.json` are offered when readable; a missing file is silent and the
|
|
1112
|
+
* operator can still type an id or keep flat chat (#318).
|
|
1113
|
+
*/
|
|
1114
|
+
async function askTelegramTopicId(
|
|
1115
|
+
ui: WizardUi,
|
|
1116
|
+
stateDir: string,
|
|
1117
|
+
prior: number | undefined,
|
|
1118
|
+
): Promise<number | undefined> {
|
|
1119
|
+
const claimed = readClaimedTelegramTopics(stateDir);
|
|
1120
|
+
const manual = "Enter thread id manually";
|
|
1121
|
+
const none = "None — flat chat (0.13 behaviour)";
|
|
1122
|
+
if (claimed.length > 0) {
|
|
1123
|
+
const options = [
|
|
1124
|
+
...claimed.map((t) => ({
|
|
1125
|
+
label: `${t.name} — ${t.threadId}`,
|
|
1126
|
+
description: `message_thread_id ${t.threadId}`,
|
|
1127
|
+
})),
|
|
1128
|
+
{ label: manual, description: "type a numeric forum topic id" },
|
|
1129
|
+
{ label: none, description: "send to the chat root, not a topic" },
|
|
1130
|
+
];
|
|
1131
|
+
const priorIdx = prior === undefined ? -1 : claimed.findIndex((t) => t.threadId === prior);
|
|
1132
|
+
const picked = await ui.select("Telegram forum topic for tier-2 pages", options, {
|
|
1133
|
+
initialIndex: priorIdx >= 0 ? priorIdx : claimed.length + 1,
|
|
1134
|
+
});
|
|
1135
|
+
if (picked === undefined) throw new Cancelled();
|
|
1136
|
+
if (picked === none) return undefined;
|
|
1137
|
+
if (picked !== manual) {
|
|
1138
|
+
const hit = claimed.find((t) => `${t.name} — ${t.threadId}` === picked);
|
|
1139
|
+
if (hit !== undefined) return hit.threadId;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const answered = await ask(
|
|
1144
|
+
ui,
|
|
1145
|
+
"Telegram forum topic id (blank for flat chat)",
|
|
1146
|
+
prior !== undefined ? String(prior) : "",
|
|
1147
|
+
);
|
|
1148
|
+
if (answered.length === 0) return undefined;
|
|
1149
|
+
const n = Number(answered);
|
|
1150
|
+
if (!Number.isFinite(n) || !Number.isSafeInteger(n)) {
|
|
1151
|
+
ui.notify(`"${answered}" is not an integer topic id — using flat chat.`, "warning");
|
|
1152
|
+
return undefined;
|
|
1153
|
+
}
|
|
1154
|
+
return n;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function readClaimedTelegramTopics(stateDir: string): Array<{ threadId: number; name: string }> {
|
|
1158
|
+
try {
|
|
1159
|
+
const raw: unknown = JSON.parse(readFileSync(join(stateDir, "threads.json"), "utf8"));
|
|
1160
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
|
|
1161
|
+
if (!("threads" in raw)) return [];
|
|
1162
|
+
const threads = raw.threads;
|
|
1163
|
+
if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
|
|
1164
|
+
const out: Array<{ threadId: number; name: string }> = [];
|
|
1165
|
+
for (const [id, entry] of Object.entries(threads)) {
|
|
1166
|
+
const threadId = Number(id);
|
|
1167
|
+
if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
|
|
1168
|
+
let name = id;
|
|
1169
|
+
if (typeof entry === "object" && entry !== null && "name" in entry) {
|
|
1170
|
+
const candidate = entry.name;
|
|
1171
|
+
if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
|
|
1172
|
+
}
|
|
1173
|
+
out.push({ threadId, name });
|
|
1174
|
+
}
|
|
1175
|
+
return out;
|
|
1176
|
+
} catch {
|
|
1177
|
+
return [];
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** How loud the orchestrator is and when the operator permits interruptions. */
|
|
1182
|
+
const askReporting: AreaAsker = async (ui, a) => {
|
|
1183
|
+
const reportScope = await askReportScope(ui, a.reportScope);
|
|
1184
|
+
const continuous = "Continuous (24-hour interrupts)";
|
|
1185
|
+
const weekly = "Weekly availability window";
|
|
1186
|
+
const mode = await ui.select(
|
|
1187
|
+
"Operator availability",
|
|
1188
|
+
[
|
|
1189
|
+
{
|
|
1190
|
+
label: continuous,
|
|
1191
|
+
description: "preserve legacy behavior: configured interrupt categories may page at any hour",
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
label: weekly,
|
|
1195
|
+
description: "hold non-bypass interruptions outside selected local working hours",
|
|
1196
|
+
},
|
|
1197
|
+
],
|
|
1198
|
+
{ initialIndex: a.availability === undefined ? 0 : 1 },
|
|
1199
|
+
);
|
|
1200
|
+
if (mode === undefined) throw new Cancelled();
|
|
1201
|
+
|
|
1202
|
+
const next: SetupAnswers = { ...a, reportScope };
|
|
1203
|
+
delete next.preservedReporting;
|
|
1204
|
+
delete next.availability;
|
|
1205
|
+
const applyDigestSchedule = (raw: string): SetupAnswers => {
|
|
1206
|
+
const updated = { ...next };
|
|
1207
|
+
delete updated.dailyDigestAt;
|
|
1208
|
+
const value = raw.trim().toLowerCase();
|
|
1209
|
+
if (value === "per-tick") {
|
|
1210
|
+
updated.digestCadence = "per-tick";
|
|
1211
|
+
} else if (value === "off" || value === "disabled" || value === "none") {
|
|
1212
|
+
updated.digestCadence = "none";
|
|
1213
|
+
} else if (value === "model-timed" || value === "daily") {
|
|
1214
|
+
updated.digestCadence = "daily";
|
|
1215
|
+
} else {
|
|
1216
|
+
updated.digestCadence = "daily";
|
|
1217
|
+
updated.dailyDigestAt = raw;
|
|
1218
|
+
}
|
|
1219
|
+
return updated;
|
|
1220
|
+
};
|
|
1221
|
+
const askDigestSchedule = async (fallback: string): Promise<SetupAnswers> => {
|
|
1222
|
+
const shown =
|
|
1223
|
+
a.digestCadence === "per-tick"
|
|
1224
|
+
? "per-tick"
|
|
1225
|
+
: a.digestCadence === "none"
|
|
1226
|
+
? "off"
|
|
1227
|
+
: a.dailyDigestAt ?? (a.digestCadence === "daily" ? "model-timed" : fallback);
|
|
1228
|
+
const schedule = await askValid(
|
|
1229
|
+
ui,
|
|
1230
|
+
'Daily rollup time in that timezone / digest cadence ("per-tick", "model-timed", "off", or 24h HH:MM)',
|
|
1231
|
+
shown,
|
|
1232
|
+
(value) =>
|
|
1233
|
+
["per-tick", "model-timed", "daily", "none", "off", "disabled"].includes(
|
|
1234
|
+
value.toLowerCase(),
|
|
1235
|
+
) || /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
|
|
1236
|
+
? undefined
|
|
1237
|
+
: 'Use "per-tick", "model-timed", "off", or a 24h HH:MM time.',
|
|
1238
|
+
);
|
|
1239
|
+
return applyDigestSchedule(schedule);
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
if (mode !== weekly) {
|
|
1243
|
+
if (mode !== continuous) {
|
|
1244
|
+
ui.notify(`Unrecognised availability choice "${mode}" — keeping 24-hour interrupts.`, "warning");
|
|
1245
|
+
}
|
|
1246
|
+
if (reportScope !== "quiet") return next;
|
|
1247
|
+
return await askDigestSchedule("model-timed");
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const defaultZone =
|
|
1251
|
+
a.reportingTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
1252
|
+
const reportingTimezone = await askValid(
|
|
1253
|
+
ui,
|
|
1254
|
+
"Operator timezone (IANA, for example Europe/London)",
|
|
1255
|
+
defaultZone,
|
|
1256
|
+
(value) => {
|
|
1257
|
+
try {
|
|
1258
|
+
new Intl.DateTimeFormat("en", { timeZone: value }).format();
|
|
1259
|
+
return undefined;
|
|
1260
|
+
} catch {
|
|
1261
|
+
return `"${value}" is not a known IANA timezone.`;
|
|
1262
|
+
}
|
|
1263
|
+
},
|
|
1264
|
+
);
|
|
1265
|
+
const daysText = await askValid(
|
|
1266
|
+
ui,
|
|
1267
|
+
"Working weekdays (comma-separated: mon,tue,wed,thu,fri,sat,sun)",
|
|
1268
|
+
a.availability?.days.join(",") ?? "mon,tue,wed,thu,fri",
|
|
1269
|
+
(value) => {
|
|
1270
|
+
const days = value.split(",").map((day) => day.trim().toLowerCase());
|
|
1271
|
+
if (days.length === 0 || days.some((day) => !WEEKDAYS.includes(day as Weekday))) {
|
|
1272
|
+
return `Use only: ${WEEKDAYS.join(",")}.`;
|
|
1273
|
+
}
|
|
1274
|
+
return new Set(days).size === days.length ? undefined : "List each weekday only once.";
|
|
1275
|
+
},
|
|
1276
|
+
);
|
|
1277
|
+
const days = daysText.split(",").map((day) => day.trim().toLowerCase() as Weekday);
|
|
1278
|
+
const start = await askValid(
|
|
1279
|
+
ui,
|
|
1280
|
+
"Availability starts (24h HH:MM)",
|
|
1281
|
+
a.availability?.start ?? "09:00",
|
|
1282
|
+
(value) => (/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : "Use 24h HH:MM."),
|
|
1283
|
+
);
|
|
1284
|
+
const end = await askValid(
|
|
1285
|
+
ui,
|
|
1286
|
+
"Availability ends (24h HH:MM)",
|
|
1287
|
+
a.availability?.end ?? "17:00",
|
|
1288
|
+
(value) =>
|
|
1289
|
+
!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)
|
|
1290
|
+
? "Use 24h HH:MM."
|
|
1291
|
+
: value === start
|
|
1292
|
+
? "Start and end must differ."
|
|
1293
|
+
: undefined,
|
|
1294
|
+
);
|
|
1295
|
+
const bypassText = await askValid(
|
|
1296
|
+
ui,
|
|
1297
|
+
`Quiet-hours bypass categories (comma-separated; "none" = none; choices: ${INTERRUPT_CATEGORIES.join(",")})`,
|
|
1298
|
+
a.availability === undefined ? "fleet-stopped" : a.availability.bypass.join(",") || "none",
|
|
1299
|
+
(value) => {
|
|
1300
|
+
if (value.toLowerCase() === "none") return undefined;
|
|
1301
|
+
const categories = value.split(",").map((category) => category.trim().toLowerCase());
|
|
1302
|
+
if (categories.some((category) => !INTERRUPT_CATEGORIES.includes(category as InterruptCategory))) {
|
|
1303
|
+
return `Use only: ${INTERRUPT_CATEGORIES.join(",")}, or "none".`;
|
|
1304
|
+
}
|
|
1305
|
+
return new Set(categories).size === categories.length ? undefined : "List each category only once.";
|
|
1306
|
+
},
|
|
1307
|
+
);
|
|
1308
|
+
const bypass =
|
|
1309
|
+
bypassText.toLowerCase() === "none"
|
|
1310
|
+
? []
|
|
1311
|
+
: bypassText.split(",").map((category) => category.trim().toLowerCase() as InterruptCategory);
|
|
1312
|
+
const scheduled = await askDigestSchedule(end);
|
|
1313
|
+
return {
|
|
1314
|
+
...scheduled,
|
|
1315
|
+
reportingTimezone,
|
|
1316
|
+
availability: { days, start, end, bypass },
|
|
1317
|
+
};
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
/** The operator's own brief. Asked last in the full interview, because the
|
|
1321
|
+
* question quotes the path the rest of the answers derive. */
|
|
1322
|
+
const askBrief: AreaAsker = async (ui, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ui, a) });
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* One dialog sequence per amend area, keyed so a new area cannot be added to
|
|
1326
|
+
* {@link AMEND_AREA_IDS} without one.
|
|
1327
|
+
*/
|
|
1328
|
+
const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
1329
|
+
tracker: askTrackerAndRepos,
|
|
1330
|
+
gates: askGatesOnly,
|
|
1331
|
+
// The two per-worker knobs the full interview separates with the authority
|
|
1332
|
+
// grants; an amend has no reason to put anything between them.
|
|
1333
|
+
caps: async (ui, a, probes) => await askWorkerModel(ui, await askCaps(ui, a, probes), probes),
|
|
1334
|
+
"code-graph": askGraph,
|
|
1335
|
+
authority: askAuthorityArea,
|
|
1336
|
+
policy: askPolicy,
|
|
1337
|
+
escalation: askEscalation,
|
|
1338
|
+
reporting: askReporting,
|
|
1339
|
+
// `askBrief` alone would be a trap. The floor brief and the unfilled stub both
|
|
1340
|
+
// send an operator here to *fill in* Releases and Project context, and judgment
|
|
1341
|
+
// is deliberately not persisted — so an area that asked only "overwrite it?"
|
|
1342
|
+
// would rewrite POLICY.md from an empty judgment and replace a filled file with
|
|
1343
|
+
// the very stubs they came to remove. The grants are already on disk, so the
|
|
1344
|
+
// boundary questions are asked against the authority this project really has.
|
|
1345
|
+
brief: async (ui, a, probes) =>
|
|
1346
|
+
await askBrief(ui, { ...a, judgment: await askJudgment(ui, a.releaseGrants, a.judgment ?? {}) }, probes),
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
/** The two ways to answer the first question a configured project gets. Labels,
|
|
1350
|
+
* because the harness's select resolves to the label it displayed. */
|
|
1351
|
+
const AMEND_ONE = "Change one area";
|
|
1352
|
+
const REINTERVIEW = "Walk every question again";
|
|
1353
|
+
/** Third chooser row when a config already exists: start a full interview for a
|
|
1354
|
+
* brand-new neighbour rather than amending the one that is already there. */
|
|
1355
|
+
const ADD_PROJECT = "Add another project";
|
|
1356
|
+
|
|
1357
|
+
/** Outcome of the first chooser on a re-run. */
|
|
1358
|
+
type AmendChoice =
|
|
1359
|
+
| { kind: "area"; area: AmendAreaId }
|
|
1360
|
+
| { kind: "reinterview" }
|
|
1361
|
+
| { kind: "add-project" };
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* The first question a re-run asks, and the reason amend mode exists: adding one
|
|
1365
|
+
* key should not cost twenty prompts. Also the entry for adding a neighbour
|
|
1366
|
+
* project without leaving the wizard (#319).
|
|
1367
|
+
*
|
|
1368
|
+
* Returns the area to amend, a full re-interview of the named project, or the
|
|
1369
|
+
* add-a-project path. Only asked when a project is already configured — a first
|
|
1370
|
+
* run has nothing to amend and is never shown this.
|
|
1371
|
+
*/
|
|
1372
|
+
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
|
|
1373
|
+
const mode = await ui.select(
|
|
1374
|
+
`"${prior.name}" is already configured — what would you like to do?`,
|
|
1375
|
+
[
|
|
1376
|
+
{
|
|
1377
|
+
label: AMEND_ONE,
|
|
1378
|
+
description: "asks one area's questions; every other answer is carried through from the saved config",
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
label: REINTERVIEW,
|
|
1382
|
+
description: "the full interview, every prompt pre-filled with what is configured now",
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
label: ADD_PROJECT,
|
|
1386
|
+
description: "full interview for a new project; existing projects stay as they are",
|
|
1387
|
+
},
|
|
1388
|
+
],
|
|
1389
|
+
{ initialIndex: 0 },
|
|
1390
|
+
);
|
|
1391
|
+
if (mode === undefined) throw new Cancelled();
|
|
1392
|
+
if (mode === ADD_PROJECT) return { kind: "add-project" };
|
|
1393
|
+
if (mode !== AMEND_ONE) {
|
|
1394
|
+
// Either the operator chose the full interview, or the dialog answered with
|
|
1395
|
+
// a label we never offered. Both land on today's behaviour, which is the one
|
|
1396
|
+
// that cannot silently skip a question.
|
|
1397
|
+
if (mode !== REINTERVIEW) ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning");
|
|
1398
|
+
return { kind: "reinterview" };
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const choices = amendChoices(prior);
|
|
1402
|
+
const picked = await ui.select(
|
|
1403
|
+
"Which area? Each row shows what it says now",
|
|
1404
|
+
choices.map((c) => ({ label: c.label, description: c.description })),
|
|
1405
|
+
{ initialIndex: 0 },
|
|
1406
|
+
);
|
|
1407
|
+
if (picked === undefined) throw new Cancelled();
|
|
1408
|
+
|
|
1409
|
+
const chosen = choices.find((c) => c.label === picked);
|
|
1410
|
+
if (chosen === undefined) {
|
|
1411
|
+
// Guessing an area here would ask the wrong questions and carry the rest
|
|
1412
|
+
// through as if they had been reviewed. Abandoning changes nothing.
|
|
1413
|
+
ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning");
|
|
1414
|
+
throw new Cancelled();
|
|
1415
|
+
}
|
|
1416
|
+
return { kind: "area", area: chosen.id };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* The conversation. Reads only — every answer is collected before anything is
|
|
1421
|
+
* checked against GitHub, and long before anything is written.
|
|
1422
|
+
*
|
|
1423
|
+
* Seeded from one answers object rather than pre-filling each prompt from
|
|
1424
|
+
* `prior?.field ?? default`: that is the same carry-through an amend relies on,
|
|
1425
|
+
* so the two flows cannot disagree about what an unanswered field is.
|
|
1426
|
+
*/
|
|
1427
|
+
async function collectAnswers(
|
|
1428
|
+
ui: WizardUi,
|
|
1429
|
+
prior: ProjectConfig | undefined,
|
|
1430
|
+
projectArg: string | undefined,
|
|
1431
|
+
probes: SetupProbes,
|
|
1432
|
+
opts: { added?: boolean } = {},
|
|
1433
|
+
): Promise<SetupAnswers> {
|
|
1434
|
+
// Added projects seed under projects/<name>/; a first install and a re-interview
|
|
1435
|
+
// of an existing project keep the flat (or already-on-disk) roots.
|
|
1436
|
+
const seed =
|
|
1437
|
+
prior === undefined
|
|
1438
|
+
? defaultAnswers(projectArg ?? "", { added: opts.added === true })
|
|
1439
|
+
: answersFromProject(prior);
|
|
1440
|
+
|
|
1441
|
+
const projectName = await askValid(
|
|
1442
|
+
ui,
|
|
1443
|
+
"Project name",
|
|
1444
|
+
projectArg ?? seed.projectName,
|
|
1445
|
+
(v) => (v.length > 0 ? undefined : "A name is required — it is how `omp-conductor status --project <name>` finds this project."),
|
|
1446
|
+
);
|
|
1447
|
+
|
|
1448
|
+
// When the operator renames mid-interview on an add, re-seed the scoped roots
|
|
1449
|
+
// so they track the final name rather than the empty/CLI placeholder.
|
|
1450
|
+
let a: SetupAnswers = { ...seed, projectName };
|
|
1451
|
+
if (opts.added === true && prior === undefined) {
|
|
1452
|
+
const scoped = defaultAnswers(projectName, { added: true });
|
|
1453
|
+
a = {
|
|
1454
|
+
...a,
|
|
1455
|
+
workspaceRoot: scoped.workspaceRoot,
|
|
1456
|
+
mirrorRoot: scoped.mirrorRoot,
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
a = await askTrackerAndRepos(ui, a, probes);
|
|
1460
|
+
// Straight after the repos, because it is a fact about them: one clone per
|
|
1461
|
+
// routed repo, under one root.
|
|
1462
|
+
a = await askGraph(ui, a, probes);
|
|
1463
|
+
a = await askCaps(ui, a, probes);
|
|
1464
|
+
a = await askAuthorityArea(ui, a, probes);
|
|
1465
|
+
a = await askPolicy(ui, a, probes);
|
|
1466
|
+
a = await askWorkerModel(ui, a, probes);
|
|
1467
|
+
a = await askEscalation(ui, a, probes);
|
|
1468
|
+
a = await askReporting(ui, a, probes);
|
|
1469
|
+
// Asked last, and asked with the real path in the question — which needs the
|
|
1470
|
+
// rest of the answers to derive.
|
|
1471
|
+
return await askBrief(ui, a, probes);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** The dry run, rendered. Same routing code the loop uses, so this is what the
|
|
1475
|
+
* next tick would actually do — not a description of it. */
|
|
1476
|
+
function formatPreview(p: QueuePreview): string[] {
|
|
1477
|
+
const lines = [
|
|
1478
|
+
`state ${p.paused ? "paused" : "armed"}`,
|
|
1479
|
+
p.ready.length === 0
|
|
1480
|
+
? "Would pick up: nothing — the queue is empty."
|
|
1481
|
+
: `Would pick up ${p.ready.length} issue(s), caps permitting:`,
|
|
1482
|
+
];
|
|
1483
|
+
for (const r of p.ready) lines.push(` #${r.number} → ${r.repo} ${r.branch} ${r.title}`);
|
|
1484
|
+
|
|
1485
|
+
if (p.unroutable.length > 0) {
|
|
1486
|
+
lines.push("", `Cannot route ${p.unroutable.length} issue(s) — these escalate instead of running:`);
|
|
1487
|
+
for (const u of p.unroutable) {
|
|
1488
|
+
lines.push(` #${u.number} ${u.reason} [${u.labels.join(", ") || "no labels"}] ${u.title}`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return lines;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
/** What the whole conversation produced: the answers, and which area an amend
|
|
1496
|
+
* narrowed it to. `amend` absent means every question was asked. */
|
|
1497
|
+
export interface CollectedSetup {
|
|
1498
|
+
answers: SetupAnswers;
|
|
1499
|
+
amend?: { area: AmendAreaId; before: ProjectConfig };
|
|
1500
|
+
/** True when this run is adding a neighbour project (or first-run-for-name). */
|
|
1501
|
+
added?: boolean;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
export async function ensureSetupArm(
|
|
1506
|
+
projectName: string,
|
|
1507
|
+
arm: typeof armTicks = armTicks,
|
|
1508
|
+
): Promise<string> {
|
|
1509
|
+
const armed = await arm(projectName);
|
|
1510
|
+
return armed.alreadyArmed
|
|
1511
|
+
? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
|
|
1512
|
+
: `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* The whole conversation, from the amend question to the last prompt, and not one
|
|
1517
|
+
* byte further: no `gh`, no dry run, nothing written.
|
|
1518
|
+
*
|
|
1519
|
+
* Exported at exactly that seam so a test can script the dialogs and pin what a
|
|
1520
|
+
* first run asks and what an amend refuses to ask — the two properties amend mode
|
|
1521
|
+
* is judged on — on a host with no `gh` and no config.
|
|
1522
|
+
*/
|
|
1523
|
+
export async function collectSetup(
|
|
1524
|
+
ui: WizardUi,
|
|
1525
|
+
existing: ConductorConfig | undefined,
|
|
1526
|
+
projectArg: string | undefined,
|
|
1527
|
+
areaArg: AmendAreaId | undefined,
|
|
1528
|
+
probes: SetupProbes,
|
|
1529
|
+
): Promise<CollectedSetup> {
|
|
1530
|
+
// Only a project that is already configured can be amended. A first run, or a
|
|
1531
|
+
// name this config has never seen, goes straight into the full interview with
|
|
1532
|
+
// no extra question — which is what it was before amend mode existed.
|
|
1533
|
+
//
|
|
1534
|
+
// When the config already has at least one project and the named (or sole)
|
|
1535
|
+
// target is unknown, this is an add: seed under projects/<name>/ so the new
|
|
1536
|
+
// fleet never shares the first project's flat worktrees/mirrors (#319).
|
|
1537
|
+
const prior = priorProject(existing, projectArg);
|
|
1538
|
+
if (prior === undefined) {
|
|
1539
|
+
const adding = (existing?.projects.length ?? 0) > 0;
|
|
1540
|
+
const answers = await collectAnswers(ui, undefined, projectArg, probes, { added: adding });
|
|
1541
|
+
// An add that typed an already-configured name would silently replace via
|
|
1542
|
+
// buildConfig. Ask first — amend (carry-through full interview of that
|
|
1543
|
+
// project) or replace (answers as collected, roots as answered).
|
|
1544
|
+
const named = existing?.projects.find((p) => p.name === answers.projectName);
|
|
1545
|
+
if (named !== undefined) {
|
|
1546
|
+
const mode = await ui.select(
|
|
1547
|
+
`"${answers.projectName}" is already configured — amend or replace?`,
|
|
1548
|
+
[
|
|
1549
|
+
{
|
|
1550
|
+
label: "Amend (keep existing settings, re-ask everything pre-filled)",
|
|
1551
|
+
description: "full interview seeded from the saved project; other projects untouched",
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
label: "Replace with these answers",
|
|
1555
|
+
description: "overwrites this project's config entry on apply; other projects untouched",
|
|
1556
|
+
},
|
|
1557
|
+
],
|
|
1558
|
+
{ initialIndex: 0 },
|
|
1559
|
+
);
|
|
1560
|
+
if (mode === undefined) throw new Cancelled();
|
|
1561
|
+
if (mode.startsWith("Amend")) {
|
|
1562
|
+
return { answers: await collectAnswers(ui, named, answers.projectName, probes) };
|
|
1563
|
+
}
|
|
1564
|
+
// Replace: keep the answers just collected (including any scoped roots the
|
|
1565
|
+
// operator saw). buildConfig replaces by name.
|
|
1566
|
+
return { answers };
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
answers,
|
|
1570
|
+
added: adding || undefined,
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// A positional CLI area skips the chooser: the operator already named which
|
|
1575
|
+
// area they came to amend, so the first dialog is that area's first question
|
|
1576
|
+
// rather than a menu of every area.
|
|
1577
|
+
if (areaArg !== undefined) {
|
|
1578
|
+
return {
|
|
1579
|
+
answers: await AREA_ASKERS[areaArg](ui, answersFromProject(prior), probes),
|
|
1580
|
+
amend: { area: areaArg, before: prior },
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const choice = await chooseAmendArea(ui, prior);
|
|
1585
|
+
if (choice.kind === "add-project") {
|
|
1586
|
+
// Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
|
|
1587
|
+
// here: the operator just chose to add, so the name comes from the prompt.
|
|
1588
|
+
const answers = await collectAnswers(ui, undefined, undefined, probes, { added: true });
|
|
1589
|
+
const named = existing?.projects.find((p) => p.name === answers.projectName);
|
|
1590
|
+
if (named !== undefined) {
|
|
1591
|
+
const mode = await ui.select(
|
|
1592
|
+
`"${answers.projectName}" is already configured — amend or replace?`,
|
|
1593
|
+
[
|
|
1594
|
+
{
|
|
1595
|
+
label: "Amend (keep existing settings, re-ask everything pre-filled)",
|
|
1596
|
+
description: "full interview seeded from the saved project; other projects untouched",
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
label: "Replace with these answers",
|
|
1600
|
+
description: "overwrites this project's config entry on apply; other projects untouched",
|
|
1601
|
+
},
|
|
1602
|
+
],
|
|
1603
|
+
{ initialIndex: 0 },
|
|
1604
|
+
);
|
|
1605
|
+
if (mode === undefined) throw new Cancelled();
|
|
1606
|
+
if (mode.startsWith("Amend")) {
|
|
1607
|
+
return { answers: await collectAnswers(ui, named, answers.projectName, probes) };
|
|
1608
|
+
}
|
|
1609
|
+
return { answers };
|
|
1610
|
+
}
|
|
1611
|
+
return { answers, added: true };
|
|
1612
|
+
}
|
|
1613
|
+
if (choice.kind === "reinterview") {
|
|
1614
|
+
// Full interview of the *existing* name is a replace of that project. Ask
|
|
1615
|
+
// once so a silent overwrite cannot happen from muscle-memory Enter.
|
|
1616
|
+
const replace = await ui.confirm(
|
|
1617
|
+
`Replace existing project "${prior.name}"?`,
|
|
1618
|
+
"Walks every question again and overwrites this project's config entry on apply. " +
|
|
1619
|
+
"Other projects are left alone. Cancel and pick \"Change one area\" to amend without a full replace, " +
|
|
1620
|
+
"or \"Add another project\" to create a neighbour.",
|
|
1621
|
+
);
|
|
1622
|
+
if (replace !== true) throw new Cancelled();
|
|
1623
|
+
return { answers: await collectAnswers(ui, prior, projectArg, probes) };
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
return {
|
|
1627
|
+
answers: await AREA_ASKERS[choice.area](ui, answersFromProject(prior), probes),
|
|
1628
|
+
amend: { area: choice.area, before: prior },
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Operator steps herdr requires for a new fleet pane. Verified against
|
|
1634
|
+
* `herdr workspace create` / `herdr agent start` help and recover.sh bootstrap:
|
|
1635
|
+
* create a workspace at the project's cwd, then start omp *into* that pane with
|
|
1636
|
+
* `--kind omp --pane <id>`. Do **not** invent `agent start --session/--cwd` —
|
|
1637
|
+
* `agent start` requires an existing pane at a shell prompt, and starting into a
|
|
1638
|
+
* live orchestrator pane is refused or worse.
|
|
1639
|
+
*/
|
|
1640
|
+
export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig): string {
|
|
1641
|
+
const session = process.env["HERDR_SESSION"] ?? "conductor";
|
|
1642
|
+
const cwds = cfg.projects.map((p) => p.workspaceRoot).join(":");
|
|
1643
|
+
return [
|
|
1644
|
+
"herdr handoff (CLI cannot do these — exact herdr argv):",
|
|
1645
|
+
` 1. In session "${session}", create a workspace for project "${project.name}" at its workspaceRoot:`,
|
|
1646
|
+
` herdr --session ${session} workspace create --cwd ${project.workspaceRoot} --label ${project.name} --no-focus`,
|
|
1647
|
+
" Read root_pane.pane_id from the JSON reply (jq -r '((.result // .).root_pane.pane_id) // empty').",
|
|
1648
|
+
` 2. Start omp in that empty pane. The agent NAME must match tick config agentName ("${project.name}"):`,
|
|
1649
|
+
` herdr --session ${session} agent start ${project.name} --kind omp --pane <pane-id>`,
|
|
1650
|
+
" The pane must be at a shell prompt with no agent on it. Never agent-start into a live orchestrator pane.",
|
|
1651
|
+
` 3. Point herdr-conductor recovery at every fleet cwd (colon-separated; see #320):`,
|
|
1652
|
+
` FLEET_CWDS=${cwds}`,
|
|
1653
|
+
` Legacy single-fleet hosts can keep FLEET_CWD=${project.workspaceRoot} until multi-fleet recovery lands.`,
|
|
1654
|
+
" 4. Reload the daemon so it serves every configured project:",
|
|
1655
|
+
" omp-conductor restart --now",
|
|
1656
|
+
" (printed, not auto-run, when workers are live or a neighbour was just added)",
|
|
1657
|
+
].join("\n");
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
export async function setup(
|
|
1663
|
+
ui: WizardUi,
|
|
1664
|
+
projectArg: string | undefined,
|
|
1665
|
+
areaArg?: AmendAreaId,
|
|
1666
|
+
probes: SetupProbes = DEFAULT_PROBES,
|
|
1667
|
+
): Promise<void> {
|
|
1668
|
+
const path = configPath();
|
|
1669
|
+
// A config that exists but does not parse is a fault to report, never
|
|
1670
|
+
// something to quietly replace: overwriting it would delete every project it
|
|
1671
|
+
// describes. Absence, by contrast, is just the first run.
|
|
1672
|
+
const existing = existsSync(path) ? loadConfig() : undefined;
|
|
1673
|
+
if (existing === undefined) {
|
|
1674
|
+
ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
let collected: CollectedSetup;
|
|
1678
|
+
try {
|
|
1679
|
+
collected = await collectSetup(ui, existing, projectArg, areaArg, probes);
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
if (!(err instanceof Cancelled)) throw err;
|
|
1682
|
+
ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const { answers, amend, added } = collected;
|
|
1686
|
+
|
|
1687
|
+
const scopes = await checkTokenScopes();
|
|
1688
|
+
if (!scopes.ok) {
|
|
1689
|
+
ui.notify(
|
|
1690
|
+
`Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
|
|
1691
|
+
`Run \`gh auth refresh -s repo,project\`, then run setup again.`,
|
|
1692
|
+
"error",
|
|
1693
|
+
);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// Drafted here — after the interview, before the plan — because the plan's single
|
|
1698
|
+
// consent gate says "Writes ORCHESTRATOR.md + POLICY.md", and this is what those
|
|
1699
|
+
// files will contain. Each draft carries its own preview and confirm; declining
|
|
1700
|
+
// one keeps the shipped stub, so an operator who wants none reaches the same plan.
|
|
1701
|
+
const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
|
|
1702
|
+
const labels = await planLabels(answers.trackerRepo, answers);
|
|
1703
|
+
const telegram = detectTelegram();
|
|
1704
|
+
let nextConfig;
|
|
1705
|
+
try {
|
|
1706
|
+
nextConfig = buildConfig(answers, existing);
|
|
1707
|
+
} catch (err) {
|
|
1708
|
+
ui.notify(
|
|
1709
|
+
`Setup stopped before writing anything. ${err instanceof Error ? err.message : String(err)}`,
|
|
1710
|
+
"error",
|
|
1711
|
+
);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const project = findProject(nextConfig, answers.projectName);
|
|
1715
|
+
const totalWorkers = totalConfiguredWorkers(nextConfig);
|
|
1716
|
+
const overcommit = workerOvercommit(totalWorkers);
|
|
1717
|
+
// The same project as it is configured right now, so a moved `workspaceRoot`
|
|
1718
|
+
if (
|
|
1719
|
+
project.escalation.orchestrator === "external" &&
|
|
1720
|
+
!answers.writeOrchestratorBrief &&
|
|
1721
|
+
(!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
|
|
1722
|
+
) {
|
|
1723
|
+
ui.notify(
|
|
1724
|
+
`Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
|
|
1725
|
+
`Run setup again and approve the brief write.`,
|
|
1726
|
+
"error",
|
|
1727
|
+
);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
const runtime = planHostRuntime(
|
|
1731
|
+
project,
|
|
1732
|
+
resolveCaps(project, nextConfig.defaults),
|
|
1733
|
+
telegram.stateDir,
|
|
1734
|
+
undefined,
|
|
1735
|
+
totalWorkers,
|
|
1736
|
+
);
|
|
1737
|
+
let queuePreview: string[];
|
|
1738
|
+
try {
|
|
1739
|
+
queuePreview = formatPreview(await previewProject(project));
|
|
1740
|
+
} catch (err) {
|
|
1741
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1742
|
+
ui.notify(
|
|
1743
|
+
`Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
|
|
1744
|
+
"error",
|
|
1745
|
+
);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
ui.notify(
|
|
1750
|
+
[
|
|
1751
|
+
// The delta first when there is one, then the whole plan: the confirm has
|
|
1752
|
+
// to name every mutation it authorises, and a delta names none of them.
|
|
1753
|
+
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
|
|
1754
|
+
summarisePlan(answers, scopes, labels, telegram),
|
|
1755
|
+
"",
|
|
1756
|
+
formatHostRuntimePlan(runtime),
|
|
1757
|
+
"",
|
|
1758
|
+
...(overcommit === undefined ? [] : [`WARNING: ${overcommit}`, ""]),
|
|
1759
|
+
"Dry run against the PROPOSED config:",
|
|
1760
|
+
...queuePreview,
|
|
1761
|
+
"",
|
|
1762
|
+
"Nothing has been changed yet.",
|
|
1763
|
+
].join("\n"),
|
|
1764
|
+
"info",
|
|
1765
|
+
);
|
|
1766
|
+
|
|
1767
|
+
const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
|
|
1768
|
+
// Deliberately `ui.confirm` rather than `askYesNo`: this is the one confirm
|
|
1769
|
+
// after the interview, and dismissing it means the same thing as answering no
|
|
1770
|
+
// — do not apply — so it returns through the existing "left untouched" path
|
|
1771
|
+
// instead of raising a `Cancelled` nothing outside `collectSetup` catches.
|
|
1772
|
+
const go = await ui.confirm(
|
|
1773
|
+
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
1774
|
+
[
|
|
1775
|
+
toCreate.length > 0
|
|
1776
|
+
? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
|
|
1777
|
+
: "Creates no labels.",
|
|
1778
|
+
`Writes ${path}, prepares a paused state database, then runs a paused daemon smoke.`,
|
|
1779
|
+
runtime.service.action === "keep"
|
|
1780
|
+
? `Keeps the staged systemd unit at ${runtime.service.path}.`
|
|
1781
|
+
: `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
|
|
1782
|
+
runtime.tick === undefined
|
|
1783
|
+
? ""
|
|
1784
|
+
: runtime.tick.action === "keep"
|
|
1785
|
+
? `Keeps the external heartbeat config at ${runtime.tick.path}.`
|
|
1786
|
+
: `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
|
|
1787
|
+
answers.writeOrchestratorBrief
|
|
1788
|
+
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
1789
|
+
: "",
|
|
1790
|
+
project.escalation.orchestrator === "external"
|
|
1791
|
+
? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
|
|
1792
|
+
: "Dispatch resumes after the smoke succeeds.",
|
|
1793
|
+
"Issues are only claimed after every setup gate succeeds.",
|
|
1794
|
+
]
|
|
1795
|
+
.filter((s) => s.length > 0)
|
|
1796
|
+
.join(" "),
|
|
1797
|
+
);
|
|
1798
|
+
if (!go) {
|
|
1799
|
+
ui.notify("Left untouched — no labels created, no config written, nothing armed.", "info");
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
1804
|
+
// partially applied setup unable to claim work.
|
|
1805
|
+
prepareConductor(project.name);
|
|
1806
|
+
const created = await createMissingLabels(answers.trackerRepo, labels);
|
|
1807
|
+
saveConfig(nextConfig);
|
|
1808
|
+
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
|
|
1809
|
+
const runtimeFiles = writeHostRuntime(runtime);
|
|
1810
|
+
const smoke = await runSetupSmoke(project.name);
|
|
1811
|
+
let smokeLine =
|
|
1812
|
+
`paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
|
|
1813
|
+
`stored status for ${smoke.status.project}`;
|
|
1814
|
+
let restartVia: "systemctl" | "cli" | undefined;
|
|
1815
|
+
if (smoke.mode === "existing") {
|
|
1816
|
+
if (smoke.status.liveWorkers > 0) {
|
|
1817
|
+
// Runs are live: never yank the daemon. Print the reload command; the new
|
|
1818
|
+
// project only joins the multi-tenant loop after restart (#319).
|
|
1819
|
+
ui.notify(
|
|
1820
|
+
[
|
|
1821
|
+
`Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
|
|
1822
|
+
"Dispatch remains paused. Let those workers finish.",
|
|
1823
|
+
"Then run `omp-conductor restart --now`.",
|
|
1824
|
+
project.escalation.orchestrator === "external"
|
|
1825
|
+
? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume --project ${project.name}\`.`
|
|
1826
|
+
: `Then run \`omp-conductor resume --project ${project.name}\`.`,
|
|
1827
|
+
].join("\n"),
|
|
1828
|
+
"warning",
|
|
1829
|
+
);
|
|
1830
|
+
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
// No live workers: still prefer an explicit reload when this run *added* a
|
|
1834
|
+
// neighbour — auto-restart would bounce every other project's heartbeat for
|
|
1835
|
+
// a config change they did not ask for. Amends of the only/same project keep
|
|
1836
|
+
// the old auto-restart so a first-time install still comes up alone.
|
|
1837
|
+
if (added === true) {
|
|
1838
|
+
ui.notify(
|
|
1839
|
+
[
|
|
1840
|
+
"Setup files are updated. The running daemon does not serve the new project until it reloads.",
|
|
1841
|
+
"No live workers — safe to reload now:",
|
|
1842
|
+
" omp-conductor restart --now",
|
|
1843
|
+
].join("\n"),
|
|
1844
|
+
"info",
|
|
1845
|
+
);
|
|
1846
|
+
} else {
|
|
1847
|
+
const restarted = await restartDaemon({ project: project.name });
|
|
1848
|
+
restartVia = restarted.via;
|
|
1849
|
+
smokeLine =
|
|
1850
|
+
`existing /healthz and stored status; restarted through ${restarted.via}; ` +
|
|
1851
|
+
`new /healthz on :${restarted.record.port}`;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
let armLine = "embedded orchestrator — no heartbeat arm marker";
|
|
1856
|
+
if (project.escalation.orchestrator === "external") {
|
|
1857
|
+
ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
1858
|
+
try {
|
|
1859
|
+
armLine = await ensureSetupArm(project.name);
|
|
1860
|
+
} catch (err) {
|
|
1861
|
+
ui.notify(
|
|
1862
|
+
[
|
|
1863
|
+
"Setup files passed the paused daemon smoke, but the fleet remains held.",
|
|
1864
|
+
err instanceof Error ? err.message : String(err),
|
|
1865
|
+
`Start the external orchestrator in ${project.workspaceRoot}, then run \`omp-conductor arm --project ${project.name}\`.`,
|
|
1866
|
+
"After the arm proof succeeds, run `omp-conductor resume`.",
|
|
1867
|
+
].join("\n"),
|
|
1868
|
+
"warning",
|
|
1869
|
+
);
|
|
1870
|
+
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
setPaused(false, undefined, project.name);
|
|
1875
|
+
|
|
1876
|
+
ui.notify(
|
|
1877
|
+
[
|
|
1878
|
+
created.length > 0 ? `Created label(s): ${created.join(", ")}` : "All required labels already existed.",
|
|
1879
|
+
`Wrote ${path}; dispatch is ready.`,
|
|
1880
|
+
briefPath === undefined
|
|
1881
|
+
? "Kept the existing orchestrator brief."
|
|
1882
|
+
: `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
|
|
1883
|
+
runtimeFiles.length === 0
|
|
1884
|
+
? "Host runtime files were already current."
|
|
1885
|
+
: `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
|
|
1886
|
+
`Smoke passed: ${smokeLine}.`,
|
|
1887
|
+
`Heartbeat: ${armLine}.`,
|
|
1888
|
+
"",
|
|
1889
|
+
"Use the documented toy-issue drill to prove one complete worker path.",
|
|
1890
|
+
].join("\n"),
|
|
1891
|
+
"info",
|
|
1892
|
+
);
|
|
1893
|
+
|
|
1894
|
+
// The two installs setup used to only print for the operator to retype.
|
|
1895
|
+
// Offered rather than assumed: each needs root, and an operator who wants to
|
|
1896
|
+
// read the unit before it is enabled must be able to decline and run the verb
|
|
1897
|
+
// later. `undefined` — a dismissed prompt — is a skip, not a cancel: the config
|
|
1898
|
+
// is already written by this point, so there is nothing left to abandon.
|
|
1899
|
+
//
|
|
1900
|
+
// Gated on `installedAction`, never on `service.action`. The staged copy is
|
|
1901
|
+
// written by this very run, so it is always current by now — every fleet
|
|
1902
|
+
// configured before the install was executed would have been told the unit
|
|
1903
|
+
// matched and never offered the install, which is precisely the population that
|
|
1904
|
+
// has never installed one.
|
|
1905
|
+
if (runtime.installedAction === "keep") {
|
|
1906
|
+
ui.notify(`The unit systemd reads (${runtime.installedPath}) already matches this config.`, "info");
|
|
1907
|
+
} else if (platform() !== "linux") {
|
|
1908
|
+
// No systemctl to run. The staged file is real and is what an operator copies
|
|
1909
|
+
// to the box that will run it, so say where it is rather than offer an install
|
|
1910
|
+
// that could only refuse.
|
|
1911
|
+
ui.notify(
|
|
1912
|
+
`Staged the unit at ${runtime.service.path}. systemd install is Linux-only — copy it to the fleet host and run \`omp-conductor setup host\` there.`,
|
|
1913
|
+
"info",
|
|
1914
|
+
);
|
|
1915
|
+
} else {
|
|
1916
|
+
const install = await ui.confirm(
|
|
1917
|
+
"Install and start the supervised daemon now?",
|
|
1918
|
+
`${runtime.installedAction === "create" ? "Installs" : "Updates"} ${runtime.installedPath} from ` +
|
|
1919
|
+
`${runtime.service.path}, then enables and restarts it. Needs root, one step at a time, and shows every ` +
|
|
1920
|
+
"command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
|
|
1921
|
+
);
|
|
1922
|
+
if (install === true)
|
|
1923
|
+
await runHostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
|
|
1924
|
+
else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
if (graphRepos(project).length > 0) {
|
|
1928
|
+
const wantsGraph = await ui.confirm(
|
|
1929
|
+
"Install the code-graph indexes now?",
|
|
1930
|
+
"Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
|
|
1931
|
+
"indexing run so the first fetch happens while you watch. Minutes per repo. " +
|
|
1932
|
+
"`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
|
|
1933
|
+
);
|
|
1934
|
+
if (wantsGraph === true) await runGraphInstall(project, ui);
|
|
1935
|
+
else
|
|
1936
|
+
ui.notify(
|
|
1937
|
+
"Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
|
|
1938
|
+
"info",
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Always print: first install, amend, and add-a-project all need the operator
|
|
1943
|
+
// to create/verify the herdr pane. Added projects especially — the CLI wrote
|
|
1944
|
+
// tick + config but cannot start a herdr agent (#319).
|
|
1945
|
+
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1946
|
+
}
|