omp-conductor 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
package/src/plugin.ts DELETED
@@ -1,1495 +0,0 @@
1
- /**
2
- * The omp plugin surface: one `/conductor` command with four subcommands.
3
- *
4
- * `status`, `pause` and `resume` are argument parsing plus printing over
5
- * ./daemon.ts, so the plugin and the `omp-conductor` CLI can never disagree
6
- * about what a cap means or where the state lives.
7
- *
8
- * `setup` is the exception, and only in volume: it owns the dialogs and nothing
9
- * else. Every decision it makes lives in ./setup.ts, which is headless and
10
- * tested; this file turns answers into questions and back again. The invariant
11
- * worth protecting is on `setup()` below — nothing is written before the confirm.
12
- */
13
- import { existsSync, readFileSync } from "node:fs";
14
- import { dirname, isAbsolute } from "node:path";
15
- import {
16
- formatBriefReport,
17
- formatMigrateResult,
18
- inspectBriefLayout,
19
- migrateToPolicy,
20
- repairPolicyBannerCrumbs,
21
- } from "./brief-upgrade.ts";
22
- import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
23
- import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
24
- import {
25
- isPaused,
26
- prepareConductor,
27
- previewProject,
28
- setPaused,
29
- type QueuePreview,
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";
41
- import { restartDaemon } from "./lifecycle.ts";
42
-
43
- import { defaultGraphRoot } from "./graph.ts";
44
- import {
45
- formatHostRuntimePlan,
46
- planHostRuntime,
47
- runSetupSmoke,
48
- writeHostRuntime,
49
- } from "./setup-host.ts";
50
- import {
51
- AMEND_AREAS,
52
- BASE_FRESHNESS_CHOICES,
53
- BEHIND_BASE_CHOICES,
54
- DRAFT_POLICY_CHOICES,
55
- ORCHESTRATOR_BRIEF_NAME,
56
- POLICY_BRIEF_NAME,
57
- RELEASE_REQUIREMENT_CHOICES,
58
- REPORT_SCOPE_CHOICES,
59
- SETUP_DEFAULTS,
60
- amendChoices,
61
- answersFromProject,
62
- briefPathForProject,
63
- policyPathForProject,
64
- renderFloorForProject,
65
- buildConfig,
66
- checkTokenScopes,
67
- createMissingLabels,
68
- defaultAnswers,
69
- detectTelegram,
70
- formatGates,
71
- orchestratorBriefPath,
72
- planLabels,
73
- renderBriefForProject,
74
- summariseAmend,
75
- summarisePlan,
76
- writeOrchestratorBrief,
77
- type AmendAreaId,
78
- type SetupAnswers,
79
- } from "./setup.ts";
80
- import {
81
- BASE_FRESHNESS,
82
- BEHIND_BASE_ACTIONS,
83
- DEFAULT_CAPS,
84
- DRAFT_POLICIES,
85
- RELEASE_REQUIREMENTS,
86
- RELEASE_SHAPES,
87
- type Caps,
88
- type ConductorConfig,
89
- type OrchestratorMode,
90
- type ProjectConfig,
91
- type ProjectPolicy,
92
- type ReleaseRequirement,
93
- type ReportScope,
94
- type ReportScopeChoice,
95
- type ResolvedGrants,
96
- } from "./types.ts";
97
-
98
- /**
99
- * The slice of the omp extension API this plugin actually touches, mirroring
100
- * `RegisteredCommand` / `ExtensionUIContext` from `@oh-my-pi/pi-coding-agent`.
101
- *
102
- * Declared here rather than imported because the harness is a peer dependency:
103
- * the package has to type-check without it installed. Structural typing means
104
- * the real API object satisfies this on the way in, and narrowing the surface
105
- * to the five members the wizard uses keeps the coupling visible.
106
- */
107
- interface Completion {
108
- value: string;
109
- label: string;
110
- description?: string;
111
- }
112
-
113
- interface CommandContext {
114
- ui: {
115
- notify(message: string, type?: "info" | "warning" | "error"): void;
116
- confirm(title: string, message: string): Promise<boolean>;
117
- /**
118
- * Single-line text prompt. Resolves `undefined` when the operator dismisses
119
- * the dialog, which the wizard treats as "abandon, change nothing".
120
- *
121
- * The harness has no pre-filled variant, so `placeholder` carries the
122
- * default and submitting an empty line accepts it.
123
- */
124
- input(title: string, placeholder?: string): Promise<string | undefined>;
125
- /**
126
- * Single-choice list. Resolves the chosen option's **label**, or
127
- * `undefined` when the operator dismisses it — so callers map labels back to
128
- * their own values rather than trusting the index.
129
- */
130
- select(
131
- title: string,
132
- options: { label: string; description?: string }[],
133
- dialogOptions?: { initialIndex?: number },
134
- ): Promise<string | undefined>;
135
- };
136
- }
137
-
138
- interface PluginApi {
139
- registerCommand(
140
- name: string,
141
- options: {
142
- description?: string;
143
- getArgumentCompletions?: (argumentPrefix: string) => Completion[] | null;
144
- handler: (args: string, ctx: CommandContext) => Promise<void>;
145
- },
146
- ): void;
147
- }
148
-
149
- const SUBCOMMANDS: Completion[] = [
150
- {
151
- value: "setup",
152
- label: "setup",
153
- description: "wizard: config, labels, dry run, then arm — or amend one area of a configured project",
154
- },
155
- { value: "status", label: "status", description: "layered fleet report: dispatch, ticks, pane, herdr, daemon" },
156
- { value: "hold", label: "hold", description: "soft stop: pause claiming AND disarm ticks" },
157
- { value: "halt", label: "halt", description: "hold + stop dispatch daemon; pass --pane to pin recovery off" },
158
- { value: "arm", label: "arm", description: "proof-gated: inbound Telegram round-trip, then write arm marker" },
159
- { value: "disarm", label: "disarm", description: "remove arm marker so ticks skip" },
160
- { value: "release-pane", label: "release-pane", description: "clear halt --pane recovery pin" },
161
- { value: "pause", label: "pause", description: "stop claiming only (ticks keep firing if armed); prefer hold" },
162
- { value: "resume", label: "resume", description: "clear pause only — does not re-arm" },
163
- {
164
- value: "brief-upgrade",
165
- label: "brief-upgrade",
166
- description: "check ORCHESTRATOR.md against the brief this version ships",
167
- },
168
- ];
169
-
170
- const USAGE = [
171
- "/conductor setup [project] create a project, or amend one area of one you already have",
172
- "/conductor status [project] layered fleet report (dispatch, ticks, pane, herdr, daemon)",
173
- "/conductor hold [project] soft stop: pause claiming AND disarm ticks",
174
- "/conductor halt [--pane] [project] hold + stop daemon; --pane pins conductor recovery off only",
175
- "/conductor arm [project] proof-gated inbound Telegram round-trip, then arm ticks",
176
- "/conductor disarm [project] remove arm marker (ticks skip)",
177
- "/conductor release-pane [project] clear halt --pane recovery pin",
178
- "/conductor pause stop claiming only (prefer hold)",
179
- "/conductor resume clear pause only — does not re-arm",
180
- "/conductor brief-upgrade [project] check ORCHESTRATOR.md against the shipped brief",
181
- ].join("\n");
182
-
183
- /**
184
- * Dismissing any dialog abandons the whole wizard.
185
- *
186
- * Thrown rather than returned as a sentinel: the prompt sequence runs to a
187
- * dozen questions, and a cancellation check after each one would bury the
188
- * shape of the conversation under branching.
189
- */
190
- class Cancelled extends Error {
191
- constructor() {
192
- super("setup cancelled");
193
- this.name = "Cancelled";
194
- }
195
- }
196
-
197
- /** The spelling `config.ts` validates tracker repos against. Checked here too
198
- * so a typo is fixed in the dialog instead of in an error an hour later. */
199
- const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
200
-
201
- /**
202
- * One text answer. The placeholder shows the default and an empty submission
203
- * takes it, because the harness has no pre-filled input dialog — so "Enter
204
- * accepts what you see" is the contract the whole wizard is built on.
205
- */
206
- async function ask(ctx: CommandContext, title: string, fallback: string): Promise<string> {
207
- const raw = await ctx.ui.input(title, fallback.length > 0 ? fallback : undefined);
208
- if (raw === undefined) throw new Cancelled();
209
- const trimmed = raw.trim();
210
- return trimmed.length > 0 ? trimmed : fallback;
211
- }
212
-
213
- /**
214
- * Re-asks until the answer passes `check`, which returns the complaint or
215
- * `undefined`. Bounded at three tries: a dialog that cannot be escaped is worse
216
- * than one that gives up and leaves the config alone.
217
- */
218
- async function askValid(
219
- ctx: CommandContext,
220
- title: string,
221
- fallback: string,
222
- check: (value: string) => string | undefined,
223
- ): Promise<string> {
224
- for (let attempt = 0; attempt < 3; attempt++) {
225
- const value = await ask(ctx, title, fallback);
226
- const problem = check(value);
227
- if (problem === undefined) return value;
228
- ctx.ui.notify(problem, "warning");
229
- }
230
- throw new Cancelled();
231
- }
232
-
233
- /** A cap. Unparseable input keeps the current value rather than writing a NaN
234
- * the validator would later reject — the operator sees why, immediately. */
235
- async function askNumber(ctx: CommandContext, title: string, fallback: number): Promise<number> {
236
- const raw = await ask(ctx, title, String(fallback));
237
- const value = Number(raw);
238
- if (!Number.isFinite(value) || value < 0) {
239
- ctx.ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning");
240
- return fallback;
241
- }
242
- return value;
243
- }
244
-
245
- /**
246
- * Daily spend ceiling. Blank / "none" / "off" → null (no gate). Unparseable
247
- * non-empty input keeps the current value. Distinct from askNumber so operators
248
- * can turn the money brake off without writing a magic 0 (which is a hard stop).
249
- */
250
- async function askSpendCap(
251
- ctx: CommandContext,
252
- title: string,
253
- fallback: number | null,
254
- ): Promise<number | null> {
255
- const seed = fallback === null ? "" : String(fallback);
256
- const raw = (await ask(ctx, title, seed)).trim().toLowerCase();
257
- if (raw === "" || raw === "none" || raw === "off" || raw === "null") return null;
258
- const value = Number(raw);
259
- if (!Number.isFinite(value) || value < 0) {
260
- ctx.ui.notify(
261
- `"${raw}" is not a non-negative number or blank — keeping ${fallback === null ? "no cap" : fallback}.`,
262
- "warning",
263
- );
264
- return fallback;
265
- }
266
- return value;
267
- }
268
-
269
- /**
270
- * Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
271
- * `bun run check, bun test @ server`. Shown through `formatGates`, the same
272
- * spelling the amend menu reads a repo's current gates back with.
273
- *
274
- * ponytail: the ceiling is a command containing a comma or a literal " @ ",
275
- * which this would split wrongly. Rare in a lint or test invocation, and the
276
- * config file is hand-editable. Upgrade path is `ctx.ui.editor()`, a real
277
- * multi-line buffer, once someone hits it.
278
- */
279
- async function askGates(
280
- ctx: CommandContext,
281
- repoName: string,
282
- seed: { cmd: string; cwd: string }[],
283
- ): Promise<{ cmd: string; cwd: string }[]> {
284
- const raw = await ask(
285
- ctx,
286
- `Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
287
- formatGates(seed),
288
- );
289
-
290
- const gates: { cmd: string; cwd: string }[] = [];
291
- for (const chunk of raw.split(",")) {
292
- const entry = chunk.trim();
293
- if (entry.length === 0) continue;
294
- const at = entry.lastIndexOf(" @ ");
295
- if (at === -1) gates.push({ cmd: entry, cwd: "." });
296
- else gates.push({ cmd: entry.slice(0, at).trim(), cwd: entry.slice(at + 3).trim() });
297
- }
298
-
299
- if (gates.length === 0) {
300
- // Loud, because an unattended push with no gate is how a lint failure
301
- // reaches the runners at 03:00 with nobody watching.
302
- ctx.ui.notify(`No gates for ${repoName} — nothing will be verified before a push.`, "warning");
303
- }
304
- return gates;
305
- }
306
-
307
- /**
308
- * How loud the orchestrator should be. A list rather than a confirm: "report
309
- * scope" has no natural yes, and phrasing it as one would bury which answer
310
- * means silence. The cursor starts on the current setting so Enter re-affirms
311
- * it, the same contract every other prompt here has.
312
- */
313
- async function askReportScope(ctx: CommandContext, current: ReportScopeChoice): Promise<ReportScopeChoice> {
314
- const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
315
- const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
316
- const picked = await ctx.ui.select("What should the orchestrator report unprompted?", options, {
317
- initialIndex: at === -1 ? 0 : at,
318
- });
319
- if (picked === undefined) throw new Cancelled();
320
-
321
- const choice = REPORT_SCOPE_CHOICES.find((c) => c.label === picked);
322
- if (choice === undefined) {
323
- // The harness answers with a label we did not offer only if the dialog
324
- // contract changed under us; keeping the current scope is the answer that
325
- // changes nothing, and it is said out loud rather than assumed.
326
- ctx.ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
327
- return current;
328
- }
329
- return choice.scope;
330
- }
331
-
332
- /**
333
- * One value out of a closed vocabulary, described in the operator's words.
334
- *
335
- * A select rather than a confirm, and the cursor starts on the configured value
336
- * so Enter re-affirms it — the contract every prompt here has. It cannot be a
337
- * confirm: a precondition's safe answer is "keep requiring it", and this
338
- * harness's confirms always start on no, so a re-run that Entered through them
339
- * would quietly relax the gate it was meant to leave alone.
340
- *
341
- * The label *is* the config value, so the answer the harness hands back needs no
342
- * lookup table that could disagree with the vocabulary it was built from.
343
- */
344
- async function askLiteral<T extends string>(
345
- ctx: CommandContext,
346
- title: string,
347
- values: readonly T[],
348
- described: { readonly [K in T]: string },
349
- current: T,
350
- ): Promise<T> {
351
- const at = values.findIndex((v) => v === current);
352
- const picked = await ctx.ui.select(
353
- title,
354
- values.map((v) => ({ label: v, description: described[v] })),
355
- { initialIndex: at === -1 ? 0 : at },
356
- );
357
- if (picked === undefined) throw new Cancelled();
358
-
359
- const hit = values.find((v) => v === picked);
360
- if (hit === undefined) {
361
- // The harness answered with a label we never offered, which only happens if
362
- // the dialog contract changed under us. Keeping the current value is the
363
- // answer that changes nothing, and it is said out loud rather than assumed.
364
- ctx.ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
365
- return current;
366
- }
367
- return hit;
368
- }
369
-
370
- /** How an empty list is both shown and typed. A word, because a blank line in
371
- * this wizard means "accept what you see", not "clear it". */
372
- const EMPTY_LIST = "none";
373
-
374
- /** A name list as the prompt shows it and reads it back — one spelling, so the
375
- * pre-filled default and the value it round-trips to cannot drift. */
376
- function formatNameList(names: readonly string[]): string {
377
- return names.length === 0 ? EMPTY_LIST : names.join(", ");
378
- }
379
-
380
- function parseNameList(answer: string): string[] {
381
- if (answer.trim().toLowerCase() === EMPTY_LIST) return [];
382
- return answer
383
- .split(",")
384
- .map((name) => name.trim())
385
- .filter((name) => name.length > 0);
386
- }
387
-
388
- /** Check names, artefacts, environments: open-ended lists this package cannot
389
- * enumerate, so the only validation is the shape. */
390
- async function askNameList(ctx: CommandContext, title: string, seed: readonly string[]): Promise<string[]> {
391
- return parseNameList(await ask(ctx, title, formatNameList(seed)));
392
- }
393
-
394
- /**
395
- * The `requires` set, typed rather than picked one confirm at a time.
396
- *
397
- * Validated in the dialog against the same array the loader validates against,
398
- * and the complaint names every accepted value — an operator who mistyped a
399
- * requirement they believed they had set would otherwise find out from a release
400
- * that went ahead without it.
401
- */
402
- async function askReleaseRequirements(
403
- ctx: CommandContext,
404
- prior: readonly ReleaseRequirement[],
405
- ): Promise<ReleaseRequirement[]> {
406
- const accepted = RELEASE_REQUIREMENTS.join(", ");
407
- // The vocabulary, spelled out where it is being asked for. Built from the same
408
- // data the validator reads, so a fifth requirement is offered here the moment
409
- // it exists rather than staying invisible to everyone who did not read #129.
410
- ctx.ui.notify(
411
- RELEASE_REQUIREMENTS.map((r) => `${r} — ${RELEASE_REQUIREMENT_CHOICES[r]}`).join("\n"),
412
- "info",
413
- );
414
- const answered = await askValid(
415
- ctx,
416
- `Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`,
417
- formatNameList(prior),
418
- (value) => {
419
- const unknown = parseNameList(value).filter((name) => !RELEASE_REQUIREMENTS.some((r) => r === name));
420
- return unknown.length === 0 ? undefined : `Not a release requirement: ${unknown.join(", ")}. Accepted: ${accepted}.`;
421
- },
422
- );
423
-
424
- const chosen = new Set(parseNameList(answered));
425
- // The vocabulary's order, not the operator's: two fleets that require the same
426
- // three things must read identically in the plan and in a refusal.
427
- return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
428
- }
429
-
430
- /**
431
- * The gating conditions #126's verbs read (#129).
432
- *
433
- * Asked here rather than left to a hand-edit because the whole point of moving
434
- * them out of POLICY.md is that they are config: a condition an operator can
435
- * only reach by opening `config.json` is one that stays at its default while
436
- * their prose says something else, which is the drift this key ended.
437
- */
438
- async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy): Promise<ProjectPolicy> {
439
- const merge = {
440
- requiredChecks: await askNameList(
441
- ctx,
442
- `Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`,
443
- prior.merge.requiredChecks,
444
- ),
445
- baseFreshness: await askLiteral(
446
- ctx,
447
- "Merge — must the PR be level with its base?",
448
- BASE_FRESHNESS,
449
- BASE_FRESHNESS_CHOICES,
450
- prior.merge.baseFreshness,
451
- ),
452
- drafts: await askLiteral(
453
- ctx,
454
- "Merge — draft pull requests",
455
- DRAFT_POLICIES,
456
- DRAFT_POLICY_CHOICES,
457
- prior.merge.drafts,
458
- ),
459
- whenBehindBase: await askLiteral(
460
- ctx,
461
- "Merge — a green PR that fell behind its base",
462
- BEHIND_BASE_ACTIONS,
463
- BEHIND_BASE_CHOICES,
464
- prior.merge.whenBehindBase,
465
- ),
466
- };
467
-
468
- const release = {
469
- requires: await askReleaseRequirements(ctx, prior.release.requires),
470
- requiredChecks: await askNameList(
471
- ctx,
472
- `Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
473
- prior.release.requiredChecks,
474
- ),
475
- artefacts: await askNameList(
476
- ctx,
477
- `Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
478
- prior.release.artefacts,
479
- ),
480
- environments: await askNameList(
481
- ctx,
482
- `Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
483
- prior.release.environments,
484
- ),
485
- };
486
-
487
- return { merge, release };
488
- }
489
-
490
- /**
491
- * Who merges and who releases. Two confirms rather than one four-way list:
492
- * these are independent grants — delegating merges is routine, delegating
493
- * releases is not — and a menu of four combinations frames them as equally
494
- * ordinary choices, which is exactly the framing a release grant must not get.
495
- *
496
- * Neither confirm can start on "yes", so a re-run that Enters through the
497
- * wizard revokes rather than renews. That is the safe direction, and the
498
- * current grant is named in the question so the revoke is never a surprise.
499
- */
500
- async function askAuthority(
501
- ctx: CommandContext,
502
- prior: ProjectConfig["authority"],
503
- ): Promise<ProjectConfig["authority"]> {
504
- const merge = await ctx.ui.confirm(
505
- "Merge authority",
506
- "Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " +
507
- "re-checked against the base branch first. Default: humans merge" +
508
- `${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
509
- );
510
- const release = await ctx.ui.confirm(
511
- "Release authority",
512
- "Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
513
- "procedure you write into its brief — and its brief forbids cutting one before you have. " +
514
- "Default: humans release" +
515
- `${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
516
- );
517
- return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
518
- }
519
-
520
- /**
521
- * What each shape means to the operator being asked about it, in their words
522
- * rather than the classifier's. Declared as data over the closed enum so a sixth
523
- * shape cannot be added without a question to ask about it — an unasked shape
524
- * would silently take the deny default and read as a decision afterwards.
525
- *
526
- * No fleet vocabulary here on purpose (#122): every one of these is an act the
527
- * package can recognise anywhere, not a step in one project's release topology.
528
- */
529
- const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]: string } = {
530
- "git-tag": "create git tags (`git tag v1.2.3`)",
531
- "git-push-tags": "push tags to the remote (`git push --follow-tags`)",
532
- "package-publish": "publish packages (`npm publish` and equivalents)",
533
- "github-release": "create GitHub releases (`gh release create`)",
534
- deploy:
535
- "deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
536
- "This is the one grant that mutates a live environment rather than producing an artifact",
537
- };
538
-
539
- /**
540
- * The mechanical tool gate, one confirm per shape.
541
- *
542
- * One binary question used to cover all five, which is how #122 happened: an
543
- * operator who meant "it may cut a release" also granted "it may deploy to
544
- * production", because there was one switch for both. Asking five times is the
545
- * point — each answer is a different blast radius.
546
- *
547
- * No confirm can start on "yes", so a re-run that Enters through the wizard
548
- * revokes rather than renews. The current grant is named in the question, so
549
- * that revoke is never a surprise.
550
- */
551
- async function askReleaseGrants(
552
- ctx: CommandContext,
553
- prior: ResolvedGrants,
554
- ): Promise<ResolvedGrants> {
555
- const grants = { ...prior };
556
- for (const shape of RELEASE_SHAPES) {
557
- const open = await ctx.ui.confirm(
558
- `Release tool gate — ${shape}`,
559
- `Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` +
560
- "operator brief carries the procedure it must follow. A worker session is refused this " +
561
- "whatever you answer. Default: no" +
562
- `${prior[shape] === "orchestrator" ? " — currently granted, answer no to take it back" : ""}.`,
563
- );
564
- grants[shape] = open ? "orchestrator" : "human";
565
- }
566
- return grants;
567
- }
568
-
569
- /**
570
- * Where the session that triages escalations lives. Phrased as a fact about the
571
- * host rather than a preference, because that is what it is: answering yes when
572
- * no such session exists leaves tier-1 escalations sitting in issue comments
573
- * that nobody drains.
574
- */
575
- async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode): Promise<OrchestratorMode> {
576
- const external = await ctx.ui.confirm(
577
- "Orchestrator session",
578
- "Do you already run your own orchestrator session for this project — a visible TUI session, say? " +
579
- "Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " +
580
- "to drain. Default: no, the daemon runs one" +
581
- `${prior === "external" ? " — currently external" : ""}.`,
582
- );
583
- return external ? "external" : "embedded";
584
- }
585
-
586
- /**
587
- * Whether workers get a code graph, and where its clones live.
588
- *
589
- * One confirm and at most one prompt, asked after the repos are known because
590
- * the answer is derived per repo. A declined answer leaves the field off every
591
- * repo, which is what keeps an existing fleet's briefs byte-identical.
592
- *
593
- * The root is validated as absolute here rather than at load time so the
594
- * operator learns immediately: a relative path would be resolved against
595
- * whichever cwd happened to read the config, and never against the directory
596
- * that was indexed.
597
- */
598
- async function askGraphRoot(
599
- ctx: CommandContext,
600
- trackerRepo: string,
601
- repoNames: string[],
602
- prior: string | undefined,
603
- ): Promise<string | undefined> {
604
- const wanted = await ctx.ui.confirm(
605
- "Code-graph discovery",
606
- "Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
607
- 'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
608
- "pinned to the default branch purely for indexing — never your own checkout" +
609
- `${prior === undefined ? "" : `. Currently on, under ${prior}`}.`,
610
- );
611
- if (!wanted) return undefined;
612
-
613
- return await askValid(
614
- ctx,
615
- `Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
616
- prior ?? defaultGraphRoot(trackerRepo),
617
- (v) =>
618
- isAbsolute(expandHome(v))
619
- ? undefined
620
- : `"${v}" is not an absolute path — a worker reads this from its own worktree, so a relative one names the wrong directory.`,
621
- );
622
- }
623
-
624
- /**
625
- * Whether to render the operator's own brief, and — separately — whether an
626
- * existing one may be replaced. Two questions on purpose: that file is where a
627
- * fleet's release and reporting policy ends up, so it is never overwritten by
628
- * an operator who only meant to re-run setup.
629
- */
630
- async function askOrchestratorBrief(ctx: CommandContext, a: SetupAnswers): Promise<boolean> {
631
- const path = orchestratorBriefPath(a);
632
- const wanted = await ctx.ui.confirm(
633
- `Write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} under ${dirname(path)}?`,
634
- `Writes composed ${ORCHESTRATOR_BRIEF_NAME} (package floor, refreshed each tick) and ${POLICY_BRIEF_NAME} ` +
635
- `(Releases, Project context, Reporting, Amendments — yours to edit via the Learning loop). ` +
636
- `The conductor stops at green PRs either way.`,
637
- );
638
- if (!wanted) return false;
639
- if (!existsSync(path)) return true;
640
-
641
- return await ctx.ui.confirm(
642
- `Overwrite existing ${ORCHESTRATOR_BRIEF_NAME} / ${POLICY_BRIEF_NAME}?`,
643
- `${path} already exists. Overwriting replaces the composed brief and POLICY.md scaffold — any policy you wrote is lost.`,
644
- );
645
- }
646
-
647
- /** The project these answers would replace, so a re-run pre-fills with what is
648
- * already there instead of making the operator retype it. */
649
- function priorProject(existing: ConductorConfig | undefined, name: string | undefined): ProjectConfig | undefined {
650
- if (existing === undefined) return undefined;
651
- if (name !== undefined) return existing.projects.find((p) => p.name === name);
652
- return existing.projects.length === 1 ? existing.projects[0] : undefined;
653
- }
654
-
655
- /**
656
- * One area's questions, over the answers everything else is carried through in.
657
- *
658
- * Every asker takes the whole answer set and returns the whole answer set with
659
- * only its own fields replaced. That is what lets the full interview fold them in
660
- * order while an amend applies exactly one, with no second spelling of either the
661
- * prompts or the defaults they pre-fill from: the value shown is always the value
662
- * that would otherwise be carried through.
663
- */
664
- type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>;
665
-
666
- /**
667
- * Where work comes from and where it lands: tracker, labels, routing prefix, and
668
- * every repo an issue can be routed to, each with its gates. One area because it
669
- * is one fact — the identity of the queue — and changing any part of it without
670
- * seeing the rest is how a routing prefix stops matching its labels.
671
- */
672
- const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
673
- const trackerRepo = await askValid(
674
- ctx,
675
- "Tracker repo (owner/repo) — where ready issues live",
676
- a.trackerRepo,
677
- (v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
678
- );
679
-
680
- const queueLabel = await ask(
681
- ctx,
682
- "Queue label — the human sign-off that makes an issue claimable",
683
- a.queueLabel,
684
- );
685
-
686
- // One confirm instead of three prompts: the namespaced defaults are right for
687
- // almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
688
- // its reputation.
689
- const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
690
- const customiseStates = await ctx.ui.confirm(
691
- "State labels",
692
- `The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
693
- `"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
694
- );
695
- if (customiseStates) {
696
- stateLabels.inProgress = await ask(ctx, "Label for a run in progress", stateLabels.inProgress);
697
- stateLabels.blocked = await ask(ctx, "Label for a run parked on a human", stateLabels.blocked);
698
- stateLabels.failed = await ask(ctx, "Label for a run that gave up", stateLabels.failed);
699
- }
700
-
701
- const routingLabelPrefix = await ask(
702
- ctx,
703
- "Routing label prefix — an issue picks its checkout with <prefix><repo>",
704
- a.routingLabelPrefix,
705
- );
706
-
707
- const targetRepos: SetupAnswers["targetRepos"] = [];
708
- for (let i = 0; ; i++) {
709
- const seed = a.targetRepos[i];
710
- const name = await askValid(
711
- ctx,
712
- `Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
713
- seed?.name ?? "",
714
- (v) => (v.length > 0 ? undefined : "A routing key is required, or no issue can reach this repo."),
715
- );
716
- const cloneUrl = await askValid(
717
- ctx,
718
- `Clone URL for ${routingLabelPrefix}${name}`,
719
- seed?.cloneUrl ?? "",
720
- (v) => (v.length > 0 ? undefined : "A clone URL is required — the daemon mirrors it before every run."),
721
- );
722
- const defaultBranch = await ask(
723
- ctx,
724
- `Default branch for ${name} — worktrees are cut from it and PRs target it`,
725
- seed?.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
726
- );
727
- targetRepos.push({ name, cloneUrl, defaultBranch, gates: await askGates(ctx, name, seed?.gates ?? []) });
728
-
729
- const more = await ctx.ui.confirm(
730
- "Another repo?",
731
- `${targetRepos.map((r) => r.name).join(", ")} configured. Add another checkout this project routes to?`,
732
- );
733
- if (!more) break;
734
- }
735
-
736
- return { ...a, trackerRepo, queueLabel, stateLabels, routingLabelPrefix, targetRepos };
737
- };
738
-
739
- /**
740
- * The gates alone, repo by repo, with nothing else asked.
741
- *
742
- * The area that earns amend mode: a CI command changes far more often than a
743
- * clone URL does, and re-typing four repos to correct one lint invocation is the
744
- * reason an operator edits config.json by hand instead.
745
- */
746
- const askGatesOnly: AreaAsker = async (ctx, a) => {
747
- if (a.targetRepos.length === 0) {
748
- ctx.ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning");
749
- return a;
750
- }
751
-
752
- const targetRepos: SetupAnswers["targetRepos"] = [];
753
- for (const r of a.targetRepos) {
754
- targetRepos.push({ ...r, gates: await askGates(ctx, r.name, r.gates) });
755
- }
756
- return { ...a, targetRepos };
757
- };
758
-
759
- /**
760
- * Whether workers get a code graph, and where its clones live. Asked after the
761
- * repos in the full interview because the answer is derived per repo.
762
- */
763
- const askGraph: AreaAsker = async (ctx, a) => {
764
- const graphRoot = await askGraphRoot(
765
- ctx,
766
- a.trackerRepo,
767
- a.targetRepos.map((r) => r.name),
768
- a.graphRoot,
769
- );
770
-
771
- const next: SetupAnswers = { ...a };
772
- // Deleted rather than set to `undefined`: the absence of the key is what keeps
773
- // a project that declines graphs identical to one written before they existed.
774
- if (graphRoot === undefined) delete next.graphRoot;
775
- else next.graphRoot = graphRoot;
776
- return next;
777
- };
778
-
779
- /** The hard ceilings. One confirm first, because the shipped defaults are the
780
- * answer for anyone who has not measured their own runners. */
781
- const askCaps: AreaAsker = async (ctx, a) => {
782
- const caps: Partial<Caps> = { ...a.caps };
783
- const spendLabel =
784
- DEFAULT_CAPS.dailySpendUsd === null ? "no spend cap" : `$${DEFAULT_CAPS.dailySpendUsd}/day`;
785
- // Workers are in-process omp sessions inside the daemon PID. On a host under
786
- // 16 GiB the measured shape (2 workers + orchestrator) peaks at 3–4 GB and
787
- // swaps on a 7.6 GB shared VPS (#51) — setup therefore defaults to 1 there.
788
- const workersDefault =
789
- caps.maxConcurrentWorkers ?? recommendedMaxWorkers(hostRamBytes());
790
- const smallHostNote =
791
- workersDefault < DEFAULT_CAPS.maxConcurrentWorkers
792
- ? ` This host looks under 16 GiB RAM, so the worker default is ${workersDefault} instead of ${DEFAULT_CAPS.maxConcurrentWorkers}.`
793
- : "";
794
- const tuneCaps = await ctx.ui.confirm(
795
- "Caps",
796
- `Defaults: ${workersDefault} workers, ` +
797
- `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} base / ` +
798
- `${DEFAULT_CAPS.workerMaxTurnsCeiling} max turns and ` +
799
- `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
800
- `${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
801
- `${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
802
- );
803
- if (!tuneCaps) {
804
- if (
805
- caps.maxConcurrentWorkers === undefined &&
806
- workersDefault !== DEFAULT_CAPS.maxConcurrentWorkers
807
- ) {
808
- caps.maxConcurrentWorkers = workersDefault;
809
- }
810
- return { ...a, caps };
811
- }
812
-
813
- // Spelled out rather than looped: adding a cap should fail to compile here,
814
- // not silently go unasked.
815
- caps.maxConcurrentWorkers = await askNumber(
816
- ctx,
817
- "Max concurrent workers",
818
- workersDefault,
819
- );
820
- caps.dailySpendUsd = await askSpendCap(
821
- ctx,
822
- "Spend ceiling per rolling day (USD) — blank = no spend cap",
823
- caps.dailySpendUsd !== undefined ? caps.dailySpendUsd : DEFAULT_CAPS.dailySpendUsd,
824
- );
825
- const workerMaxTurns = await askNumber(
826
- ctx,
827
- "Turn ceiling per worker",
828
- caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns,
829
- );
830
- caps.workerMaxTurns = workerMaxTurns;
831
- const turnCeilingFallback = Math.max(
832
- workerMaxTurns,
833
- caps.workerMaxTurnsCeiling ??
834
- Math.min(workerMaxTurns * 2, Number.MAX_SAFE_INTEGER),
835
- );
836
- const workerMaxTurnsCeiling = await askNumber(
837
- ctx,
838
- "Maximum turn ceiling for one issue",
839
- turnCeilingFallback,
840
- );
841
- if (workerMaxTurnsCeiling < workerMaxTurns) {
842
- ctx.ui.notify(
843
- `Maximum turn ceiling ${workerMaxTurnsCeiling} cannot be below the ` +
844
- `${workerMaxTurns}-turn worker base — keeping ${turnCeilingFallback}.`,
845
- "warning",
846
- );
847
- caps.workerMaxTurnsCeiling = turnCeilingFallback;
848
- } else {
849
- caps.workerMaxTurnsCeiling = workerMaxTurnsCeiling;
850
- }
851
- caps.workerWallClockMs = await askNumber(
852
- ctx,
853
- "Wall-clock ceiling per worker (ms)",
854
- caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
855
- );
856
- caps.maxAttemptsPerIssue = await askNumber(
857
- ctx,
858
- "Failed implementation attempts per issue before escalation",
859
- caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
860
- );
861
- caps.maxContinuationsPerIssue = await askNumber(
862
- ctx,
863
- "Operational continuations per issue before escalation",
864
- caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue,
865
- );
866
- return { ...a, caps };
867
- };
868
-
869
- /** Outside the caps block: a model is not a ceiling, and an operator who left
870
- * the caps alone may still want workers on a cheaper model. */
871
- const askWorkerModel: AreaAsker = async (ctx, a) => {
872
- const answered = await ask(ctx, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
873
- const next: SetupAnswers = { ...a };
874
- if (answered.trim().length === 0) delete next.workerModel;
875
- else next.workerModel = answered.trim();
876
- return next;
877
- };
878
-
879
- /** The two ownership questions, then the mechanical gate one shape at a time:
880
- * together they are what decides what an unattended fleet may do unasked. */
881
- const askAuthorityArea: AreaAsker = async (ctx, a) => ({
882
- ...a,
883
- authority: await askAuthority(ctx, a.authority),
884
- releaseGrants: await askReleaseGrants(ctx, a.releaseGrants),
885
- });
886
-
887
- /** What a merge and a release must satisfy. Asked straight after the grants:
888
- * who may act, then under what conditions (#129). */
889
- const askPolicy: AreaAsker = async (ctx, a) => ({ ...a, policy: await askPolicyPreconditions(ctx, a.policy) });
890
-
891
- /** How a stuck run reaches a human, and who triages it when it does. */
892
- const askEscalation: AreaAsker = async (ctx, a) => {
893
- const telegram = detectTelegram();
894
- let telegramChatId = a.telegramChatId;
895
- if (telegram.available && telegram.hasToken) {
896
- if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
897
- const usePaired = await ctx.ui.confirm(
898
- "Tier-2 escalations",
899
- `omp-telegram is paired with chat ${telegram.pairedOwnerId}. Page it when a run is stuck?`,
900
- );
901
- if (usePaired) telegramChatId = telegram.pairedOwnerId;
902
- } else {
903
- const answered = await ask(ctx, "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "");
904
- telegramChatId = answered.length > 0 ? answered : undefined;
905
- }
906
- } else {
907
- // Not an error: tier 2 degrades to a comment, which is the documented fallback.
908
- ctx.ui.notify(
909
- `No usable omp-telegram install at ${telegram.stateDir} — tier-2 escalations will comment on the issue.`,
910
- "info",
911
- );
912
- }
913
-
914
- const fallbackToIssueComment = await ctx.ui.confirm(
915
- "Escalation fallback",
916
- "Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
917
- );
918
-
919
- const orchestratorMode = await askOrchestratorMode(ctx, a.orchestratorMode);
920
-
921
- const next: SetupAnswers = { ...a, fallbackToIssueComment, orchestratorMode };
922
- if (telegramChatId === undefined) delete next.telegramChatId;
923
- else next.telegramChatId = telegramChatId;
924
- return next;
925
- };
926
-
927
- /** How loud the orchestrator is when nobody asked it anything. */
928
- const askReporting: AreaAsker = async (ctx, a) => {
929
- const reportScope = await askReportScope(ctx, a.reportScope);
930
- if (reportScope !== "quiet") return { ...a, reportScope };
931
- // `quiet` picks the explicit form, whose only free parameter is when the
932
- // daily rollup happens. Blank = whenever the orchestrator composes it.
933
- const at = await ctx.ui.input(
934
- "Daily rollup time, 24h HH:MM (blank = whenever the orchestrator composes it):",
935
- a.quietDigestAt,
936
- );
937
- const trimmed = at?.trim() ?? "";
938
- if (trimmed !== "" && !/^([01]\d|2[0-3]):[0-5]\d$/.test(trimmed)) {
939
- ctx.ui.notify(`"${trimmed}" is not a 24h HH:MM time — leaving the digest model-timed.`, "warning");
940
- return { ...a, reportScope };
941
- }
942
- return { ...a, reportScope, ...(trimmed === "" ? {} : { quietDigestAt: trimmed }) };
943
- };
944
-
945
- /** The operator's own brief. Asked last in the full interview, because the
946
- * question quotes the path the rest of the answers derive. */
947
- const askBrief: AreaAsker = async (ctx, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ctx, a) });
948
-
949
- /**
950
- * One dialog sequence per amend area, keyed so a new area cannot be added to
951
- * {@link AMEND_AREA_IDS} without one.
952
- */
953
- const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
954
- tracker: askTrackerAndRepos,
955
- gates: askGatesOnly,
956
- // The two per-worker knobs the full interview separates with the authority
957
- // grants; an amend has no reason to put anything between them.
958
- caps: async (ctx, a) => await askWorkerModel(ctx, await askCaps(ctx, a)),
959
- graph: askGraph,
960
- authority: askAuthorityArea,
961
- policy: askPolicy,
962
- escalation: askEscalation,
963
- reporting: askReporting,
964
- brief: askBrief,
965
- };
966
-
967
- /** The two ways to answer the first question a configured project gets. Labels,
968
- * because the harness's select resolves to the label it displayed. */
969
- const AMEND_ONE = "Change one area";
970
- const REINTERVIEW = "Walk every question again";
971
-
972
- /**
973
- * The first question a re-run asks, and the reason amend mode exists: adding one
974
- * key should not cost twenty prompts.
975
- *
976
- * Returns the area to amend, or `undefined` for the full interview. Only asked
977
- * when the named project is already configured — a first run, or a new project
978
- * beside an old one, has nothing to amend and is never shown this.
979
- */
980
- async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promise<AmendAreaId | undefined> {
981
- const mode = await ctx.ui.select(
982
- `"${prior.name}" is already configured — what would you like to do?`,
983
- [
984
- {
985
- label: AMEND_ONE,
986
- description: "asks one area's questions; every other answer is carried through from the saved config",
987
- },
988
- {
989
- label: REINTERVIEW,
990
- description: "the full interview, every prompt pre-filled with what is configured now",
991
- },
992
- ],
993
- { initialIndex: 0 },
994
- );
995
- if (mode === undefined) throw new Cancelled();
996
- if (mode !== AMEND_ONE) {
997
- // Either the operator chose the full interview, or the dialog answered with
998
- // a label we never offered. Both land on today's behaviour, which is the one
999
- // that cannot silently skip a question.
1000
- if (mode !== REINTERVIEW) ctx.ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning");
1001
- return undefined;
1002
- }
1003
-
1004
- const choices = amendChoices(prior);
1005
- const picked = await ctx.ui.select(
1006
- "Which area? Each row shows what it says now",
1007
- choices.map((c) => ({ label: c.label, description: c.description })),
1008
- { initialIndex: 0 },
1009
- );
1010
- if (picked === undefined) throw new Cancelled();
1011
-
1012
- const chosen = choices.find((c) => c.label === picked);
1013
- if (chosen === undefined) {
1014
- // Guessing an area here would ask the wrong questions and carry the rest
1015
- // through as if they had been reviewed. Abandoning changes nothing.
1016
- ctx.ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning");
1017
- throw new Cancelled();
1018
- }
1019
- return chosen.id;
1020
- }
1021
-
1022
- /**
1023
- * The conversation. Reads only — every answer is collected before anything is
1024
- * checked against GitHub, and long before anything is written.
1025
- *
1026
- * Seeded from one answers object rather than pre-filling each prompt from
1027
- * `prior?.field ?? default`: that is the same carry-through an amend relies on,
1028
- * so the two flows cannot disagree about what an unanswered field is.
1029
- */
1030
- async function collectAnswers(
1031
- ctx: CommandContext,
1032
- prior: ProjectConfig | undefined,
1033
- projectArg: string | undefined,
1034
- ): Promise<SetupAnswers> {
1035
- const seed = prior === undefined ? defaultAnswers(projectArg ?? "") : answersFromProject(prior);
1036
-
1037
- const projectName = await askValid(
1038
- ctx,
1039
- "Project name",
1040
- projectArg ?? seed.projectName,
1041
- (v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
1042
- );
1043
-
1044
- let a: SetupAnswers = { ...seed, projectName };
1045
- a = await askTrackerAndRepos(ctx, a);
1046
- // Straight after the repos, because it is a fact about them: one clone per
1047
- // routed repo, under one root.
1048
- a = await askGraph(ctx, a);
1049
- a = await askCaps(ctx, a);
1050
- a = await askAuthorityArea(ctx, a);
1051
- a = await askPolicy(ctx, a);
1052
- a = await askWorkerModel(ctx, a);
1053
- a = await askEscalation(ctx, a);
1054
- a = await askReporting(ctx, a);
1055
- // Asked last, and asked with the real path in the question — which needs the
1056
- // rest of the answers to derive.
1057
- return await askBrief(ctx, a);
1058
- }
1059
-
1060
- /** The dry run, rendered. Same routing code the loop uses, so this is what the
1061
- * next tick would actually do — not a description of it. */
1062
- function formatPreview(p: QueuePreview): string[] {
1063
- const lines = [
1064
- `state ${p.paused ? "paused" : "armed"}`,
1065
- p.ready.length === 0
1066
- ? "Would pick up: nothing — the queue is empty."
1067
- : `Would pick up ${p.ready.length} issue(s), caps permitting:`,
1068
- ];
1069
- for (const r of p.ready) lines.push(` #${r.number} → ${r.repo} ${r.branch} ${r.title}`);
1070
-
1071
- if (p.unroutable.length > 0) {
1072
- lines.push("", `Cannot route ${p.unroutable.length} issue(s) — these escalate instead of running:`);
1073
- for (const u of p.unroutable) {
1074
- lines.push(` #${u.number} ${u.reason} [${u.labels.join(", ") || "no labels"}] ${u.title}`);
1075
- }
1076
- }
1077
- return lines;
1078
- }
1079
-
1080
-
1081
- /** What the whole conversation produced: the answers, and which area an amend
1082
- * narrowed it to. `amend` absent means every question was asked. */
1083
- export interface CollectedSetup {
1084
- answers: SetupAnswers;
1085
- amend?: { area: AmendAreaId; before: ProjectConfig };
1086
- }
1087
-
1088
-
1089
- export async function ensureSetupArm(
1090
- projectName: string,
1091
- arm: typeof armTicks = armTicks,
1092
- ): Promise<string> {
1093
- const armed = await arm(projectName);
1094
- return armed.alreadyArmed
1095
- ? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
1096
- : `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
1097
- }
1098
-
1099
- /**
1100
- * The whole conversation, from the amend question to the last prompt, and not one
1101
- * byte further: no `gh`, no dry run, nothing written.
1102
- *
1103
- * Exported at exactly that seam so a test can script the dialogs and pin what a
1104
- * first run asks and what an amend refuses to ask — the two properties amend mode
1105
- * is judged on — on a host with no `gh` and no config.
1106
- */
1107
- export async function collectSetup(
1108
- ctx: CommandContext,
1109
- existing: ConductorConfig | undefined,
1110
- projectArg: string | undefined,
1111
- ): Promise<CollectedSetup> {
1112
- // Only a project that is already configured can be amended. A first run, or a
1113
- // name this config has never seen, goes straight into the full interview with
1114
- // no extra question — which is what it was before amend mode existed.
1115
- const prior = priorProject(existing, projectArg);
1116
- if (prior === undefined) return { answers: await collectAnswers(ctx, undefined, projectArg) };
1117
-
1118
- const area = await chooseAmendArea(ctx, prior);
1119
- if (area === undefined) return { answers: await collectAnswers(ctx, prior, projectArg) };
1120
-
1121
- return { answers: await AREA_ASKERS[area](ctx, answersFromProject(prior)), amend: { area, before: prior } };
1122
- }
1123
-
1124
- /**
1125
- * The onboarding wizard, and — for a project it already knows — the amend.
1126
- *
1127
- * The invariant that makes this safe against a live tracker: no mutation occurs
1128
- * before the consent below. Config, tracker, and host-runtime planning are
1129
- * read-only. The paused state, labels, config, brief, runtime files, smoke, and
1130
- * arm proof all follow the same consent gate.
1131
- *
1132
- * An amend changes which questions are asked and what the summary leads with,
1133
- * and nothing else: the same answers, the same `buildConfig`, the same single
1134
- * confirm, the same dry run. One writer, one consent gate.
1135
- */
1136
- async function setup(ctx: CommandContext, projectArg: string | undefined): Promise<void> {
1137
- const path = configPath();
1138
- // A config that exists but does not parse is a fault to report, never
1139
- // something to quietly replace: overwriting it would delete every project it
1140
- // describes. Absence, by contrast, is just the first run.
1141
- const existing = existsSync(path) ? loadConfig() : undefined;
1142
- if (existing === undefined) {
1143
- ctx.ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
1144
- }
1145
-
1146
- let collected: CollectedSetup;
1147
- try {
1148
- collected = await collectSetup(ctx, existing, projectArg);
1149
- } catch (err) {
1150
- if (!(err instanceof Cancelled)) throw err;
1151
- ctx.ui.notify("Setup cancelled — nothing was changed.", "info");
1152
- return;
1153
- }
1154
- const { answers, amend } = collected;
1155
-
1156
- const scopes = await checkTokenScopes();
1157
- if (!scopes.ok) {
1158
- ctx.ui.notify(
1159
- `Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
1160
- `Run \`gh auth refresh -s repo,project\`, then run setup again.`,
1161
- "error",
1162
- );
1163
- return;
1164
- }
1165
- const labels = await planLabels(answers.trackerRepo, answers);
1166
- const telegram = detectTelegram();
1167
- const nextConfig = buildConfig(answers, existing);
1168
- const project = findProject(nextConfig, answers.projectName);
1169
- // The same project as it is configured right now, so a moved `workspaceRoot`
1170
- if (
1171
- project.escalation.orchestrator === "external" &&
1172
- !answers.writeOrchestratorBrief &&
1173
- (!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
1174
- ) {
1175
- ctx.ui.notify(
1176
- `Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
1177
- `Run setup again and approve the brief write.`,
1178
- "error",
1179
- );
1180
- return;
1181
- }
1182
- const runtime = planHostRuntime(
1183
- project,
1184
- resolveCaps(project, nextConfig.defaults),
1185
- telegram.stateDir,
1186
- );
1187
- let queuePreview: string[];
1188
- try {
1189
- queuePreview = formatPreview(await previewProject(project));
1190
- } catch (err) {
1191
- const message = err instanceof Error ? err.message : String(err);
1192
- ctx.ui.notify(
1193
- `Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
1194
- "error",
1195
- );
1196
- return;
1197
- }
1198
-
1199
- ctx.ui.notify(
1200
- [
1201
- // The delta first when there is one, then the whole plan: the confirm has
1202
- // to name every mutation it authorises, and a delta names none of them.
1203
- ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
1204
- summarisePlan(answers, scopes, labels, telegram),
1205
- "",
1206
- formatHostRuntimePlan(runtime),
1207
- "",
1208
- "Dry run against the PROPOSED config:",
1209
- ...queuePreview,
1210
- "",
1211
- "Nothing has been changed yet.",
1212
- ].join("\n"),
1213
- "info",
1214
- );
1215
-
1216
- const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
1217
- const go = await ctx.ui.confirm(
1218
- amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
1219
- [
1220
- toCreate.length > 0
1221
- ? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
1222
- : "Creates no labels.",
1223
- `Writes ${path}, prepares a paused state database, then runs a paused daemon smoke.`,
1224
- runtime.service.action === "keep"
1225
- ? `Keeps the staged systemd unit at ${runtime.service.path}.`
1226
- : `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
1227
- runtime.tick === undefined
1228
- ? ""
1229
- : runtime.tick.action === "keep"
1230
- ? `Keeps the external heartbeat config at ${runtime.tick.path}.`
1231
- : `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
1232
- answers.writeOrchestratorBrief
1233
- ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
1234
- : "",
1235
- project.escalation.orchestrator === "external"
1236
- ? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
1237
- : "Dispatch resumes after the smoke succeeds.",
1238
- "Issues are only claimed after every setup gate succeeds.",
1239
- ]
1240
- .filter((s) => s.length > 0)
1241
- .join(" "),
1242
- );
1243
- if (!go) {
1244
- ctx.ui.notify("Left untouched — no labels created, no config written, nothing armed.", "info");
1245
- return;
1246
- }
1247
-
1248
- // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
1249
- // partially applied setup unable to claim work.
1250
- prepareConductor();
1251
- const created = await createMissingLabels(answers.trackerRepo, labels);
1252
- saveConfig(nextConfig);
1253
- const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers) : undefined;
1254
- const runtimeFiles = writeHostRuntime(runtime);
1255
- const smoke = await runSetupSmoke(project.name);
1256
- let smokeLine =
1257
- `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
1258
- `stored status for ${smoke.status.project}`;
1259
- let restartVia: "systemctl" | "cli" | undefined;
1260
- if (smoke.mode === "existing") {
1261
- if (smoke.status.liveWorkers > 0) {
1262
- ctx.ui.notify(
1263
- [
1264
- `Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
1265
- "Dispatch remains paused. Let those workers finish.",
1266
- `Then run \`omp-conductor restart --project ${project.name}\`.`,
1267
- project.escalation.orchestrator === "external"
1268
- ? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume\`.`
1269
- : "Then run `omp-conductor resume`.",
1270
- ].join("\n"),
1271
- "warning",
1272
- );
1273
- return;
1274
- }
1275
- const restarted = await restartDaemon({ project: project.name });
1276
- restartVia = restarted.via;
1277
- smokeLine =
1278
- `existing /healthz and stored status; restarted through ${restarted.via}; ` +
1279
- `new /healthz on :${restarted.record.port}`;
1280
- }
1281
-
1282
- let armLine = "embedded orchestrator — no heartbeat arm marker";
1283
- if (project.escalation.orchestrator === "external") {
1284
- ctx.ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
1285
- try {
1286
- armLine = await ensureSetupArm(project.name);
1287
- } catch (err) {
1288
- ctx.ui.notify(
1289
- [
1290
- "Setup files passed the paused daemon smoke, but the fleet remains held.",
1291
- err instanceof Error ? err.message : String(err),
1292
- `Start the external orchestrator in ${project.workspaceRoot}, then run \`omp-conductor arm --project ${project.name}\`.`,
1293
- "After the arm proof succeeds, run `omp-conductor resume`.",
1294
- ].join("\n"),
1295
- "warning",
1296
- );
1297
- return;
1298
- }
1299
- }
1300
- setPaused(false);
1301
-
1302
- ctx.ui.notify(
1303
- [
1304
- created.length > 0 ? `Created label(s): ${created.join(", ")}` : "All required labels already existed.",
1305
- `Wrote ${path}; dispatch is ready.`,
1306
- briefPath === undefined
1307
- ? "Kept the existing orchestrator brief."
1308
- : `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
1309
- runtimeFiles.length === 0
1310
- ? "Host runtime files were already current."
1311
- : `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
1312
- `Smoke passed: ${smokeLine}.`,
1313
- `Heartbeat: ${armLine}.`,
1314
- "",
1315
- "On a systemd host, install and start the supervised daemon:",
1316
- ...(restartVia === "cli" ? [" omp-conductor stop"] : []),
1317
- ...runtime.installCommands.map((command) => ` ${command}`),
1318
- "",
1319
- "Without systemd, run `omp-conductor start`.",
1320
- "Use the documented toy-issue drill to prove one complete worker path.",
1321
- ].join("\n"),
1322
- "info",
1323
- );
1324
- }
1325
-
1326
- export default function conductorPlugin(pi: PluginApi): void {
1327
- pi.registerCommand("conductor", {
1328
- description: "Dispatch ready issues to omp coding sessions",
1329
- getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((s) => s.value.startsWith(prefix.trim())),
1330
-
1331
- handler: async (args, ctx) => {
1332
- // findProject() throws when the config holds several projects and none is
1333
- // named, so the project name rides along as an optional second word.
1334
- const tokens = args.trim().split(/\s+/).filter(Boolean);
1335
- const sub = tokens[0];
1336
- const withPane = tokens.includes("--pane");
1337
- const project = tokens.find((t, i) => i > 0 && t !== "--pane");
1338
-
1339
- try {
1340
- switch (sub) {
1341
- case "setup":
1342
- await setup(ctx, project);
1343
- break;
1344
-
1345
- case "status":
1346
- ctx.ui.notify(await renderStatus(project), "info");
1347
- break;
1348
-
1349
- case "hold": {
1350
- const r = hold(project);
1351
- ctx.ui.notify(
1352
- `Held — claiming paused; ticks disarmed at ${r.disarmed.path}. Daemon and pane left running.`,
1353
- "info",
1354
- );
1355
- break;
1356
- }
1357
-
1358
- case "halt": {
1359
- if (withPane) {
1360
- const r = await haltWithPane(project);
1361
- const stop =
1362
- r.stop.kind === "not-running"
1363
- ? "daemon was not running"
1364
- : `daemon stopped (pid ${r.stop.pid})`;
1365
- ctx.ui.notify(
1366
- `Halted — ${stop}. Pane: ${r.pane.stopped} (${r.pane.detail}); recovery pinned at ${r.pane.pinPath}.`,
1367
- "info",
1368
- );
1369
- } else {
1370
- const r = await halt(project);
1371
- const stop =
1372
- r.stop.kind === "not-running"
1373
- ? "daemon was not running"
1374
- : `daemon stopped (pid ${r.stop.pid})`;
1375
- ctx.ui.notify(`Halted — ${stop}. Pane left running.`, "info");
1376
- }
1377
- break;
1378
- }
1379
-
1380
- case "arm": {
1381
- ctx.ui.notify("Arm: sending inbound Telegram challenge — reply in the bot DM…", "info");
1382
- const r = await armTicks(project);
1383
- ctx.ui.notify(`ARMED — owner ${r.owner}; marker ${r.path}`, "info");
1384
- break;
1385
- }
1386
-
1387
- case "disarm": {
1388
- const r = disarmTicks(project);
1389
- ctx.ui.notify(`Disarmed — ${r.path}`, "info");
1390
- break;
1391
- }
1392
-
1393
- case "release-pane": {
1394
- const r = clearPaneHalt(project);
1395
- ctx.ui.notify(
1396
- r.wasHalted ? `Pane recovery pin cleared (${r.path}).` : `No pane recovery pin at ${r.path}.`,
1397
- "info",
1398
- );
1399
- break;
1400
- }
1401
-
1402
- case "pause":
1403
- setPaused(true, { source: "pause", reason: "via /conductor pause" });
1404
- ctx.ui.notify("Paused claiming only — ticks keep firing if armed. Prefer /conductor hold.", "info");
1405
- break;
1406
-
1407
- case "resume":
1408
- releaseHold();
1409
- ctx.ui.notify("Resumed claiming — did NOT re-arm. Run /conductor arm for ticks.", "info");
1410
- break;
1411
-
1412
- case "brief-upgrade": {
1413
- const p = findProject(loadConfig(), project);
1414
- const path = briefPathForProject(p);
1415
- const rendered = renderBriefForProject(p);
1416
- const layout = inspectBriefLayout(p.workspaceRoot, rendered);
1417
- if (layout.kind === "missing") {
1418
- ctx.ui.notify(
1419
- `No brief at ${path} — run /conductor setup and say yes to writing ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME}.`,
1420
- "warning",
1421
- );
1422
- break;
1423
- }
1424
- if (layout.kind === "overlay") {
1425
- ctx.ui.notify(
1426
- formatBriefReport(path, layout, []),
1427
- "info",
1428
- );
1429
- const repair = await ctx.ui.confirm(
1430
- "Repair POLICY.md banner crumbs and recompose?",
1431
- "Strips any leading HTML-comment leftovers from a pre-fix migrate, then recomposes ORCHESTRATOR.md from the package floor + POLICY.md.",
1432
- );
1433
- if (repair) {
1434
- const repaired = repairPolicyBannerCrumbs({
1435
- orchestratorPath: layout.orchestratorPath,
1436
- policyPath: layout.policyPath,
1437
- floor: renderFloorForProject(p),
1438
- });
1439
- ctx.ui.notify(
1440
- repaired === undefined
1441
- ? "Recomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip."
1442
- : formatMigrateResult(repaired),
1443
- "info",
1444
- );
1445
- }
1446
- break;
1447
- }
1448
- if (layout.kind === "legacy-bannered") {
1449
- ctx.ui.notify(
1450
- [
1451
- `Legacy bannered brief at ${layout.orchestratorPath}.`,
1452
- "Migrate the owned half into POLICY.md so the package floor refreshes each tick.",
1453
- ].join("\n"),
1454
- "warning",
1455
- );
1456
- const migrate = await ctx.ui.confirm(
1457
- "Migrate to POLICY.md overlay?",
1458
- "Writes POLICY.md from everything below YOURS TO EDIT, recomposes ORCHESTRATOR.md from the package floor + that policy, and keeps backups.",
1459
- );
1460
- if (migrate) {
1461
- const result = migrateToPolicy({
1462
- orchestratorPath: layout.orchestratorPath,
1463
- policyPath: policyPathForProject(p),
1464
- floor: renderFloorForProject(p),
1465
- owned: layout.owned,
1466
- });
1467
- ctx.ui.notify(formatMigrateResult(result), "info");
1468
- }
1469
- break;
1470
- }
1471
- // Hand-written: the plugin no longer merges single-file briefs
1472
- // (#131). There is no banner, so nothing here can tell which lines
1473
- // are the operator's — a retrofit has to name the cut first.
1474
- ctx.ui.notify(
1475
- `Hand-written brief at ${path} — run: omp-conductor brief-upgrade --retrofit (then --migrate). The plugin no longer merges single-file briefs.`,
1476
- "warning",
1477
- );
1478
- break;
1479
- }
1480
-
1481
- default:
1482
- ctx.ui.notify(
1483
- `${sub ? `Unknown subcommand "${sub}".` : "Pick a subcommand."}\n\n${USAGE}` +
1484
- (isPaused() ? "\n\nThe conductor is currently paused." : ""),
1485
- sub ? "warning" : "info",
1486
- );
1487
- }
1488
- } catch (err) {
1489
- // Config problems arrive as a single readable message listing every
1490
- // fault, which is more use to the operator than a stack.
1491
- ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
1492
- }
1493
- },
1494
- });
1495
- }