omp-conductor 0.3.5 → 0.3.7
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 +112 -6
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +18 -2
- package/src/orchestrator-tick.ts +433 -5
- package/src/plugin.ts +285 -98
- package/src/setup.ts +283 -2
- package/src/worktree.ts +19 -7
package/src/plugin.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* worth protecting is on `setup()` below — nothing is written before the confirm.
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
-
import {
|
|
14
|
+
import { isAbsolute } from "node:path";
|
|
15
15
|
import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
|
|
16
16
|
import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
17
17
|
import {
|
|
@@ -25,24 +25,30 @@ import {
|
|
|
25
25
|
} from "./daemon.ts";
|
|
26
26
|
import { defaultGraphRoot } from "./graph.ts";
|
|
27
27
|
import {
|
|
28
|
+
AMEND_AREAS,
|
|
28
29
|
ORCHESTRATOR_BRIEF_NAME,
|
|
29
30
|
REPORT_SCOPE_CHOICES,
|
|
30
31
|
SETUP_DEFAULTS,
|
|
32
|
+
amendChoices,
|
|
33
|
+
answersFromProject,
|
|
31
34
|
briefPathForProject,
|
|
32
35
|
buildConfig,
|
|
33
36
|
checkTokenScopes,
|
|
34
37
|
createMissingLabels,
|
|
38
|
+
defaultAnswers,
|
|
35
39
|
detectTelegram,
|
|
40
|
+
formatGates,
|
|
36
41
|
orchestratorBriefPath,
|
|
37
42
|
planLabels,
|
|
38
43
|
renderBriefForProject,
|
|
44
|
+
summariseAmend,
|
|
39
45
|
summarisePlan,
|
|
40
46
|
writeOrchestratorBrief,
|
|
47
|
+
type AmendAreaId,
|
|
41
48
|
type SetupAnswers,
|
|
42
49
|
} from "./setup.ts";
|
|
43
50
|
import {
|
|
44
51
|
DEFAULT_CAPS,
|
|
45
|
-
DEFAULT_REPORT_SCOPE,
|
|
46
52
|
type Caps,
|
|
47
53
|
type ConductorConfig,
|
|
48
54
|
type OrchestratorMode,
|
|
@@ -102,7 +108,11 @@ interface PluginApi {
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
const SUBCOMMANDS: Completion[] = [
|
|
105
|
-
{
|
|
111
|
+
{
|
|
112
|
+
value: "setup",
|
|
113
|
+
label: "setup",
|
|
114
|
+
description: "wizard: config, labels, dry run, then arm — or amend one area of a configured project",
|
|
115
|
+
},
|
|
106
116
|
{ value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
|
|
107
117
|
{ value: "pause", label: "pause", description: "stop claiming new work" },
|
|
108
118
|
{ value: "resume", label: "resume", description: "allow claiming again" },
|
|
@@ -114,7 +124,7 @@ const SUBCOMMANDS: Completion[] = [
|
|
|
114
124
|
];
|
|
115
125
|
|
|
116
126
|
const USAGE = [
|
|
117
|
-
"/conductor setup [project] create
|
|
127
|
+
"/conductor setup [project] create a project, or amend one area of one you already have",
|
|
118
128
|
"/conductor status [project] pause state, caps, active runs, today's usage",
|
|
119
129
|
"/conductor pause stop claiming new work",
|
|
120
130
|
"/conductor resume allow claiming again",
|
|
@@ -185,7 +195,8 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
|
|
|
185
195
|
|
|
186
196
|
/**
|
|
187
197
|
* Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
|
|
188
|
-
* `bun run check, bun test @ server`.
|
|
198
|
+
* `bun run check, bun test @ server`. Shown through `formatGates`, the same
|
|
199
|
+
* spelling the amend menu reads a repo's current gates back with.
|
|
189
200
|
*
|
|
190
201
|
* ponytail: the ceiling is a command containing a comma or a literal " @ ",
|
|
191
202
|
* which this would split wrongly. Rare in a lint or test invocation, and the
|
|
@@ -197,8 +208,11 @@ async function askGates(
|
|
|
197
208
|
repoName: string,
|
|
198
209
|
seed: { cmd: string; cwd: string }[],
|
|
199
210
|
): Promise<{ cmd: string; cwd: string }[]> {
|
|
200
|
-
const
|
|
201
|
-
|
|
211
|
+
const raw = await ask(
|
|
212
|
+
ctx,
|
|
213
|
+
`Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
|
|
214
|
+
formatGates(seed),
|
|
215
|
+
);
|
|
202
216
|
|
|
203
217
|
const gates: { cmd: string; cwd: string }[] = [];
|
|
204
218
|
for (const chunk of raw.split(",")) {
|
|
@@ -359,40 +373,40 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
|
|
|
359
373
|
}
|
|
360
374
|
|
|
361
375
|
/**
|
|
362
|
-
*
|
|
363
|
-
*
|
|
376
|
+
* One area's questions, over the answers everything else is carried through in.
|
|
377
|
+
*
|
|
378
|
+
* Every asker takes the whole answer set and returns the whole answer set with
|
|
379
|
+
* only its own fields replaced. That is what lets the full interview fold them in
|
|
380
|
+
* order while an amend applies exactly one, with no second spelling of either the
|
|
381
|
+
* prompts or the defaults they pre-fill from: the value shown is always the value
|
|
382
|
+
* that would otherwise be carried through.
|
|
364
383
|
*/
|
|
365
|
-
|
|
366
|
-
ctx: CommandContext,
|
|
367
|
-
existing: ConductorConfig | undefined,
|
|
368
|
-
projectArg: string | undefined,
|
|
369
|
-
): Promise<SetupAnswers> {
|
|
370
|
-
const prior = priorProject(existing, projectArg);
|
|
371
|
-
|
|
372
|
-
const projectName = await askValid(
|
|
373
|
-
ctx,
|
|
374
|
-
"Project name",
|
|
375
|
-
projectArg ?? prior?.name ?? "",
|
|
376
|
-
(v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
|
|
377
|
-
);
|
|
384
|
+
type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>;
|
|
378
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Where work comes from and where it lands: tracker, labels, routing prefix, and
|
|
388
|
+
* every repo an issue can be routed to, each with its gates. One area because it
|
|
389
|
+
* is one fact — the identity of the queue — and changing any part of it without
|
|
390
|
+
* seeing the rest is how a routing prefix stops matching its labels.
|
|
391
|
+
*/
|
|
392
|
+
const askTrackerAndRepos: AreaAsker = async (ctx, a) => {
|
|
379
393
|
const trackerRepo = await askValid(
|
|
380
394
|
ctx,
|
|
381
395
|
"Tracker repo (owner/repo) — where ready issues live",
|
|
382
|
-
|
|
396
|
+
a.trackerRepo,
|
|
383
397
|
(v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
|
|
384
398
|
);
|
|
385
399
|
|
|
386
400
|
const queueLabel = await ask(
|
|
387
401
|
ctx,
|
|
388
402
|
"Queue label — the human sign-off that makes an issue claimable",
|
|
389
|
-
|
|
403
|
+
a.queueLabel,
|
|
390
404
|
);
|
|
391
405
|
|
|
392
406
|
// One confirm instead of three prompts: the namespaced defaults are right for
|
|
393
407
|
// almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
|
|
394
408
|
// its reputation.
|
|
395
|
-
const stateLabels: SetupAnswers["stateLabels"] = { ...
|
|
409
|
+
const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
|
|
396
410
|
const customiseStates = await ctx.ui.confirm(
|
|
397
411
|
"State labels",
|
|
398
412
|
`The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
|
|
@@ -407,13 +421,12 @@ async function collectAnswers(
|
|
|
407
421
|
const routingLabelPrefix = await ask(
|
|
408
422
|
ctx,
|
|
409
423
|
"Routing label prefix — an issue picks its checkout with <prefix><repo>",
|
|
410
|
-
|
|
424
|
+
a.routingLabelPrefix,
|
|
411
425
|
);
|
|
412
426
|
|
|
413
427
|
const targetRepos: SetupAnswers["targetRepos"] = [];
|
|
414
|
-
const seeds = Object.values(prior?.routing.repos ?? {});
|
|
415
428
|
for (let i = 0; ; i++) {
|
|
416
|
-
const seed =
|
|
429
|
+
const seed = a.targetRepos[i];
|
|
417
430
|
const name = await askValid(
|
|
418
431
|
ctx,
|
|
419
432
|
`Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
|
|
@@ -440,18 +453,53 @@ async function collectAnswers(
|
|
|
440
453
|
if (!more) break;
|
|
441
454
|
}
|
|
442
455
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
456
|
+
return { ...a, trackerRepo, queueLabel, stateLabels, routingLabelPrefix, targetRepos };
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The gates alone, repo by repo, with nothing else asked.
|
|
461
|
+
*
|
|
462
|
+
* The area that earns amend mode: a CI command changes far more often than a
|
|
463
|
+
* clone URL does, and re-typing four repos to correct one lint invocation is the
|
|
464
|
+
* reason an operator edits config.json by hand instead.
|
|
465
|
+
*/
|
|
466
|
+
const askGatesOnly: AreaAsker = async (ctx, a) => {
|
|
467
|
+
if (a.targetRepos.length === 0) {
|
|
468
|
+
ctx.ui.notify("No repos are configured yet — amend \"tracker & repos\" first.", "warning");
|
|
469
|
+
return a;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const targetRepos: SetupAnswers["targetRepos"] = [];
|
|
473
|
+
for (const r of a.targetRepos) {
|
|
474
|
+
targetRepos.push({ ...r, gates: await askGates(ctx, r.name, r.gates) });
|
|
475
|
+
}
|
|
476
|
+
return { ...a, targetRepos };
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Whether workers get a code graph, and where its clones live. Asked after the
|
|
481
|
+
* repos in the full interview because the answer is derived per repo.
|
|
482
|
+
*/
|
|
483
|
+
const askGraph: AreaAsker = async (ctx, a) => {
|
|
447
484
|
const graphRoot = await askGraphRoot(
|
|
448
485
|
ctx,
|
|
449
|
-
trackerRepo,
|
|
450
|
-
targetRepos.map((r) => r.name),
|
|
451
|
-
|
|
486
|
+
a.trackerRepo,
|
|
487
|
+
a.targetRepos.map((r) => r.name),
|
|
488
|
+
a.graphRoot,
|
|
452
489
|
);
|
|
453
490
|
|
|
454
|
-
const
|
|
491
|
+
const next: SetupAnswers = { ...a };
|
|
492
|
+
// Deleted rather than set to `undefined`: the absence of the key is what keeps
|
|
493
|
+
// a project that declines graphs identical to one written before they existed.
|
|
494
|
+
if (graphRoot === undefined) delete next.graphRoot;
|
|
495
|
+
else next.graphRoot = graphRoot;
|
|
496
|
+
return next;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** The hard ceilings. One confirm first, because the shipped defaults are the
|
|
500
|
+
* answer for anyone who has not measured their own runners. */
|
|
501
|
+
const askCaps: AreaAsker = async (ctx, a) => {
|
|
502
|
+
const caps: Partial<Caps> = { ...a.caps };
|
|
455
503
|
const tuneCaps = await ctx.ui.confirm(
|
|
456
504
|
"Caps",
|
|
457
505
|
`Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
|
|
@@ -459,44 +507,48 @@ async function collectAnswers(
|
|
|
459
507
|
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
460
508
|
`${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
|
|
461
509
|
);
|
|
462
|
-
if (tuneCaps) {
|
|
463
|
-
// Spelled out rather than looped: adding a cap should fail to compile here,
|
|
464
|
-
// not silently go unasked.
|
|
465
|
-
caps.maxConcurrentWorkers = await askNumber(
|
|
466
|
-
ctx,
|
|
467
|
-
"Max concurrent workers",
|
|
468
|
-
caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
|
|
469
|
-
);
|
|
470
|
-
caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
|
|
471
|
-
caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
|
|
472
|
-
caps.workerWallClockMs = await askNumber(
|
|
473
|
-
ctx,
|
|
474
|
-
"Wall-clock ceiling per worker (ms)",
|
|
475
|
-
caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
|
|
476
|
-
);
|
|
477
|
-
caps.maxAttemptsPerIssue = await askNumber(
|
|
478
|
-
ctx,
|
|
479
|
-
"Attempts per issue before it escalates",
|
|
480
|
-
caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
|
|
481
|
-
);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Straight after the caps, and for the same reason they sit together: these
|
|
485
|
-
// are the two questions that decide what an unattended fleet may do without
|
|
486
|
-
// asking anybody.
|
|
487
|
-
const authority = await askAuthority(ctx, prior?.authority ?? SETUP_DEFAULTS.authority);
|
|
510
|
+
if (!tuneCaps) return { ...a, caps };
|
|
488
511
|
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
|
|
512
|
+
// Spelled out rather than looped: adding a cap should fail to compile here,
|
|
513
|
+
// not silently go unasked.
|
|
514
|
+
caps.maxConcurrentWorkers = await askNumber(
|
|
492
515
|
ctx,
|
|
493
|
-
"
|
|
494
|
-
|
|
516
|
+
"Max concurrent workers",
|
|
517
|
+
caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
|
|
495
518
|
);
|
|
496
|
-
|
|
497
|
-
|
|
519
|
+
caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
|
|
520
|
+
caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
|
|
521
|
+
caps.workerWallClockMs = await askNumber(
|
|
522
|
+
ctx,
|
|
523
|
+
"Wall-clock ceiling per worker (ms)",
|
|
524
|
+
caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
|
|
525
|
+
);
|
|
526
|
+
caps.maxAttemptsPerIssue = await askNumber(
|
|
527
|
+
ctx,
|
|
528
|
+
"Attempts per issue before it escalates",
|
|
529
|
+
caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
|
|
530
|
+
);
|
|
531
|
+
return { ...a, caps };
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
/** Outside the caps block: a model is not a ceiling, and an operator who left
|
|
535
|
+
* the caps alone may still want workers on a cheaper model. */
|
|
536
|
+
const askWorkerModel: AreaAsker = async (ctx, a) => {
|
|
537
|
+
const answered = await ask(ctx, "Worker model pattern (blank = harness default)", a.workerModel ?? "");
|
|
538
|
+
const next: SetupAnswers = { ...a };
|
|
539
|
+
if (answered.trim().length === 0) delete next.workerModel;
|
|
540
|
+
else next.workerModel = answered.trim();
|
|
541
|
+
return next;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/** Both grants, asked together because they are the two questions that decide
|
|
545
|
+
* what an unattended fleet may do without asking anybody. */
|
|
546
|
+
const askAuthorityArea: AreaAsker = async (ctx, a) => ({ ...a, authority: await askAuthority(ctx, a.authority) });
|
|
547
|
+
|
|
548
|
+
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
549
|
+
const askEscalation: AreaAsker = async (ctx, a) => {
|
|
498
550
|
const telegram = detectTelegram();
|
|
499
|
-
let telegramChatId =
|
|
551
|
+
let telegramChatId = a.telegramChatId;
|
|
500
552
|
if (telegram.available && telegram.hasToken) {
|
|
501
553
|
if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
|
|
502
554
|
const usePaired = await ctx.ui.confirm(
|
|
@@ -521,33 +573,128 @@ async function collectAnswers(
|
|
|
521
573
|
"Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
|
|
522
574
|
);
|
|
523
575
|
|
|
524
|
-
const orchestratorMode = await askOrchestratorMode(
|
|
576
|
+
const orchestratorMode = await askOrchestratorMode(ctx, a.orchestratorMode);
|
|
577
|
+
|
|
578
|
+
const next: SetupAnswers = { ...a, fallbackToIssueComment, orchestratorMode };
|
|
579
|
+
if (telegramChatId === undefined) delete next.telegramChatId;
|
|
580
|
+
else next.telegramChatId = telegramChatId;
|
|
581
|
+
return next;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/** How loud the orchestrator is when nobody asked it anything. */
|
|
585
|
+
const askReporting: AreaAsker = async (ctx, a) => ({ ...a, reportScope: await askReportScope(ctx, a.reportScope) });
|
|
586
|
+
|
|
587
|
+
/** The operator's own brief. Asked last in the full interview, because the
|
|
588
|
+
* question quotes the path the rest of the answers derive. */
|
|
589
|
+
const askBrief: AreaAsker = async (ctx, a) => ({ ...a, writeOrchestratorBrief: await askOrchestratorBrief(ctx, a) });
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* One dialog sequence per amend area, keyed so a new area cannot be added to
|
|
593
|
+
* {@link AMEND_AREA_IDS} without one.
|
|
594
|
+
*/
|
|
595
|
+
const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
|
|
596
|
+
tracker: askTrackerAndRepos,
|
|
597
|
+
gates: askGatesOnly,
|
|
598
|
+
// The two per-worker knobs the full interview separates with the authority
|
|
599
|
+
// grants; an amend has no reason to put anything between them.
|
|
600
|
+
caps: async (ctx, a) => await askWorkerModel(ctx, await askCaps(ctx, a)),
|
|
601
|
+
graph: askGraph,
|
|
602
|
+
authority: askAuthorityArea,
|
|
603
|
+
escalation: askEscalation,
|
|
604
|
+
reporting: askReporting,
|
|
605
|
+
brief: askBrief,
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
/** The two ways to answer the first question a configured project gets. Labels,
|
|
609
|
+
* because the harness's select resolves to the label it displayed. */
|
|
610
|
+
const AMEND_ONE = "Change one area";
|
|
611
|
+
const REINTERVIEW = "Walk every question again";
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The first question a re-run asks, and the reason amend mode exists: adding one
|
|
615
|
+
* key should not cost twenty prompts.
|
|
616
|
+
*
|
|
617
|
+
* Returns the area to amend, or `undefined` for the full interview. Only asked
|
|
618
|
+
* when the named project is already configured — a first run, or a new project
|
|
619
|
+
* beside an old one, has nothing to amend and is never shown this.
|
|
620
|
+
*/
|
|
621
|
+
async function chooseAmendArea(ctx: CommandContext, prior: ProjectConfig): Promise<AmendAreaId | undefined> {
|
|
622
|
+
const mode = await ctx.ui.select(
|
|
623
|
+
`"${prior.name}" is already configured — what would you like to do?`,
|
|
624
|
+
[
|
|
625
|
+
{
|
|
626
|
+
label: AMEND_ONE,
|
|
627
|
+
description: "asks one area's questions; every other answer is carried through from the saved config",
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
label: REINTERVIEW,
|
|
631
|
+
description: "the full interview, every prompt pre-filled with what is configured now",
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
{ initialIndex: 0 },
|
|
635
|
+
);
|
|
636
|
+
if (mode === undefined) throw new Cancelled();
|
|
637
|
+
if (mode !== AMEND_ONE) {
|
|
638
|
+
// Either the operator chose the full interview, or the dialog answered with
|
|
639
|
+
// a label we never offered. Both land on today's behaviour, which is the one
|
|
640
|
+
// that cannot silently skip a question.
|
|
641
|
+
if (mode !== REINTERVIEW) ctx.ui.notify(`Unrecognised choice "${mode}" — asking everything.`, "warning");
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const choices = amendChoices(prior);
|
|
646
|
+
const picked = await ctx.ui.select(
|
|
647
|
+
"Which area? Each row shows what it says now",
|
|
648
|
+
choices.map((c) => ({ label: c.label, description: c.description })),
|
|
649
|
+
{ initialIndex: 0 },
|
|
650
|
+
);
|
|
651
|
+
if (picked === undefined) throw new Cancelled();
|
|
652
|
+
|
|
653
|
+
const chosen = choices.find((c) => c.label === picked);
|
|
654
|
+
if (chosen === undefined) {
|
|
655
|
+
// Guessing an area here would ask the wrong questions and carry the rest
|
|
656
|
+
// through as if they had been reviewed. Abandoning changes nothing.
|
|
657
|
+
ctx.ui.notify(`Unrecognised choice "${picked}" — nothing was changed.`, "warning");
|
|
658
|
+
throw new Cancelled();
|
|
659
|
+
}
|
|
660
|
+
return chosen.id;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The conversation. Reads only — every answer is collected before anything is
|
|
665
|
+
* checked against GitHub, and long before anything is written.
|
|
666
|
+
*
|
|
667
|
+
* Seeded from one answers object rather than pre-filling each prompt from
|
|
668
|
+
* `prior?.field ?? default`: that is the same carry-through an amend relies on,
|
|
669
|
+
* so the two flows cannot disagree about what an unanswered field is.
|
|
670
|
+
*/
|
|
671
|
+
async function collectAnswers(
|
|
672
|
+
ctx: CommandContext,
|
|
673
|
+
prior: ProjectConfig | undefined,
|
|
674
|
+
projectArg: string | undefined,
|
|
675
|
+
): Promise<SetupAnswers> {
|
|
676
|
+
const seed = prior === undefined ? defaultAnswers(projectArg ?? "") : answersFromProject(prior);
|
|
677
|
+
|
|
678
|
+
const projectName = await askValid(
|
|
525
679
|
ctx,
|
|
526
|
-
|
|
680
|
+
"Project name",
|
|
681
|
+
projectArg ?? seed.projectName,
|
|
682
|
+
(v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
|
|
527
683
|
);
|
|
528
684
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
reportScope,
|
|
543
|
-
// Asked last, and asked with the real path in the question — which needs the
|
|
544
|
-
// rest of the answers to derive, so the decision is folded in below.
|
|
545
|
-
writeOrchestratorBrief: false,
|
|
546
|
-
};
|
|
547
|
-
if (telegramChatId !== undefined) answers.telegramChatId = telegramChatId;
|
|
548
|
-
if (workerModel !== undefined) answers.workerModel = workerModel;
|
|
549
|
-
if (graphRoot !== undefined) answers.graphRoot = graphRoot;
|
|
550
|
-
return { ...answers, writeOrchestratorBrief: await askOrchestratorBrief(ctx, answers) };
|
|
685
|
+
let a: SetupAnswers = { ...seed, projectName };
|
|
686
|
+
a = await askTrackerAndRepos(ctx, a);
|
|
687
|
+
// Straight after the repos, because it is a fact about them: one clone per
|
|
688
|
+
// routed repo, under one root.
|
|
689
|
+
a = await askGraph(ctx, a);
|
|
690
|
+
a = await askCaps(ctx, a);
|
|
691
|
+
a = await askAuthorityArea(ctx, a);
|
|
692
|
+
a = await askWorkerModel(ctx, a);
|
|
693
|
+
a = await askEscalation(ctx, a);
|
|
694
|
+
a = await askReporting(ctx, a);
|
|
695
|
+
// Asked last, and asked with the real path in the question — which needs the
|
|
696
|
+
// rest of the answers to derive.
|
|
697
|
+
return await askBrief(ctx, a);
|
|
551
698
|
}
|
|
552
699
|
|
|
553
700
|
/** The dry run, rendered. Same routing code the loop uses, so this is what the
|
|
@@ -585,14 +732,50 @@ async function tryPreview(project: string): Promise<string[]> {
|
|
|
585
732
|
}
|
|
586
733
|
}
|
|
587
734
|
|
|
735
|
+
/** What the whole conversation produced: the answers, and which area an amend
|
|
736
|
+
* narrowed it to. `amend` absent means every question was asked. */
|
|
737
|
+
export interface CollectedSetup {
|
|
738
|
+
answers: SetupAnswers;
|
|
739
|
+
amend?: { area: AmendAreaId; before: ProjectConfig };
|
|
740
|
+
}
|
|
741
|
+
|
|
588
742
|
/**
|
|
589
|
-
* The
|
|
743
|
+
* The whole conversation, from the amend question to the last prompt, and not one
|
|
744
|
+
* byte further: no `gh`, no dry run, nothing written.
|
|
745
|
+
*
|
|
746
|
+
* Exported at exactly that seam so a test can script the dialogs and pin what a
|
|
747
|
+
* first run asks and what an amend refuses to ask — the two properties amend mode
|
|
748
|
+
* is judged on — on a host with no `gh` and no config.
|
|
749
|
+
*/
|
|
750
|
+
export async function collectSetup(
|
|
751
|
+
ctx: CommandContext,
|
|
752
|
+
existing: ConductorConfig | undefined,
|
|
753
|
+
projectArg: string | undefined,
|
|
754
|
+
): Promise<CollectedSetup> {
|
|
755
|
+
// Only a project that is already configured can be amended. A first run, or a
|
|
756
|
+
// name this config has never seen, goes straight into the full interview with
|
|
757
|
+
// no extra question — which is what it was before amend mode existed.
|
|
758
|
+
const prior = priorProject(existing, projectArg);
|
|
759
|
+
if (prior === undefined) return { answers: await collectAnswers(ctx, undefined, projectArg) };
|
|
760
|
+
|
|
761
|
+
const area = await chooseAmendArea(ctx, prior);
|
|
762
|
+
if (area === undefined) return { answers: await collectAnswers(ctx, prior, projectArg) };
|
|
763
|
+
|
|
764
|
+
return { answers: await AREA_ASKERS[area](ctx, answersFromProject(prior)), amend: { area, before: prior } };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The onboarding wizard, and — for a project it already knows — the amend.
|
|
590
769
|
*
|
|
591
770
|
* The invariant that makes this safe to run against a live tracker: nothing is
|
|
592
771
|
* written or created before the confirm below returns true. Reading the config,
|
|
593
772
|
* asking questions, `checkTokenScopes`, `planLabels` and `previewQueue` are all
|
|
594
773
|
* reads. The four mutations — `createMissingLabels`, `saveConfig`,
|
|
595
774
|
* `writeOrchestratorBrief`, `armConductor` — all live after it. Keep it that way.
|
|
775
|
+
*
|
|
776
|
+
* An amend changes which questions are asked and what the summary leads with,
|
|
777
|
+
* and nothing else: the same answers, the same `buildConfig`, the same single
|
|
778
|
+
* confirm, the same dry run. One writer, one consent gate.
|
|
596
779
|
*/
|
|
597
780
|
async function setup(ctx: CommandContext, projectArg: string | undefined): Promise<void> {
|
|
598
781
|
const path = configPath();
|
|
@@ -604,14 +787,15 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
604
787
|
ctx.ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
|
|
605
788
|
}
|
|
606
789
|
|
|
607
|
-
let
|
|
790
|
+
let collected: CollectedSetup;
|
|
608
791
|
try {
|
|
609
|
-
|
|
792
|
+
collected = await collectSetup(ctx, existing, projectArg);
|
|
610
793
|
} catch (err) {
|
|
611
794
|
if (!(err instanceof Cancelled)) throw err;
|
|
612
795
|
ctx.ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
613
796
|
return;
|
|
614
797
|
}
|
|
798
|
+
const { answers, amend } = collected;
|
|
615
799
|
|
|
616
800
|
const scopes = await checkTokenScopes();
|
|
617
801
|
const labels = await planLabels(answers.trackerRepo, answers);
|
|
@@ -619,6 +803,9 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
619
803
|
|
|
620
804
|
ctx.ui.notify(
|
|
621
805
|
[
|
|
806
|
+
// The delta first when there is one, then the whole plan: the confirm has
|
|
807
|
+
// to name every mutation it authorises, and a delta names none of them.
|
|
808
|
+
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
|
|
622
809
|
summarisePlan(answers, scopes, labels, telegram),
|
|
623
810
|
"",
|
|
624
811
|
existing === undefined
|
|
@@ -633,7 +820,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
633
820
|
|
|
634
821
|
const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
|
|
635
822
|
const go = await ctx.ui.confirm(
|
|
636
|
-
"Apply this setup?"
|
|
823
|
+
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
637
824
|
[
|
|
638
825
|
toCreate.length > 0
|
|
639
826
|
? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
|