omp-conductor 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -169
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +9 -5
- package/src/briefs/policy.md +4 -4
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +235 -199
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1046 -796
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +442 -374
- package/src/escalate.ts +43 -3
- package/src/fleet.ts +317 -43
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +298 -39
- package/src/privileged.ts +264 -0
- package/src/reports.ts +1 -1
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/{plugin.ts → setup-wizard.ts} +790 -465
- package/src/setup.ts +264 -20
- package/src/types.ts +2 -2
- package/src/upgrade.ts +44 -10
- package/src/verbs/server.ts +32 -9
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +6 -1
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
|
@@ -1,52 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The setup wizard: prompts, per-area amend, and the one-writer apply sequence.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
12
|
*/
|
|
13
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
-
import {
|
|
14
|
+
import { platform } from "node:os";
|
|
15
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
15
16
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
|
|
17
|
+
configPath,
|
|
18
|
+
expandHome,
|
|
19
|
+
findProject,
|
|
20
|
+
loadConfig,
|
|
21
|
+
resolveCaps,
|
|
22
|
+
saveConfig,
|
|
23
|
+
} from "./config.ts";
|
|
24
|
+
import { hostRamBytes, recommendedMaxWorkers, workerOvercommit } from "./host.ts";
|
|
24
25
|
import {
|
|
25
|
-
isPaused,
|
|
26
26
|
prepareConductor,
|
|
27
27
|
previewProject,
|
|
28
28
|
setPaused,
|
|
29
29
|
type QueuePreview,
|
|
30
30
|
} from "./daemon.ts";
|
|
31
|
-
import {
|
|
32
|
-
armTicks,
|
|
33
|
-
clearPaneHalt,
|
|
34
|
-
disarmTicks,
|
|
35
|
-
halt,
|
|
36
|
-
haltWithPane,
|
|
37
|
-
hold,
|
|
38
|
-
releaseHold,
|
|
39
|
-
renderStatus,
|
|
40
|
-
} from "./fleet.ts";
|
|
31
|
+
import { armTicks, telegramStateDir } from "./fleet.ts";
|
|
41
32
|
import { restartDaemon } from "./lifecycle.ts";
|
|
42
|
-
|
|
43
|
-
import { defaultGraphRoot } from "./graph.ts";
|
|
33
|
+
import { defaultGraphRoot, graphRepos } from "./graph.ts";
|
|
44
34
|
import {
|
|
45
35
|
formatHostRuntimePlan,
|
|
46
36
|
planHostRuntime,
|
|
37
|
+
totalConfiguredWorkers,
|
|
47
38
|
runSetupSmoke,
|
|
39
|
+
SYSTEMD_UNIT_DIR,
|
|
48
40
|
writeHostRuntime,
|
|
49
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";
|
|
50
44
|
import {
|
|
51
45
|
AMEND_AREAS,
|
|
52
46
|
BASE_FRESHNESS_CHOICES,
|
|
@@ -61,7 +55,6 @@ import {
|
|
|
61
55
|
answersFromProject,
|
|
62
56
|
briefPathForProject,
|
|
63
57
|
policyPathForProject,
|
|
64
|
-
renderFloorForProject,
|
|
65
58
|
buildConfig,
|
|
66
59
|
checkTokenScopes,
|
|
67
60
|
createMissingLabels,
|
|
@@ -70,12 +63,13 @@ import {
|
|
|
70
63
|
formatGates,
|
|
71
64
|
orchestratorBriefPath,
|
|
72
65
|
planLabels,
|
|
73
|
-
renderBriefForProject,
|
|
74
66
|
summariseAmend,
|
|
75
67
|
summarisePlan,
|
|
76
68
|
writeOrchestratorBrief,
|
|
77
69
|
type AmendAreaId,
|
|
70
|
+
type OperatorJudgment,
|
|
78
71
|
type SetupAnswers,
|
|
72
|
+
type ProbedProse,
|
|
79
73
|
} from "./setup.ts";
|
|
80
74
|
import {
|
|
81
75
|
BASE_FRESHNESS,
|
|
@@ -94,95 +88,10 @@ import {
|
|
|
94
88
|
type ProjectConfig,
|
|
95
89
|
type ProjectPolicy,
|
|
96
90
|
type ReleaseRequirement,
|
|
97
|
-
type ReportScope,
|
|
98
91
|
type ReportScopeChoice,
|
|
99
92
|
type ResolvedGrants,
|
|
100
93
|
} from "./types.ts";
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* The slice of the omp extension API this plugin actually touches, mirroring
|
|
104
|
-
* `RegisteredCommand` / `ExtensionUIContext` from `@oh-my-pi/pi-coding-agent`.
|
|
105
|
-
*
|
|
106
|
-
* Declared here rather than imported because the harness is a peer dependency:
|
|
107
|
-
* the package has to type-check without it installed. Structural typing means
|
|
108
|
-
* the real API object satisfies this on the way in, and narrowing the surface
|
|
109
|
-
* to the five members the wizard uses keeps the coupling visible.
|
|
110
|
-
*/
|
|
111
|
-
interface Completion {
|
|
112
|
-
value: string;
|
|
113
|
-
label: string;
|
|
114
|
-
description?: string;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
interface CommandContext {
|
|
118
|
-
ui: {
|
|
119
|
-
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
120
|
-
confirm(title: string, message: string): Promise<boolean>;
|
|
121
|
-
/**
|
|
122
|
-
* Single-line text prompt. Resolves `undefined` when the operator dismisses
|
|
123
|
-
* the dialog, which the wizard treats as "abandon, change nothing".
|
|
124
|
-
*
|
|
125
|
-
* The harness has no pre-filled variant, so `placeholder` carries the
|
|
126
|
-
* default and submitting an empty line accepts it.
|
|
127
|
-
*/
|
|
128
|
-
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
129
|
-
/**
|
|
130
|
-
* Single-choice list. Resolves the chosen option's **label**, or
|
|
131
|
-
* `undefined` when the operator dismisses it — so callers map labels back to
|
|
132
|
-
* their own values rather than trusting the index.
|
|
133
|
-
*/
|
|
134
|
-
select(
|
|
135
|
-
title: string,
|
|
136
|
-
options: { label: string; description?: string }[],
|
|
137
|
-
dialogOptions?: { initialIndex?: number },
|
|
138
|
-
): Promise<string | undefined>;
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
interface PluginApi {
|
|
143
|
-
registerCommand(
|
|
144
|
-
name: string,
|
|
145
|
-
options: {
|
|
146
|
-
description?: string;
|
|
147
|
-
getArgumentCompletions?: (argumentPrefix: string) => Completion[] | null;
|
|
148
|
-
handler: (args: string, ctx: CommandContext) => Promise<void>;
|
|
149
|
-
},
|
|
150
|
-
): void;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const SUBCOMMANDS: Completion[] = [
|
|
154
|
-
{
|
|
155
|
-
value: "setup",
|
|
156
|
-
label: "setup",
|
|
157
|
-
description: "wizard: config, labels, dry run, then arm — or amend one area of a configured project",
|
|
158
|
-
},
|
|
159
|
-
{ value: "status", label: "status", description: "layered fleet report: dispatch, ticks, pane, herdr, daemon" },
|
|
160
|
-
{ value: "hold", label: "hold", description: "soft stop: pause claiming AND disarm ticks" },
|
|
161
|
-
{ value: "halt", label: "halt", description: "hold + stop dispatch daemon; pass --pane to pin recovery off" },
|
|
162
|
-
{ value: "arm", label: "arm", description: "proof-gated: inbound Telegram round-trip, then write arm marker" },
|
|
163
|
-
{ value: "disarm", label: "disarm", description: "remove arm marker so ticks skip" },
|
|
164
|
-
{ value: "release-pane", label: "release-pane", description: "clear halt --pane recovery pin" },
|
|
165
|
-
{ value: "pause", label: "pause", description: "stop claiming only (ticks keep firing if armed); prefer hold" },
|
|
166
|
-
{ value: "resume", label: "resume", description: "clear pause only — does not re-arm" },
|
|
167
|
-
{
|
|
168
|
-
value: "brief-upgrade",
|
|
169
|
-
label: "brief-upgrade",
|
|
170
|
-
description: "check ORCHESTRATOR.md against the brief this version ships",
|
|
171
|
-
},
|
|
172
|
-
];
|
|
173
|
-
|
|
174
|
-
const USAGE = [
|
|
175
|
-
"/conductor setup [project] create a project, or amend one area of one you already have",
|
|
176
|
-
"/conductor status [project] layered fleet report (dispatch, ticks, pane, herdr, daemon)",
|
|
177
|
-
"/conductor hold [project] soft stop: pause claiming AND disarm ticks",
|
|
178
|
-
"/conductor halt [--pane] [project] hold + stop daemon; --pane pins conductor recovery off only",
|
|
179
|
-
"/conductor arm [project] proof-gated inbound Telegram round-trip, then arm ticks",
|
|
180
|
-
"/conductor disarm [project] remove arm marker (ticks skip)",
|
|
181
|
-
"/conductor release-pane [project] clear halt --pane recovery pin",
|
|
182
|
-
"/conductor pause stop claiming only (prefer hold)",
|
|
183
|
-
"/conductor resume clear pause only — does not re-arm",
|
|
184
|
-
"/conductor brief-upgrade [project] check ORCHESTRATOR.md against the shipped brief",
|
|
185
|
-
].join("\n");
|
|
94
|
+
import type { WizardUi } from "./wizard-ui.ts";
|
|
186
95
|
|
|
187
96
|
/**
|
|
188
97
|
* Dismissing any dialog abandons the whole wizard.
|
|
@@ -207,29 +116,43 @@ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
|
207
116
|
* takes it, because the harness has no pre-filled input dialog — so "Enter
|
|
208
117
|
* accepts what you see" is the contract the whole wizard is built on.
|
|
209
118
|
*/
|
|
210
|
-
async function ask(
|
|
211
|
-
const raw = await
|
|
119
|
+
async function ask(ui: WizardUi, title: string, fallback: string): Promise<string> {
|
|
120
|
+
const raw = await ui.input(title, fallback.length > 0 ? fallback : undefined);
|
|
212
121
|
if (raw === undefined) throw new Cancelled();
|
|
213
122
|
const trimmed = raw.trim();
|
|
214
123
|
return trimmed.length > 0 ? trimmed : fallback;
|
|
215
124
|
}
|
|
216
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
|
+
|
|
217
140
|
/**
|
|
218
141
|
* Re-asks until the answer passes `check`, which returns the complaint or
|
|
219
142
|
* `undefined`. Bounded at three tries: a dialog that cannot be escaped is worse
|
|
220
143
|
* than one that gives up and leaves the config alone.
|
|
221
144
|
*/
|
|
222
145
|
async function askValid(
|
|
223
|
-
|
|
146
|
+
ui: WizardUi,
|
|
224
147
|
title: string,
|
|
225
148
|
fallback: string,
|
|
226
149
|
check: (value: string) => string | undefined,
|
|
227
150
|
): Promise<string> {
|
|
228
151
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
229
|
-
const value = await ask(
|
|
152
|
+
const value = await ask(ui, title, fallback);
|
|
230
153
|
const problem = check(value);
|
|
231
154
|
if (problem === undefined) return value;
|
|
232
|
-
|
|
155
|
+
ui.notify(problem, "warning");
|
|
233
156
|
}
|
|
234
157
|
throw new Cancelled();
|
|
235
158
|
}
|
|
@@ -237,11 +160,11 @@ async function askValid(
|
|
|
237
160
|
|
|
238
161
|
/** A cap. Unparseable input keeps the current value rather than writing a NaN
|
|
239
162
|
* the validator would later reject — the operator sees why, immediately. */
|
|
240
|
-
async function askNumber(
|
|
241
|
-
const raw = await ask(
|
|
163
|
+
async function askNumber(ui: WizardUi, title: string, fallback: number): Promise<number> {
|
|
164
|
+
const raw = await ask(ui, title, String(fallback));
|
|
242
165
|
const value = Number(raw);
|
|
243
166
|
if (!Number.isFinite(value) || value < 0) {
|
|
244
|
-
|
|
167
|
+
ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning");
|
|
245
168
|
return fallback;
|
|
246
169
|
}
|
|
247
170
|
return value;
|
|
@@ -253,16 +176,16 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
|
|
|
253
176
|
* can turn the money brake off without writing a magic 0 (which is a hard stop).
|
|
254
177
|
*/
|
|
255
178
|
async function askSpendCap(
|
|
256
|
-
|
|
179
|
+
ui: WizardUi,
|
|
257
180
|
title: string,
|
|
258
181
|
fallback: number | null,
|
|
259
182
|
): Promise<number | null> {
|
|
260
183
|
const seed = fallback === null ? "" : String(fallback);
|
|
261
|
-
const raw = (await ask(
|
|
184
|
+
const raw = (await ask(ui, title, seed)).trim().toLowerCase();
|
|
262
185
|
if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null;
|
|
263
186
|
const value = Number(raw);
|
|
264
187
|
if (!Number.isFinite(value) || value < 0) {
|
|
265
|
-
|
|
188
|
+
ui.notify(
|
|
266
189
|
`"${raw}" is not a non-negative number or blank — keeping ${fallback === null ? "no cap" : fallback}.`,
|
|
267
190
|
"warning",
|
|
268
191
|
);
|
|
@@ -278,18 +201,23 @@ async function askSpendCap(
|
|
|
278
201
|
*
|
|
279
202
|
* ponytail: the ceiling is a command containing a comma or a literal " @ ",
|
|
280
203
|
* which this would split wrongly. Rare in a lint or test invocation, and the
|
|
281
|
-
* config file is hand-editable. Upgrade path is `
|
|
204
|
+
* config file is hand-editable. Upgrade path is `ui.editor()`, a real
|
|
282
205
|
* multi-line buffer, once someone hits it.
|
|
283
206
|
*/
|
|
284
207
|
async function askGates(
|
|
285
|
-
|
|
208
|
+
ui: WizardUi,
|
|
286
209
|
repoName: string,
|
|
287
210
|
seed: { cmd: string; cwd: string }[],
|
|
211
|
+
probed: { cmd: string; cwd: string }[] = [],
|
|
288
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.
|
|
289
217
|
const raw = await ask(
|
|
290
|
-
|
|
218
|
+
ui,
|
|
291
219
|
`Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
|
|
292
|
-
formatGates(seed),
|
|
220
|
+
formatGates(seed.length > 0 ? seed : probed),
|
|
293
221
|
);
|
|
294
222
|
|
|
295
223
|
const gates: { cmd: string; cwd: string }[] = [];
|
|
@@ -304,7 +232,7 @@ async function askGates(
|
|
|
304
232
|
if (gates.length === 0) {
|
|
305
233
|
// Loud, because an unattended push with no gate is how a lint failure
|
|
306
234
|
// reaches the runners at 03:00 with nobody watching.
|
|
307
|
-
|
|
235
|
+
ui.notify(`No gates for ${repoName} — nothing will be verified before a push.`, "warning");
|
|
308
236
|
}
|
|
309
237
|
return gates;
|
|
310
238
|
}
|
|
@@ -315,10 +243,10 @@ async function askGates(
|
|
|
315
243
|
* means silence. The cursor starts on the current setting so Enter re-affirms
|
|
316
244
|
* it, the same contract every other prompt here has.
|
|
317
245
|
*/
|
|
318
|
-
async function askReportScope(
|
|
246
|
+
async function askReportScope(ui: WizardUi, current: ReportScopeChoice): Promise<ReportScopeChoice> {
|
|
319
247
|
const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
|
|
320
248
|
const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
|
|
321
|
-
const picked = await
|
|
249
|
+
const picked = await ui.select("What should the orchestrator report unprompted?", options, {
|
|
322
250
|
initialIndex: at === -1 ? 0 : at,
|
|
323
251
|
});
|
|
324
252
|
if (picked === undefined) throw new Cancelled();
|
|
@@ -328,7 +256,7 @@ async function askReportScope(ctx: CommandContext, current: ReportScopeChoice):
|
|
|
328
256
|
// The harness answers with a label we did not offer only if the dialog
|
|
329
257
|
// contract changed under us; keeping the current scope is the answer that
|
|
330
258
|
// changes nothing, and it is said out loud rather than assumed.
|
|
331
|
-
|
|
259
|
+
ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
|
|
332
260
|
return current;
|
|
333
261
|
}
|
|
334
262
|
return choice.scope;
|
|
@@ -347,14 +275,14 @@ async function askReportScope(ctx: CommandContext, current: ReportScopeChoice):
|
|
|
347
275
|
* lookup table that could disagree with the vocabulary it was built from.
|
|
348
276
|
*/
|
|
349
277
|
async function askLiteral<T extends string>(
|
|
350
|
-
|
|
278
|
+
ui: WizardUi,
|
|
351
279
|
title: string,
|
|
352
280
|
values: readonly T[],
|
|
353
281
|
described: { readonly [K in T]: string },
|
|
354
282
|
current: T,
|
|
355
283
|
): Promise<T> {
|
|
356
284
|
const at = values.findIndex((v) => v === current);
|
|
357
|
-
const picked = await
|
|
285
|
+
const picked = await ui.select(
|
|
358
286
|
title,
|
|
359
287
|
values.map((v) => ({ label: v, description: described[v] })),
|
|
360
288
|
{ initialIndex: at === -1 ? 0 : at },
|
|
@@ -366,7 +294,7 @@ async function askLiteral<T extends string>(
|
|
|
366
294
|
// The harness answered with a label we never offered, which only happens if
|
|
367
295
|
// the dialog contract changed under us. Keeping the current value is the
|
|
368
296
|
// answer that changes nothing, and it is said out loud rather than assumed.
|
|
369
|
-
|
|
297
|
+
ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
|
|
370
298
|
return current;
|
|
371
299
|
}
|
|
372
300
|
return hit;
|
|
@@ -392,8 +320,8 @@ function parseNameList(answer: string): string[] {
|
|
|
392
320
|
|
|
393
321
|
/** Check names, artefacts, environments: open-ended lists this package cannot
|
|
394
322
|
* enumerate, so the only validation is the shape. */
|
|
395
|
-
async function askNameList(
|
|
396
|
-
return parseNameList(await ask(
|
|
323
|
+
async function askNameList(ui: WizardUi, title: string, seed: readonly string[]): Promise<string[]> {
|
|
324
|
+
return parseNameList(await ask(ui, title, formatNameList(seed)));
|
|
397
325
|
}
|
|
398
326
|
|
|
399
327
|
/**
|
|
@@ -405,19 +333,19 @@ async function askNameList(ctx: CommandContext, title: string, seed: readonly st
|
|
|
405
333
|
* that went ahead without it.
|
|
406
334
|
*/
|
|
407
335
|
async function askReleaseRequirements(
|
|
408
|
-
|
|
336
|
+
ui: WizardUi,
|
|
409
337
|
prior: readonly ReleaseRequirement[],
|
|
410
338
|
): Promise<ReleaseRequirement[]> {
|
|
411
339
|
const accepted = RELEASE_REQUIREMENTS.join(", ");
|
|
412
340
|
// The vocabulary, spelled out where it is being asked for. Built from the same
|
|
413
341
|
// data the validator reads, so a fifth requirement is offered here the moment
|
|
414
342
|
// it exists rather than staying invisible to everyone who did not read #129.
|
|
415
|
-
|
|
343
|
+
ui.notify(
|
|
416
344
|
RELEASE_REQUIREMENTS.map((r) => `${r} — ${RELEASE_REQUIREMENT_CHOICES[r]}`).join("\n"),
|
|
417
345
|
"info",
|
|
418
346
|
);
|
|
419
347
|
const answered = await askValid(
|
|
420
|
-
|
|
348
|
+
ui,
|
|
421
349
|
`Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`,
|
|
422
350
|
formatNameList(prior),
|
|
423
351
|
(value) => {
|
|
@@ -440,29 +368,29 @@ async function askReleaseRequirements(
|
|
|
440
368
|
* only reach by opening `config.json` is one that stays at its default while
|
|
441
369
|
* their prose says something else, which is the drift this key ended.
|
|
442
370
|
*/
|
|
443
|
-
async function askPolicyPreconditions(
|
|
371
|
+
async function askPolicyPreconditions(ui: WizardUi, prior: ProjectPolicy): Promise<ProjectPolicy> {
|
|
444
372
|
const merge = {
|
|
445
373
|
requiredChecks: await askNameList(
|
|
446
|
-
|
|
374
|
+
ui,
|
|
447
375
|
`Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`,
|
|
448
376
|
prior.merge.requiredChecks,
|
|
449
377
|
),
|
|
450
378
|
baseFreshness: await askLiteral(
|
|
451
|
-
|
|
379
|
+
ui,
|
|
452
380
|
"Merge — must the PR be level with its base?",
|
|
453
381
|
BASE_FRESHNESS,
|
|
454
382
|
BASE_FRESHNESS_CHOICES,
|
|
455
383
|
prior.merge.baseFreshness,
|
|
456
384
|
),
|
|
457
385
|
drafts: await askLiteral(
|
|
458
|
-
|
|
386
|
+
ui,
|
|
459
387
|
"Merge — draft pull requests",
|
|
460
388
|
DRAFT_POLICIES,
|
|
461
389
|
DRAFT_POLICY_CHOICES,
|
|
462
390
|
prior.merge.drafts,
|
|
463
391
|
),
|
|
464
392
|
whenBehindBase: await askLiteral(
|
|
465
|
-
|
|
393
|
+
ui,
|
|
466
394
|
"Merge — a green PR that fell behind its base",
|
|
467
395
|
BEHIND_BASE_ACTIONS,
|
|
468
396
|
BEHIND_BASE_CHOICES,
|
|
@@ -471,19 +399,19 @@ async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy)
|
|
|
471
399
|
};
|
|
472
400
|
|
|
473
401
|
const release = {
|
|
474
|
-
requires: await askReleaseRequirements(
|
|
402
|
+
requires: await askReleaseRequirements(ui, prior.release.requires),
|
|
475
403
|
requiredChecks: await askNameList(
|
|
476
|
-
|
|
404
|
+
ui,
|
|
477
405
|
`Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
|
|
478
406
|
prior.release.requiredChecks,
|
|
479
407
|
),
|
|
480
408
|
artefacts: await askNameList(
|
|
481
|
-
|
|
409
|
+
ui,
|
|
482
410
|
`Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
|
|
483
411
|
prior.release.artefacts,
|
|
484
412
|
),
|
|
485
413
|
environments: await askNameList(
|
|
486
|
-
|
|
414
|
+
ui,
|
|
487
415
|
`Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
|
|
488
416
|
prior.release.environments,
|
|
489
417
|
),
|
|
@@ -503,16 +431,18 @@ async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy)
|
|
|
503
431
|
* current grant is named in the question so the revoke is never a surprise.
|
|
504
432
|
*/
|
|
505
433
|
async function askAuthority(
|
|
506
|
-
|
|
434
|
+
ui: WizardUi,
|
|
507
435
|
prior: ProjectConfig["authority"],
|
|
508
436
|
): Promise<ProjectConfig["authority"]> {
|
|
509
|
-
const merge = await
|
|
437
|
+
const merge = await askYesNo(
|
|
438
|
+
ui,
|
|
510
439
|
"Merge authority",
|
|
511
440
|
"Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " +
|
|
512
441
|
"re-checked against the base branch first. Default: humans merge" +
|
|
513
442
|
`${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
514
443
|
);
|
|
515
|
-
const release = await
|
|
444
|
+
const release = await askYesNo(
|
|
445
|
+
ui,
|
|
516
446
|
"Release authority",
|
|
517
447
|
"Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
|
|
518
448
|
"procedure you write into its brief — and its brief forbids cutting one before you have. " +
|
|
@@ -541,6 +471,139 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
|
|
|
541
471
|
"This is the one grant that mutates a live environment rather than producing an artifact",
|
|
542
472
|
};
|
|
543
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
|
+
|
|
544
607
|
/**
|
|
545
608
|
* The mechanical tool gate, one confirm per shape.
|
|
546
609
|
*
|
|
@@ -554,12 +617,13 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
|
|
|
554
617
|
* that revoke is never a surprise.
|
|
555
618
|
*/
|
|
556
619
|
async function askReleaseGrants(
|
|
557
|
-
|
|
620
|
+
ui: WizardUi,
|
|
558
621
|
prior: ResolvedGrants,
|
|
559
622
|
): Promise<ResolvedGrants> {
|
|
560
623
|
const grants = { ...prior };
|
|
561
624
|
for (const shape of RELEASE_SHAPES) {
|
|
562
|
-
const open = await
|
|
625
|
+
const open = await askYesNo(
|
|
626
|
+
ui,
|
|
563
627
|
`Release tool gate — ${shape}`,
|
|
564
628
|
`Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` +
|
|
565
629
|
"operator brief carries the procedure it must follow. A worker session is refused this " +
|
|
@@ -577,8 +641,9 @@ async function askReleaseGrants(
|
|
|
577
641
|
* no such session exists leaves tier-1 escalations sitting in issue comments
|
|
578
642
|
* that nobody drains.
|
|
579
643
|
*/
|
|
580
|
-
async function askOrchestratorMode(
|
|
581
|
-
const external = await
|
|
644
|
+
async function askOrchestratorMode(ui: WizardUi, prior: OrchestratorMode): Promise<OrchestratorMode> {
|
|
645
|
+
const external = await askYesNo(
|
|
646
|
+
ui,
|
|
582
647
|
"Orchestrator session",
|
|
583
648
|
"Do you already run your own orchestrator session for this project — a visible TUI session, say? " +
|
|
584
649
|
"Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " +
|
|
@@ -601,12 +666,13 @@ async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode)
|
|
|
601
666
|
* that was indexed.
|
|
602
667
|
*/
|
|
603
668
|
async function askGraphRoot(
|
|
604
|
-
|
|
669
|
+
ui: WizardUi,
|
|
605
670
|
trackerRepo: string,
|
|
606
671
|
repoNames: string[],
|
|
607
672
|
prior: string | undefined,
|
|
608
673
|
): Promise<string | undefined> {
|
|
609
|
-
const wanted = await
|
|
674
|
+
const wanted = await askYesNo(
|
|
675
|
+
ui,
|
|
610
676
|
"Code-graph discovery",
|
|
611
677
|
"Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
|
|
612
678
|
'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
|
|
@@ -616,7 +682,7 @@ async function askGraphRoot(
|
|
|
616
682
|
if (!wanted) return undefined;
|
|
617
683
|
|
|
618
684
|
return await askValid(
|
|
619
|
-
|
|
685
|
+
ui,
|
|
620
686
|
`Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
|
|
621
687
|
prior ?? defaultGraphRoot(trackerRepo),
|
|
622
688
|
(v) =>
|
|
@@ -632,9 +698,10 @@ async function askGraphRoot(
|
|
|
632
698
|
* fleet's release and reporting policy ends up, so it is never overwritten by
|
|
633
699
|
* an operator who only meant to re-run setup.
|
|
634
700
|
*/
|
|
635
|
-
async function askOrchestratorBrief(
|
|
701
|
+
async function askOrchestratorBrief(ui: WizardUi, a: SetupAnswers): Promise<boolean> {
|
|
636
702
|
const path = orchestratorBriefPath(a);
|
|
637
|
-
const wanted = await
|
|
703
|
+
const wanted = await askYesNo(
|
|
704
|
+
ui,
|
|
638
705
|
`Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`,
|
|
639
706
|
`Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` +
|
|
640
707
|
`(Releases, Project context, Reporting, Amendments — yours to edit via the Learning loop). ` +
|
|
@@ -643,7 +710,8 @@ async function askOrchestratorBrief(ctx: CommandContext, a: SetupAnswers): Promi
|
|
|
643
710
|
if (!wanted) return false;
|
|
644
711
|
if (!existsSync(path)) return true;
|
|
645
712
|
|
|
646
|
-
return await
|
|
713
|
+
return await askYesNo(
|
|
714
|
+
ui,
|
|
647
715
|
`Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`,
|
|
648
716
|
`${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`,
|
|
649
717
|
);
|
|
@@ -666,7 +734,91 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
|
|
|
666
734
|
* prompts or the defaults they pre-fill from: the value shown is always the value
|
|
667
735
|
* that would otherwise be carried through.
|
|
668
736
|
*/
|
|
669
|
-
type AreaAsker = (
|
|
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
|
+
}
|
|
670
822
|
|
|
671
823
|
/**
|
|
672
824
|
* Where work comes from and where it lands: tracker, labels, routing prefix, and
|
|
@@ -674,16 +826,16 @@ type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>
|
|
|
674
826
|
* is one fact — the identity of the queue — and changing any part of it without
|
|
675
827
|
* seeing the rest is how a routing prefix stops matching its labels.
|
|
676
828
|
*/
|
|
677
|
-
const askTrackerAndRepos: AreaAsker = async (
|
|
829
|
+
const askTrackerAndRepos: AreaAsker = async (ui, a, probes) => {
|
|
678
830
|
const trackerRepo = await askValid(
|
|
679
|
-
|
|
831
|
+
ui,
|
|
680
832
|
"Tracker repo (owner/repo) — where ready issues live",
|
|
681
833
|
a.trackerRepo,
|
|
682
834
|
(v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
|
|
683
835
|
);
|
|
684
836
|
|
|
685
837
|
const queueLabel = await ask(
|
|
686
|
-
|
|
838
|
+
ui,
|
|
687
839
|
"Queue label — the human sign-off that makes an issue claimable",
|
|
688
840
|
a.queueLabel,
|
|
689
841
|
);
|
|
@@ -692,19 +844,20 @@ const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
|
|
|
692
844
|
// almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
|
|
693
845
|
// its reputation.
|
|
694
846
|
const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
|
|
695
|
-
const customiseStates = await
|
|
847
|
+
const customiseStates = await askYesNo(
|
|
848
|
+
ui,
|
|
696
849
|
"State labels",
|
|
697
850
|
`The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
|
|
698
851
|
`"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
|
|
699
852
|
);
|
|
700
853
|
if (customiseStates) {
|
|
701
|
-
stateLabels.inProgress = await ask(
|
|
702
|
-
stateLabels.blocked = await ask(
|
|
703
|
-
stateLabels.failed = await ask(
|
|
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);
|
|
704
857
|
}
|
|
705
858
|
|
|
706
859
|
const routingLabelPrefix = await ask(
|
|
707
|
-
|
|
860
|
+
ui,
|
|
708
861
|
"Routing label prefix — an issue picks its checkout with <prefix><repo>",
|
|
709
862
|
a.routingLabelPrefix,
|
|
710
863
|
);
|
|
@@ -713,25 +866,30 @@ const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
|
|
|
713
866
|
for (let i = 0; ; i++) {
|
|
714
867
|
const seed = a.targetRepos[i];
|
|
715
868
|
const name = await askValid(
|
|
716
|
-
|
|
869
|
+
ui,
|
|
717
870
|
`Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
|
|
718
871
|
seed?.name ?? "",
|
|
719
872
|
(v) => (v.length > 0 ? undefined : "A routing key is required, or no issue can reach this repo."),
|
|
720
873
|
);
|
|
721
874
|
const cloneUrl = await askValid(
|
|
722
|
-
|
|
875
|
+
ui,
|
|
723
876
|
`Clone URL for ${routingLabelPrefix}${name}`,
|
|
724
877
|
seed?.cloneUrl ?? "",
|
|
725
878
|
(v) => (v.length > 0 ? undefined : "A clone URL is required — the daemon mirrors it before every run."),
|
|
726
879
|
);
|
|
727
880
|
const defaultBranch = await ask(
|
|
728
|
-
|
|
881
|
+
ui,
|
|
729
882
|
`Default branch for ${name} — worktrees are cut from it and PRs target it`,
|
|
730
883
|
seed?.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
|
|
731
884
|
);
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const
|
|
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,
|
|
735
893
|
"Another repo?",
|
|
736
894
|
`${targetRepos.map((r) => r.name).join(", ")} configured. Add another checkout this project routes to?`,
|
|
737
895
|
);
|
|
@@ -748,15 +906,19 @@ const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
|
|
|
748
906
|
* clone URL does, and re-typing four repos to correct one lint invocation is the
|
|
749
907
|
* reason an operator edits config.json by hand instead.
|
|
750
908
|
*/
|
|
751
|
-
const askGatesOnly: AreaAsker = async (
|
|
909
|
+
const askGatesOnly: AreaAsker = async (ui, a, probes) => {
|
|
752
910
|
if (a.targetRepos.length === 0) {
|
|
753
|
-
|
|
911
|
+
ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning");
|
|
754
912
|
return a;
|
|
755
913
|
}
|
|
756
914
|
|
|
757
915
|
const targetRepos: SetupAnswers["targetRepos"] = [];
|
|
758
916
|
for (const r of a.targetRepos) {
|
|
759
|
-
|
|
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) });
|
|
760
922
|
}
|
|
761
923
|
return { ...a, targetRepos };
|
|
762
924
|
};
|
|
@@ -765,9 +927,9 @@ const askGatesOnly: AreaAsker = async (ctx, a) => {
|
|
|
765
927
|
* Whether workers get a code graph, and where its clones live. Asked after the
|
|
766
928
|
* repos in the full interview because the answer is derived per repo.
|
|
767
929
|
*/
|
|
768
|
-
const askGraph: AreaAsker = async (
|
|
930
|
+
const askGraph: AreaAsker = async (ui, a) => {
|
|
769
931
|
const graphRoot = await askGraphRoot(
|
|
770
|
-
|
|
932
|
+
ui,
|
|
771
933
|
a.trackerRepo,
|
|
772
934
|
a.targetRepos.map((r) => r.name),
|
|
773
935
|
a.graphRoot,
|
|
@@ -783,7 +945,7 @@ const askGraph: AreaAsker = async (ctx, a) => {
|
|
|
783
945
|
|
|
784
946
|
/** The hard ceilings. One confirm first, because the shipped defaults are the
|
|
785
947
|
* answer for anyone who has not measured their own runners. */
|
|
786
|
-
const askCaps: AreaAsker = async (
|
|
948
|
+
const askCaps: AreaAsker = async (ui, a) => {
|
|
787
949
|
const caps: Partial<Caps> = { ...a.caps };
|
|
788
950
|
const spendLabel =
|
|
789
951
|
DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`;
|
|
@@ -796,7 +958,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
796
958
|
workersDefault < DEFAULT_CAPS.maxConcurrentWorkers
|
|
797
959
|
? ` This host looks under 16 GiB RAM, so the worker default is ${workersDefault} instead of ${DEFAULT_CAPS.maxConcurrentWorkers}.`
|
|
798
960
|
: "";
|
|
799
|
-
const tuneCaps = await
|
|
961
|
+
const tuneCaps = await askYesNo(
|
|
962
|
+
ui,
|
|
800
963
|
"Caps",
|
|
801
964
|
`Defaults: ${workersDefault} workers, ` +
|
|
802
965
|
`${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
|
|
@@ -818,17 +981,17 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
818
981
|
// Spelled out rather than looped: adding a cap should fail to compile here,
|
|
819
982
|
// not silently go unasked.
|
|
820
983
|
caps.maxConcurrentWorkers = await askNumber(
|
|
821
|
-
|
|
984
|
+
ui,
|
|
822
985
|
"Max concurrent workers",
|
|
823
986
|
workersDefault,
|
|
824
987
|
);
|
|
825
988
|
caps.dailySpendUsd = await askSpendCap(
|
|
826
|
-
|
|
989
|
+
ui,
|
|
827
990
|
"Spend ceiling per rolling day (USD) — blank = no spend cap",
|
|
828
991
|
caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
|
|
829
992
|
);
|
|
830
993
|
const workerMaxTurns = await askNumber(
|
|
831
|
-
|
|
994
|
+
ui,
|
|
832
995
|
"Turn ceiling per worker",
|
|
833
996
|
caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
|
|
834
997
|
);
|
|
@@ -839,12 +1002,12 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
839
1002
|
Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
|
|
840
1003
|
);
|
|
841
1004
|
const workerMaxTurnsCeiling = await askNumber(
|
|
842
|
-
|
|
1005
|
+
ui,
|
|
843
1006
|
"Maximum turn ceiling for one issue",
|
|
844
1007
|
turnCeilingFallback,
|
|
845
1008
|
);
|
|
846
1009
|
if (workerMaxTurnsCeiling < workerMaxTurns) {
|
|
847
|
-
|
|
1010
|
+
ui.notify(
|
|
848
1011
|
`Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
|
|
849
1012
|
`${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
|
|
850
1013
|
"warning",
|
|
@@ -854,17 +1017,17 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
854
1017
|
caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
|
|
855
1018
|
}
|
|
856
1019
|
caps.workerWallClockMs = await askNumber(
|
|
857
|
-
|
|
1020
|
+
ui,
|
|
858
1021
|
"Wall-clock ceiling per worker (ms)",
|
|
859
1022
|
caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
|
|
860
1023
|
);
|
|
861
1024
|
caps.maxAttemptsPerIssue = await askNumber(
|
|
862
|
-
|
|
1025
|
+
ui,
|
|
863
1026
|
"Failed implementation attempts per issue before escalation",
|
|
864
1027
|
caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
|
|
865
1028
|
);
|
|
866
1029
|
caps.maxContinuationsPerIssue = await askNumber(
|
|
867
|
-
|
|
1030
|
+
ui,
|
|
868
1031
|
"Operational continuations per issue before escalation",
|
|
869
1032
|
caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue,
|
|
870
1033
|
);
|
|
@@ -873,8 +1036,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
|
|
|
873
1036
|
|
|
874
1037
|
/** Outside the caps block: a model is not a ceiling, and an operator who left
|
|
875
1038
|
* the caps alone may still want workers on a cheaper model. */
|
|
876
|
-
const askWorkerModel: AreaAsker = async (
|
|
877
|
-
const answered = await ask(
|
|
1039
|
+
const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
1040
|
+
const answered = await ask(ui, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
|
|
878
1041
|
const next: SetupAnswers = { ...a };
|
|
879
1042
|
if (answered.trim().length === 0) delete next.workerModel;
|
|
880
1043
|
else next.workerModel = answered.trim();
|
|
@@ -883,58 +1046,144 @@ const askWorkerModel: AreaAsker = async (ctx, a) => {
|
|
|
883
1046
|
|
|
884
1047
|
/** The two ownership questions, then the mechanical gate one shape at a time:
|
|
885
1048
|
* together they are what decides what an unattended fleet may do unasked. */
|
|
886
|
-
const askAuthorityArea: AreaAsker = async (
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
+
};
|
|
891
1059
|
|
|
892
1060
|
/** What a merge and a release must satisfy. Asked straight after the grants:
|
|
893
1061
|
* who may act, then under what conditions (#129). */
|
|
894
|
-
const askPolicy: AreaAsker = async (
|
|
1062
|
+
const askPolicy: AreaAsker = async (ui, a) => ({ ...a, policy: await askPolicyPreconditions(ui, a.policy) });
|
|
895
1063
|
|
|
896
1064
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
897
|
-
const askEscalation: AreaAsker = async (
|
|
1065
|
+
const askEscalation: AreaAsker = async (ui, a) => {
|
|
898
1066
|
const telegram = detectTelegram();
|
|
899
1067
|
let telegramChatId = a.telegramChatId;
|
|
900
1068
|
if (telegram.available && telegram.hasToken) {
|
|
901
1069
|
if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
|
|
902
|
-
const usePaired = await
|
|
1070
|
+
const usePaired = await askYesNo(
|
|
1071
|
+
ui,
|
|
903
1072
|
"Tier-2 escalations",
|
|
904
1073
|
`omp-telegram is paired with chat ${telegram.pairedOwnerId}. Page it when a run is stuck?`,
|
|
905
1074
|
);
|
|
906
1075
|
if (usePaired) telegramChatId = telegram.pairedOwnerId;
|
|
907
1076
|
} else {
|
|
908
|
-
const answered = await ask(
|
|
1077
|
+
const answered = await ask(ui, "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "");
|
|
909
1078
|
telegramChatId = answered.length > 0 ? answered : undefined;
|
|
910
1079
|
}
|
|
911
1080
|
} else {
|
|
912
1081
|
// Not an error: tier 2 degrades to a comment, which is the documented fallback.
|
|
913
|
-
|
|
1082
|
+
ui.notify(
|
|
914
1083
|
`No usable omp-telegram install at ${telegram.stateDir} — tier-2 escalations will comment on the issue.`,
|
|
915
1084
|
"info",
|
|
916
1085
|
);
|
|
917
1086
|
}
|
|
918
1087
|
|
|
919
|
-
const
|
|
1088
|
+
const telegramTopicId =
|
|
1089
|
+
telegramChatId === undefined
|
|
1090
|
+
? undefined
|
|
1091
|
+
: await askTelegramTopicId(ui, telegram.stateDir, a.telegramTopicId);
|
|
1092
|
+
|
|
1093
|
+
const fallbackToIssueComment = await askYesNo(
|
|
1094
|
+
ui,
|
|
920
1095
|
"Escalation fallback",
|
|
921
1096
|
"Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
|
|
922
1097
|
);
|
|
923
1098
|
|
|
924
|
-
const orchestratorMode = await askOrchestratorMode(
|
|
1099
|
+
const orchestratorMode = await askOrchestratorMode(ui, a.orchestratorMode);
|
|
925
1100
|
|
|
926
1101
|
const next: SetupAnswers = { ...a, fallbackToIssueComment, orchestratorMode };
|
|
927
1102
|
if (telegramChatId === undefined) delete next.telegramChatId;
|
|
928
1103
|
else next.telegramChatId = telegramChatId;
|
|
1104
|
+
if (telegramTopicId === undefined) delete next.telegramTopicId;
|
|
1105
|
+
else next.telegramTopicId = telegramTopicId;
|
|
929
1106
|
return next;
|
|
930
1107
|
};
|
|
931
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
|
+
|
|
932
1181
|
/** How loud the orchestrator is and when the operator permits interruptions. */
|
|
933
|
-
const askReporting: AreaAsker = async (
|
|
934
|
-
const reportScope = await askReportScope(
|
|
1182
|
+
const askReporting: AreaAsker = async (ui, a) => {
|
|
1183
|
+
const reportScope = await askReportScope(ui, a.reportScope);
|
|
935
1184
|
const continuous = "Continuous (24-hour interrupts)";
|
|
936
1185
|
const weekly = "Weekly availability window";
|
|
937
|
-
const mode = await
|
|
1186
|
+
const mode = await ui.select(
|
|
938
1187
|
"Operator availability",
|
|
939
1188
|
[
|
|
940
1189
|
{
|
|
@@ -977,7 +1226,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
|
|
|
977
1226
|
? "off"
|
|
978
1227
|
: a.dailyDigestAt ?? (a.digestCadence === "daily" ? "model-timed" : fallback);
|
|
979
1228
|
const schedule = await askValid(
|
|
980
|
-
|
|
1229
|
+
ui,
|
|
981
1230
|
'Daily rollup time in that timezone / digest cadence ("per-tick", "model-timed", "off", or 24h HH:MM)',
|
|
982
1231
|
shown,
|
|
983
1232
|
(value) =>
|
|
@@ -992,7 +1241,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
|
|
|
992
1241
|
|
|
993
1242
|
if (mode !== weekly) {
|
|
994
1243
|
if (mode !== continuous) {
|
|
995
|
-
|
|
1244
|
+
ui.notify(`Unrecognised availability choice "${mode}" — keeping 24-hour interrupts.`, "warning");
|
|
996
1245
|
}
|
|
997
1246
|
if (reportScope !== "quiet") return next;
|
|
998
1247
|
return await askDigestSchedule("model-timed");
|
|
@@ -1001,7 +1250,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
|
|
|
1001
1250
|
const defaultZone =
|
|
1002
1251
|
a.reportingTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
1003
1252
|
const reportingTimezone = await askValid(
|
|
1004
|
-
|
|
1253
|
+
ui,
|
|
1005
1254
|
"Operator timezone (IANA, for example Europe/London)",
|
|
1006
1255
|
defaultZone,
|
|
1007
1256
|
(value) => {
|
|
@@ -1014,7 +1263,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
|
|
|
1014
1263
|
},
|
|
1015
1264
|
);
|
|
1016
1265
|
const daysText = await askValid(
|
|
1017
|
-
|
|
1266
|
+
ui,
|
|
1018
1267
|
"Working weekdays (comma-separated: mon,tue,wed,thu,fri,sat,sun)",
|
|
1019
1268
|
a.availability?.days.join(",") ?? "mon,tue,wed,thu,fri",
|
|
1020
1269
|
(value) => {
|
|
@@ -1027,13 +1276,13 @@ const askReporting: AreaAsker = async (ctx, a) => {
|
|
|
1027
1276
|
);
|
|
1028
1277
|
const days = daysText.split(",").map((day) => day.trim().toLowerCase() as Weekday);
|
|
1029
1278
|
const start = await askValid(
|
|
1030
|
-
|
|
1279
|
+
ui,
|
|
1031
1280
|
"Availability starts (24h HH:MM)",
|
|
1032
1281
|
a.availability?.start ?? "09:00",
|
|
1033
1282
|
(value) => (/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : "Use 24h HH:MM."),
|
|
1034
1283
|
);
|
|
1035
1284
|
const end = await askValid(
|
|
1036
|
-
|
|
1285
|
+
ui,
|
|
1037
1286
|
"Availability ends (24h HH:MM)",
|
|
1038
1287
|
a.availability?.end ?? "17:00",
|
|
1039
1288
|
(value) =>
|
|
@@ -1044,7 +1293,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
|
|
|
1044
1293
|
: undefined,
|
|
1045
1294
|
);
|
|
1046
1295
|
const bypassText = await askValid(
|
|
1047
|
-
|
|
1296
|
+
ui,
|
|
1048
1297
|
`Quiet-hours bypass categories (comma-separated; "none" = none; choices: ${INTERRUPT_CATEGORIES.join(",")})`,
|
|
1049
1298
|
a.availability === undefined ? "fleet-stopped" : a.availability.bypass.join(",") || "none",
|
|
1050
1299
|
(value) => {
|
|
@@ -1070,7 +1319,7 @@ const askReporting: AreaAsker = async (ctx, a) => {
|
|
|
1070
1319
|
|
|
1071
1320
|
/** The operator's own brief. Asked last in the full interview, because the
|
|
1072
1321
|
* question quotes the path the rest of the answers derive. */
|
|
1073
|
-
const askBrief: AreaAsker = async (
|
|
1322
|
+
const askBrief: AreaAsker = async (ui, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ui, a) });
|
|
1074
1323
|
|
|
1075
1324
|
/**
|
|
1076
1325
|
* One dialog sequence per amend area, keyed so a new area cannot be added to
|
|
@@ -1081,30 +1330,47 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
|
1081
1330
|
gates: askGatesOnly,
|
|
1082
1331
|
// The two per-worker knobs the full interview separates with the authority
|
|
1083
1332
|
// grants; an amend has no reason to put anything between them.
|
|
1084
|
-
caps: async (
|
|
1085
|
-
graph: askGraph,
|
|
1333
|
+
caps: async (ui, a, probes) => await askWorkerModel(ui, await askCaps(ui, a, probes), probes),
|
|
1334
|
+
"code-graph": askGraph,
|
|
1086
1335
|
authority: askAuthorityArea,
|
|
1087
1336
|
policy: askPolicy,
|
|
1088
1337
|
escalation: askEscalation,
|
|
1089
1338
|
reporting: askReporting,
|
|
1090
|
-
brief
|
|
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),
|
|
1091
1347
|
};
|
|
1092
1348
|
|
|
1093
1349
|
/** The two ways to answer the first question a configured project gets. Labels,
|
|
1094
1350
|
* because the harness's select resolves to the label it displayed. */
|
|
1095
1351
|
const AMEND_ONE = "Change one area";
|
|
1096
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" };
|
|
1097
1362
|
|
|
1098
1363
|
/**
|
|
1099
1364
|
* The first question a re-run asks, and the reason amend mode exists: adding one
|
|
1100
|
-
* key should not cost twenty prompts.
|
|
1365
|
+
* key should not cost twenty prompts. Also the entry for adding a neighbour
|
|
1366
|
+
* project without leaving the wizard (#319).
|
|
1101
1367
|
*
|
|
1102
|
-
* Returns the area to amend,
|
|
1103
|
-
* when
|
|
1104
|
-
*
|
|
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.
|
|
1105
1371
|
*/
|
|
1106
|
-
async function chooseAmendArea(
|
|
1107
|
-
const mode = await
|
|
1372
|
+
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
|
|
1373
|
+
const mode = await ui.select(
|
|
1108
1374
|
`"${prior.name}" is already configured — what would you like to do?`,
|
|
1109
1375
|
[
|
|
1110
1376
|
{
|
|
@@ -1115,20 +1381,25 @@ async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promi
|
|
|
1115
1381
|
label: REINTERVIEW,
|
|
1116
1382
|
description: "the full interview, every prompt pre-filled with what is configured now",
|
|
1117
1383
|
},
|
|
1384
|
+
{
|
|
1385
|
+
label: ADD_PROJECT,
|
|
1386
|
+
description: "full interview for a new project; existing projects stay as they are",
|
|
1387
|
+
},
|
|
1118
1388
|
],
|
|
1119
1389
|
{ initialIndex: 0 },
|
|
1120
1390
|
);
|
|
1121
1391
|
if (mode === undefined) throw new Cancelled();
|
|
1392
|
+
if (mode === ADD_PROJECT) return { kind: "add-project" };
|
|
1122
1393
|
if (mode !== AMEND_ONE) {
|
|
1123
1394
|
// Either the operator chose the full interview, or the dialog answered with
|
|
1124
1395
|
// a label we never offered. Both land on today's behaviour, which is the one
|
|
1125
1396
|
// that cannot silently skip a question.
|
|
1126
|
-
if (mode !== REINTERVIEW)
|
|
1127
|
-
return
|
|
1397
|
+
if (mode !== REINTERVIEW) ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning");
|
|
1398
|
+
return { kind: "reinterview" };
|
|
1128
1399
|
}
|
|
1129
1400
|
|
|
1130
1401
|
const choices = amendChoices(prior);
|
|
1131
|
-
const picked = await
|
|
1402
|
+
const picked = await ui.select(
|
|
1132
1403
|
"Which area? Each row shows what it says now",
|
|
1133
1404
|
choices.map((c) => ({ label: c.label, description: c.description })),
|
|
1134
1405
|
{ initialIndex: 0 },
|
|
@@ -1139,10 +1410,10 @@ async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promi
|
|
|
1139
1410
|
if (chosen === undefined) {
|
|
1140
1411
|
// Guessing an area here would ask the wrong questions and carry the rest
|
|
1141
1412
|
// through as if they had been reviewed. Abandoning changes nothing.
|
|
1142
|
-
|
|
1413
|
+
ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning");
|
|
1143
1414
|
throw new Cancelled();
|
|
1144
1415
|
}
|
|
1145
|
-
return chosen.id;
|
|
1416
|
+
return { kind: "area", area: chosen.id };
|
|
1146
1417
|
}
|
|
1147
1418
|
|
|
1148
1419
|
/**
|
|
@@ -1154,33 +1425,50 @@ async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promi
|
|
|
1154
1425
|
* so the two flows cannot disagree about what an unanswered field is.
|
|
1155
1426
|
*/
|
|
1156
1427
|
async function collectAnswers(
|
|
1157
|
-
|
|
1428
|
+
ui: WizardUi,
|
|
1158
1429
|
prior: ProjectConfig | undefined,
|
|
1159
1430
|
projectArg: string | undefined,
|
|
1431
|
+
probes: SetupProbes,
|
|
1432
|
+
opts: { added?: boolean } = {},
|
|
1160
1433
|
): Promise<SetupAnswers> {
|
|
1161
|
-
|
|
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);
|
|
1162
1440
|
|
|
1163
1441
|
const projectName = await askValid(
|
|
1164
|
-
|
|
1442
|
+
ui,
|
|
1165
1443
|
"Project name",
|
|
1166
1444
|
projectArg ?? seed.projectName,
|
|
1167
|
-
(v) => (v.length > 0 ? undefined : "A name is required — it is how
|
|
1445
|
+
(v) => (v.length > 0 ? undefined : "A name is required — it is how `omp-conductor status --project <name>` finds this project."),
|
|
1168
1446
|
);
|
|
1169
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.
|
|
1170
1450
|
let a: SetupAnswers = { ...seed, projectName };
|
|
1171
|
-
|
|
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);
|
|
1172
1460
|
// Straight after the repos, because it is a fact about them: one clone per
|
|
1173
1461
|
// routed repo, under one root.
|
|
1174
|
-
a = await askGraph(
|
|
1175
|
-
a = await askCaps(
|
|
1176
|
-
a = await askAuthorityArea(
|
|
1177
|
-
a = await askPolicy(
|
|
1178
|
-
a = await askWorkerModel(
|
|
1179
|
-
a = await askEscalation(
|
|
1180
|
-
a = await askReporting(
|
|
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);
|
|
1181
1469
|
// Asked last, and asked with the real path in the question — which needs the
|
|
1182
1470
|
// rest of the answers to derive.
|
|
1183
|
-
return await askBrief(
|
|
1471
|
+
return await askBrief(ui, a, probes);
|
|
1184
1472
|
}
|
|
1185
1473
|
|
|
1186
1474
|
/** The dry run, rendered. Same routing code the loop uses, so this is what the
|
|
@@ -1209,6 +1497,8 @@ function formatPreview(p: QueuePreview): string[] {
|
|
|
1209
1497
|
export interface CollectedSetup {
|
|
1210
1498
|
answers: SetupAnswers;
|
|
1211
1499
|
amend?: { area: AmendAreaId; before: ProjectConfig };
|
|
1500
|
+
/** True when this run is adding a neighbour project (or first-run-for-name). */
|
|
1501
|
+
added?: boolean;
|
|
1212
1502
|
}
|
|
1213
1503
|
|
|
1214
1504
|
|
|
@@ -1231,74 +1521,206 @@ export async function ensureSetupArm(
|
|
|
1231
1521
|
* is judged on — on a host with no `gh` and no config.
|
|
1232
1522
|
*/
|
|
1233
1523
|
export async function collectSetup(
|
|
1234
|
-
|
|
1524
|
+
ui: WizardUi,
|
|
1235
1525
|
existing: ConductorConfig | undefined,
|
|
1236
1526
|
projectArg: string | undefined,
|
|
1527
|
+
areaArg: AmendAreaId | undefined,
|
|
1528
|
+
probes: SetupProbes,
|
|
1237
1529
|
): Promise<CollectedSetup> {
|
|
1238
1530
|
// Only a project that is already configured can be amended. A first run, or a
|
|
1239
1531
|
// name this config has never seen, goes straight into the full interview with
|
|
1240
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).
|
|
1241
1537
|
const prior = priorProject(existing, projectArg);
|
|
1242
|
-
if (prior === undefined)
|
|
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
|
+
}
|
|
1243
1583
|
|
|
1244
|
-
const
|
|
1245
|
-
if (
|
|
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
|
+
}
|
|
1246
1625
|
|
|
1247
|
-
return {
|
|
1626
|
+
return {
|
|
1627
|
+
answers: await AREA_ASKERS[choice.area](ui, answersFromProject(prior), probes),
|
|
1628
|
+
amend: { area: choice.area, before: prior },
|
|
1629
|
+
};
|
|
1248
1630
|
}
|
|
1249
1631
|
|
|
1250
1632
|
/**
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
1258
|
-
* An amend changes which questions are asked and what the summary leads with,
|
|
1259
|
-
* and nothing else: the same answers, the same `buildConfig`, the same single
|
|
1260
|
-
* confirm, the same dry run. One writer, one consent gate.
|
|
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.
|
|
1261
1639
|
*/
|
|
1262
|
-
|
|
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> {
|
|
1263
1668
|
const path = configPath();
|
|
1264
1669
|
// A config that exists but does not parse is a fault to report, never
|
|
1265
1670
|
// something to quietly replace: overwriting it would delete every project it
|
|
1266
1671
|
// describes. Absence, by contrast, is just the first run.
|
|
1267
1672
|
const existing = existsSync(path) ? loadConfig() : undefined;
|
|
1268
1673
|
if (existing === undefined) {
|
|
1269
|
-
|
|
1674
|
+
ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
|
|
1270
1675
|
}
|
|
1271
1676
|
|
|
1272
1677
|
let collected: CollectedSetup;
|
|
1273
1678
|
try {
|
|
1274
|
-
collected = await collectSetup(
|
|
1679
|
+
collected = await collectSetup(ui, existing, projectArg, areaArg, probes);
|
|
1275
1680
|
} catch (err) {
|
|
1276
1681
|
if (!(err instanceof Cancelled)) throw err;
|
|
1277
|
-
|
|
1682
|
+
ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
1278
1683
|
return;
|
|
1279
1684
|
}
|
|
1280
|
-
const { answers, amend } = collected;
|
|
1685
|
+
const { answers, amend, added } = collected;
|
|
1281
1686
|
|
|
1282
1687
|
const scopes = await checkTokenScopes();
|
|
1283
1688
|
if (!scopes.ok) {
|
|
1284
|
-
|
|
1689
|
+
ui.notify(
|
|
1285
1690
|
`Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
|
|
1286
1691
|
`Run \`gh auth refresh -s repo,project\`, then run setup again.`,
|
|
1287
1692
|
"error",
|
|
1288
1693
|
);
|
|
1289
1694
|
return;
|
|
1290
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) : {};
|
|
1291
1702
|
const labels = await planLabels(answers.trackerRepo, answers);
|
|
1292
1703
|
const telegram = detectTelegram();
|
|
1293
|
-
|
|
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
|
+
}
|
|
1294
1714
|
const project = findProject(nextConfig, answers.projectName);
|
|
1715
|
+
const totalWorkers = totalConfiguredWorkers(nextConfig);
|
|
1716
|
+
const overcommit = workerOvercommit(totalWorkers);
|
|
1295
1717
|
// The same project as it is configured right now, so a moved `workspaceRoot`
|
|
1296
1718
|
if (
|
|
1297
1719
|
project.escalation.orchestrator === "external" &&
|
|
1298
1720
|
!answers.writeOrchestratorBrief &&
|
|
1299
1721
|
(!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
|
|
1300
1722
|
) {
|
|
1301
|
-
|
|
1723
|
+
ui.notify(
|
|
1302
1724
|
`Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
|
|
1303
1725
|
`Run setup again and approve the brief write.`,
|
|
1304
1726
|
"error",
|
|
@@ -1309,20 +1731,22 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1309
1731
|
project,
|
|
1310
1732
|
resolveCaps(project, nextConfig.defaults),
|
|
1311
1733
|
telegram.stateDir,
|
|
1734
|
+
undefined,
|
|
1735
|
+
totalWorkers,
|
|
1312
1736
|
);
|
|
1313
1737
|
let queuePreview: string[];
|
|
1314
1738
|
try {
|
|
1315
1739
|
queuePreview = formatPreview(await previewProject(project));
|
|
1316
1740
|
} catch (err) {
|
|
1317
1741
|
const message = err instanceof Error ? err.message : String(err);
|
|
1318
|
-
|
|
1742
|
+
ui.notify(
|
|
1319
1743
|
`Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
|
|
1320
1744
|
"error",
|
|
1321
1745
|
);
|
|
1322
1746
|
return;
|
|
1323
1747
|
}
|
|
1324
1748
|
|
|
1325
|
-
|
|
1749
|
+
ui.notify(
|
|
1326
1750
|
[
|
|
1327
1751
|
// The delta first when there is one, then the whole plan: the confirm has
|
|
1328
1752
|
// to name every mutation it authorises, and a delta names none of them.
|
|
@@ -1331,6 +1755,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1331
1755
|
"",
|
|
1332
1756
|
formatHostRuntimePlan(runtime),
|
|
1333
1757
|
"",
|
|
1758
|
+
...(overcommit === undefined ? [] : [`WARNING: ${overcommit}`, ""]),
|
|
1334
1759
|
"Dry run against the PROPOSED config:",
|
|
1335
1760
|
...queuePreview,
|
|
1336
1761
|
"",
|
|
@@ -1340,7 +1765,11 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1340
1765
|
);
|
|
1341
1766
|
|
|
1342
1767
|
const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
|
|
1343
|
-
|
|
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(
|
|
1344
1773
|
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
1345
1774
|
[
|
|
1346
1775
|
toCreate.length > 0
|
|
@@ -1367,16 +1796,16 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1367
1796
|
.join(" "),
|
|
1368
1797
|
);
|
|
1369
1798
|
if (!go) {
|
|
1370
|
-
|
|
1799
|
+
ui.notify("Left untouched — no labels created, no config written, nothing armed.", "info");
|
|
1371
1800
|
return;
|
|
1372
1801
|
}
|
|
1373
1802
|
|
|
1374
1803
|
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
1375
1804
|
// partially applied setup unable to claim work.
|
|
1376
|
-
prepareConductor();
|
|
1805
|
+
prepareConductor(project.name);
|
|
1377
1806
|
const created = await createMissingLabels(answers.trackerRepo, labels);
|
|
1378
1807
|
saveConfig(nextConfig);
|
|
1379
|
-
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers) : undefined;
|
|
1808
|
+
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
|
|
1380
1809
|
const runtimeFiles = writeHostRuntime(runtime);
|
|
1381
1810
|
const smoke = await runSetupSmoke(project.name);
|
|
1382
1811
|
let smokeLine =
|
|
@@ -1385,33 +1814,51 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1385
1814
|
let restartVia: "systemctl" | "cli" | undefined;
|
|
1386
1815
|
if (smoke.mode === "existing") {
|
|
1387
1816
|
if (smoke.status.liveWorkers > 0) {
|
|
1388
|
-
|
|
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(
|
|
1389
1820
|
[
|
|
1390
1821
|
`Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
|
|
1391
1822
|
"Dispatch remains paused. Let those workers finish.",
|
|
1392
|
-
|
|
1823
|
+
"Then run `omp-conductor restart --now`.",
|
|
1393
1824
|
project.escalation.orchestrator === "external"
|
|
1394
|
-
? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume\`.`
|
|
1395
|
-
:
|
|
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}\`.`,
|
|
1396
1827
|
].join("\n"),
|
|
1397
1828
|
"warning",
|
|
1398
1829
|
);
|
|
1830
|
+
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1399
1831
|
return;
|
|
1400
1832
|
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
+
}
|
|
1406
1853
|
}
|
|
1407
1854
|
|
|
1408
1855
|
let armLine = "embedded orchestrator — no heartbeat arm marker";
|
|
1409
1856
|
if (project.escalation.orchestrator === "external") {
|
|
1410
|
-
|
|
1857
|
+
ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
1411
1858
|
try {
|
|
1412
1859
|
armLine = await ensureSetupArm(project.name);
|
|
1413
1860
|
} catch (err) {
|
|
1414
|
-
|
|
1861
|
+
ui.notify(
|
|
1415
1862
|
[
|
|
1416
1863
|
"Setup files passed the paused daemon smoke, but the fleet remains held.",
|
|
1417
1864
|
err instanceof Error ? err.message : String(err),
|
|
@@ -1420,12 +1867,13 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1420
1867
|
].join("\n"),
|
|
1421
1868
|
"warning",
|
|
1422
1869
|
);
|
|
1870
|
+
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1423
1871
|
return;
|
|
1424
1872
|
}
|
|
1425
1873
|
}
|
|
1426
|
-
setPaused(false);
|
|
1874
|
+
setPaused(false, undefined, project.name);
|
|
1427
1875
|
|
|
1428
|
-
|
|
1876
|
+
ui.notify(
|
|
1429
1877
|
[
|
|
1430
1878
|
created.length > 0 ? `Created label(s): ${created.join(", ")}` : "All required labels already existed.",
|
|
1431
1879
|
`Wrote ${path}; dispatch is ready.`,
|
|
@@ -1438,184 +1886,61 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
1438
1886
|
`Smoke passed: ${smokeLine}.`,
|
|
1439
1887
|
`Heartbeat: ${armLine}.`,
|
|
1440
1888
|
"",
|
|
1441
|
-
"On a systemd host, install and start the supervised daemon:",
|
|
1442
|
-
...(restartVia === "cli" ? [" omp-conductor stop"] : []),
|
|
1443
|
-
...runtime.installCommands.map((command) => ` ${command}`),
|
|
1444
|
-
"",
|
|
1445
|
-
"Without systemd, run `omp-conductor start`.",
|
|
1446
1889
|
"Use the documented toy-issue drill to prove one complete worker path.",
|
|
1447
1890
|
].join("\n"),
|
|
1448
1891
|
"info",
|
|
1449
1892
|
);
|
|
1450
|
-
}
|
|
1451
1893
|
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
+
}
|
|
1456
1926
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
+
}
|
|
1464
1941
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
break;
|
|
1470
|
-
|
|
1471
|
-
case "status":
|
|
1472
|
-
ctx.ui.notify(await renderStatus(project), "info");
|
|
1473
|
-
break;
|
|
1474
|
-
|
|
1475
|
-
case "hold": {
|
|
1476
|
-
const r = hold(project);
|
|
1477
|
-
ctx.ui.notify(
|
|
1478
|
-
`Held — claiming paused; ticks disarmed at ${r.disarmed.path}. Daemon and pane left running.`,
|
|
1479
|
-
"info",
|
|
1480
|
-
);
|
|
1481
|
-
break;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
case "halt": {
|
|
1485
|
-
if (withPane) {
|
|
1486
|
-
const r = await haltWithPane(project);
|
|
1487
|
-
const stop =
|
|
1488
|
-
r.stop.kind === "not-running"
|
|
1489
|
-
? "daemon was not running"
|
|
1490
|
-
: `daemon stopped (pid ${r.stop.pid})`;
|
|
1491
|
-
ctx.ui.notify(
|
|
1492
|
-
`Halted — ${stop}. Pane: ${r.pane.stopped} (${r.pane.detail}); recovery pinned at ${r.pane.pinPath}.`,
|
|
1493
|
-
"info",
|
|
1494
|
-
);
|
|
1495
|
-
} else {
|
|
1496
|
-
const r = await halt(project);
|
|
1497
|
-
const stop =
|
|
1498
|
-
r.stop.kind === "not-running"
|
|
1499
|
-
? "daemon was not running"
|
|
1500
|
-
: `daemon stopped (pid ${r.stop.pid})`;
|
|
1501
|
-
ctx.ui.notify(`Halted — ${stop}. Pane left running.`, "info");
|
|
1502
|
-
}
|
|
1503
|
-
break;
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
case "arm": {
|
|
1507
|
-
ctx.ui.notify("Arm: sending inbound Telegram challenge — reply in the bot DM…", "info");
|
|
1508
|
-
const r = await armTicks(project);
|
|
1509
|
-
ctx.ui.notify(`ARMED — owner ${r.owner}; marker ${r.path}`, "info");
|
|
1510
|
-
break;
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
case "disarm": {
|
|
1514
|
-
const r = disarmTicks(project);
|
|
1515
|
-
ctx.ui.notify(`Disarmed — ${r.path}`, "info");
|
|
1516
|
-
break;
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
case "release-pane": {
|
|
1520
|
-
const r = clearPaneHalt(project);
|
|
1521
|
-
ctx.ui.notify(
|
|
1522
|
-
r.wasHalted ? `Pane recovery pin cleared (${r.path}).` : `No pane recovery pin at ${r.path}.`,
|
|
1523
|
-
"info",
|
|
1524
|
-
);
|
|
1525
|
-
break;
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
case "pause":
|
|
1529
|
-
setPaused(true, { source: "pause", reason: "via /conductor pause" });
|
|
1530
|
-
ctx.ui.notify("Paused claiming only — ticks keep firing if armed. Prefer /conductor hold.", "info");
|
|
1531
|
-
break;
|
|
1532
|
-
|
|
1533
|
-
case "resume":
|
|
1534
|
-
releaseHold();
|
|
1535
|
-
ctx.ui.notify("Resumed claiming — did NOT re-arm. Run /conductor arm for ticks.", "info");
|
|
1536
|
-
break;
|
|
1537
|
-
|
|
1538
|
-
case "brief-upgrade": {
|
|
1539
|
-
const p = findProject(loadConfig(), project);
|
|
1540
|
-
const path = briefPathForProject(p);
|
|
1541
|
-
const rendered = renderBriefForProject(p);
|
|
1542
|
-
const layout = inspectBriefLayout(p.workspaceRoot, rendered);
|
|
1543
|
-
if (layout.kind === "missing") {
|
|
1544
|
-
ctx.ui.notify(
|
|
1545
|
-
`No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME}.`,
|
|
1546
|
-
"warning",
|
|
1547
|
-
);
|
|
1548
|
-
break;
|
|
1549
|
-
}
|
|
1550
|
-
if (layout.kind === "overlay") {
|
|
1551
|
-
ctx.ui.notify(
|
|
1552
|
-
formatBriefReport(path, layout, []),
|
|
1553
|
-
"info",
|
|
1554
|
-
);
|
|
1555
|
-
const repair = await ctx.ui.confirm(
|
|
1556
|
-
"Repair POLICY.md banner crumbs and recompose?",
|
|
1557
|
-
"Strips any leading HTML-comment leftovers from a pre-fix migrate, then recomposes ORCHESTRATOR.md from the package floor + POLICY.md.",
|
|
1558
|
-
);
|
|
1559
|
-
if (repair) {
|
|
1560
|
-
const repaired = repairPolicyBannerCrumbs({
|
|
1561
|
-
orchestratorPath: layout.orchestratorPath,
|
|
1562
|
-
policyPath: layout.policyPath,
|
|
1563
|
-
floor: renderFloorForProject(p),
|
|
1564
|
-
});
|
|
1565
|
-
ctx.ui.notify(
|
|
1566
|
-
repaired === undefined
|
|
1567
|
-
? "Recomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip."
|
|
1568
|
-
: formatMigrateResult(repaired),
|
|
1569
|
-
"info",
|
|
1570
|
-
);
|
|
1571
|
-
}
|
|
1572
|
-
break;
|
|
1573
|
-
}
|
|
1574
|
-
if (layout.kind === "legacy-bannered") {
|
|
1575
|
-
ctx.ui.notify(
|
|
1576
|
-
[
|
|
1577
|
-
`Legacy bannered brief at ${layout.orchestratorPath}.`,
|
|
1578
|
-
"Migrate the owned half into POLICY.md so the package floor refreshes each tick.",
|
|
1579
|
-
].join("\n"),
|
|
1580
|
-
"warning",
|
|
1581
|
-
);
|
|
1582
|
-
const migrate = await ctx.ui.confirm(
|
|
1583
|
-
"Migrate to POLICY.md overlay?",
|
|
1584
|
-
"Writes POLICY.md from everything below YOURS TO EDIT, recomposes ORCHESTRATOR.md from the package floor + that policy, and keeps backups.",
|
|
1585
|
-
);
|
|
1586
|
-
if (migrate) {
|
|
1587
|
-
const result = migrateToPolicy({
|
|
1588
|
-
orchestratorPath: layout.orchestratorPath,
|
|
1589
|
-
policyPath: policyPathForProject(p),
|
|
1590
|
-
floor: renderFloorForProject(p),
|
|
1591
|
-
owned: layout.owned,
|
|
1592
|
-
});
|
|
1593
|
-
ctx.ui.notify(formatMigrateResult(result), "info");
|
|
1594
|
-
}
|
|
1595
|
-
break;
|
|
1596
|
-
}
|
|
1597
|
-
// Hand-written: the plugin no longer merges single-file briefs
|
|
1598
|
-
// (#131). There is no banner, so nothing here can tell which lines
|
|
1599
|
-
// are the operator's — a retrofit has to name the cut first.
|
|
1600
|
-
ctx.ui.notify(
|
|
1601
|
-
`Hand-written brief at ${path} — run: omp-conductor brief-upgrade --retrofit (then --migrate). The plugin no longer merges single-file briefs.`,
|
|
1602
|
-
"warning",
|
|
1603
|
-
);
|
|
1604
|
-
break;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
default:
|
|
1608
|
-
ctx.ui.notify(
|
|
1609
|
-
`${sub ? `Unknown subcommand "${sub}".` : "Pick a subcommand."}\n\n${USAGE}` +
|
|
1610
|
-
(isPaused() ? "\n\nThe conductor is currently paused." : ""),
|
|
1611
|
-
sub ? "warning" : "info",
|
|
1612
|
-
);
|
|
1613
|
-
}
|
|
1614
|
-
} catch (err) {
|
|
1615
|
-
// Config problems arrive as a single readable message listing every
|
|
1616
|
-
// fault, which is more use to the operator than a stack.
|
|
1617
|
-
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
1618
|
-
}
|
|
1619
|
-
},
|
|
1620
|
-
});
|
|
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");
|
|
1621
1946
|
}
|