omp-conductor 0.3.4 → 0.3.6
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 +204 -6
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +98 -5
- package/src/briefs/orchestrator.md +18 -2
- package/src/briefs/worker.md +16 -5
- package/src/cli.ts +60 -0
- package/src/config.ts +40 -4
- package/src/daemon.ts +15 -1
- package/src/graph.ts +508 -0
- package/src/orchestrator-tick.ts +433 -5
- package/src/plugin.ts +329 -90
- package/src/setup.ts +315 -2
- package/src/types.ts +25 -0
- package/src/worktree.ts +81 -2
package/src/plugin.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
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 { isAbsolute } from "node:path";
|
|
14
15
|
import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
|
|
15
|
-
import { configPath, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
16
|
+
import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
|
|
16
17
|
import {
|
|
17
18
|
armConductor,
|
|
18
19
|
formatStatus,
|
|
@@ -22,25 +23,32 @@ import {
|
|
|
22
23
|
statusSnapshot,
|
|
23
24
|
type QueuePreview,
|
|
24
25
|
} from "./daemon.ts";
|
|
26
|
+
import { defaultGraphRoot } from "./graph.ts";
|
|
25
27
|
import {
|
|
28
|
+
AMEND_AREAS,
|
|
26
29
|
ORCHESTRATOR_BRIEF_NAME,
|
|
27
30
|
REPORT_SCOPE_CHOICES,
|
|
28
31
|
SETUP_DEFAULTS,
|
|
32
|
+
amendChoices,
|
|
33
|
+
answersFromProject,
|
|
29
34
|
briefPathForProject,
|
|
30
35
|
buildConfig,
|
|
31
36
|
checkTokenScopes,
|
|
32
37
|
createMissingLabels,
|
|
38
|
+
defaultAnswers,
|
|
33
39
|
detectTelegram,
|
|
40
|
+
formatGates,
|
|
34
41
|
orchestratorBriefPath,
|
|
35
42
|
planLabels,
|
|
36
43
|
renderBriefForProject,
|
|
44
|
+
summariseAmend,
|
|
37
45
|
summarisePlan,
|
|
38
46
|
writeOrchestratorBrief,
|
|
47
|
+
type AmendAreaId,
|
|
39
48
|
type SetupAnswers,
|
|
40
49
|
} from "./setup.ts";
|
|
41
50
|
import {
|
|
42
51
|
DEFAULT_CAPS,
|
|
43
|
-
DEFAULT_REPORT_SCOPE,
|
|
44
52
|
type Caps,
|
|
45
53
|
type ConductorConfig,
|
|
46
54
|
type OrchestratorMode,
|
|
@@ -100,7 +108,11 @@ interface PluginApi {
|
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
const SUBCOMMANDS: Completion[] = [
|
|
103
|
-
{
|
|
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
|
+
},
|
|
104
116
|
{ value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
|
|
105
117
|
{ value: "pause", label: "pause", description: "stop claiming new work" },
|
|
106
118
|
{ value: "resume", label: "resume", description: "allow claiming again" },
|
|
@@ -112,7 +124,7 @@ const SUBCOMMANDS: Completion[] = [
|
|
|
112
124
|
];
|
|
113
125
|
|
|
114
126
|
const USAGE = [
|
|
115
|
-
"/conductor setup [project] create
|
|
127
|
+
"/conductor setup [project] create a project, or amend one area of one you already have",
|
|
116
128
|
"/conductor status [project] pause state, caps, active runs, today's usage",
|
|
117
129
|
"/conductor pause stop claiming new work",
|
|
118
130
|
"/conductor resume allow claiming again",
|
|
@@ -183,7 +195,8 @@ async function askNumber(ctx: CommandContext, title: string, fallback: number):
|
|
|
183
195
|
|
|
184
196
|
/**
|
|
185
197
|
* Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
|
|
186
|
-
* `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.
|
|
187
200
|
*
|
|
188
201
|
* ponytail: the ceiling is a command containing a comma or a literal " @ ",
|
|
189
202
|
* which this would split wrongly. Rare in a lint or test invocation, and the
|
|
@@ -195,8 +208,11 @@ async function askGates(
|
|
|
195
208
|
repoName: string,
|
|
196
209
|
seed: { cmd: string; cwd: string }[],
|
|
197
210
|
): Promise<{ cmd: string; cwd: string }[]> {
|
|
198
|
-
const
|
|
199
|
-
|
|
211
|
+
const raw = await ask(
|
|
212
|
+
ctx,
|
|
213
|
+
`Pre-push gates for ${repoName} — exactly what CI runs, comma separated`,
|
|
214
|
+
formatGates(seed),
|
|
215
|
+
);
|
|
200
216
|
|
|
201
217
|
const gates: { cmd: string; cwd: string }[] = [];
|
|
202
218
|
for (const chunk of raw.split(",")) {
|
|
@@ -287,6 +303,44 @@ async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode)
|
|
|
287
303
|
return external ? "external" : "embedded";
|
|
288
304
|
}
|
|
289
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Whether workers get a code graph, and where its clones live.
|
|
308
|
+
*
|
|
309
|
+
* One confirm and at most one prompt, asked after the repos are known because
|
|
310
|
+
* the answer is derived per repo. A declined answer leaves the field off every
|
|
311
|
+
* repo, which is what keeps an existing fleet's briefs byte-identical.
|
|
312
|
+
*
|
|
313
|
+
* The root is validated as absolute here rather than at load time so the
|
|
314
|
+
* operator learns immediately: a relative path would be resolved against
|
|
315
|
+
* whichever cwd happened to read the config, and never against the directory
|
|
316
|
+
* that was indexed.
|
|
317
|
+
*/
|
|
318
|
+
async function askGraphRoot(
|
|
319
|
+
ctx: CommandContext,
|
|
320
|
+
trackerRepo: string,
|
|
321
|
+
repoNames: string[],
|
|
322
|
+
prior: string | undefined,
|
|
323
|
+
): Promise<string | undefined> {
|
|
324
|
+
const wanted = await ctx.ui.confirm(
|
|
325
|
+
"Code-graph discovery",
|
|
326
|
+
"Set up code-graph discovery for workers? Workers spend most of their turn budget finding code; " +
|
|
327
|
+
'a graph answers "who calls this" in one call. Conductor keeps one disposable clone per repo, ' +
|
|
328
|
+
"pinned to the default branch purely for indexing — never your own checkout" +
|
|
329
|
+
`${prior === undefined ? "" : `. Currently on, under ${prior}`}.`,
|
|
330
|
+
);
|
|
331
|
+
if (!wanted) return undefined;
|
|
332
|
+
|
|
333
|
+
return await askValid(
|
|
334
|
+
ctx,
|
|
335
|
+
`Root for those clones — one per repo (${repoNames.join(", ")}) is created under it`,
|
|
336
|
+
prior ?? defaultGraphRoot(trackerRepo),
|
|
337
|
+
(v) =>
|
|
338
|
+
isAbsolute(expandHome(v))
|
|
339
|
+
? undefined
|
|
340
|
+
: `"${v}" is not an absolute path — a worker reads this from its own worktree, so a relative one names the wrong directory.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
290
344
|
/**
|
|
291
345
|
* Whether to render the operator's own brief, and — separately — whether an
|
|
292
346
|
* existing one may be replaced. Two questions on purpose: that file is where a
|
|
@@ -319,40 +373,40 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
|
|
|
319
373
|
}
|
|
320
374
|
|
|
321
375
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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.
|
|
324
383
|
*/
|
|
325
|
-
|
|
326
|
-
ctx: CommandContext,
|
|
327
|
-
existing: ConductorConfig | undefined,
|
|
328
|
-
projectArg: string | undefined,
|
|
329
|
-
): Promise<SetupAnswers> {
|
|
330
|
-
const prior = priorProject(existing, projectArg);
|
|
331
|
-
|
|
332
|
-
const projectName = await askValid(
|
|
333
|
-
ctx,
|
|
334
|
-
"Project name",
|
|
335
|
-
projectArg ?? prior?.name ?? "",
|
|
336
|
-
(v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
|
|
337
|
-
);
|
|
384
|
+
type AreaAsker = (ctx: CommandContext, a: SetupAnswers) => Promise<SetupAnswers>;
|
|
338
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) => {
|
|
339
393
|
const trackerRepo = await askValid(
|
|
340
394
|
ctx,
|
|
341
395
|
"Tracker repo (owner/repo) — where ready issues live",
|
|
342
|
-
|
|
396
|
+
a.trackerRepo,
|
|
343
397
|
(v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
|
|
344
398
|
);
|
|
345
399
|
|
|
346
400
|
const queueLabel = await ask(
|
|
347
401
|
ctx,
|
|
348
402
|
"Queue label — the human sign-off that makes an issue claimable",
|
|
349
|
-
|
|
403
|
+
a.queueLabel,
|
|
350
404
|
);
|
|
351
405
|
|
|
352
406
|
// One confirm instead of three prompts: the namespaced defaults are right for
|
|
353
407
|
// almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
|
|
354
408
|
// its reputation.
|
|
355
|
-
const stateLabels: SetupAnswers["stateLabels"] = { ...
|
|
409
|
+
const stateLabels: SetupAnswers["stateLabels"] = { ...a.stateLabels };
|
|
356
410
|
const customiseStates = await ctx.ui.confirm(
|
|
357
411
|
"State labels",
|
|
358
412
|
`The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
|
|
@@ -367,13 +421,12 @@ async function collectAnswers(
|
|
|
367
421
|
const routingLabelPrefix = await ask(
|
|
368
422
|
ctx,
|
|
369
423
|
"Routing label prefix — an issue picks its checkout with <prefix><repo>",
|
|
370
|
-
|
|
424
|
+
a.routingLabelPrefix,
|
|
371
425
|
);
|
|
372
426
|
|
|
373
427
|
const targetRepos: SetupAnswers["targetRepos"] = [];
|
|
374
|
-
const seeds = Object.values(prior?.routing.repos ?? {});
|
|
375
428
|
for (let i = 0; ; i++) {
|
|
376
|
-
const seed =
|
|
429
|
+
const seed = a.targetRepos[i];
|
|
377
430
|
const name = await askValid(
|
|
378
431
|
ctx,
|
|
379
432
|
`Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
|
|
@@ -400,7 +453,53 @@ async function collectAnswers(
|
|
|
400
453
|
if (!more) break;
|
|
401
454
|
}
|
|
402
455
|
|
|
403
|
-
|
|
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) => {
|
|
484
|
+
const graphRoot = await askGraphRoot(
|
|
485
|
+
ctx,
|
|
486
|
+
a.trackerRepo,
|
|
487
|
+
a.targetRepos.map((r) => r.name),
|
|
488
|
+
a.graphRoot,
|
|
489
|
+
);
|
|
490
|
+
|
|
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 };
|
|
404
503
|
const tuneCaps = await ctx.ui.confirm(
|
|
405
504
|
"Caps",
|
|
406
505
|
`Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
|
|
@@ -408,44 +507,48 @@ async function collectAnswers(
|
|
|
408
507
|
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
409
508
|
`${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
|
|
410
509
|
);
|
|
411
|
-
if (tuneCaps) {
|
|
412
|
-
// Spelled out rather than looped: adding a cap should fail to compile here,
|
|
413
|
-
// not silently go unasked.
|
|
414
|
-
caps.maxConcurrentWorkers = await askNumber(
|
|
415
|
-
ctx,
|
|
416
|
-
"Max concurrent workers",
|
|
417
|
-
caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
|
|
418
|
-
);
|
|
419
|
-
caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
|
|
420
|
-
caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
|
|
421
|
-
caps.workerWallClockMs = await askNumber(
|
|
422
|
-
ctx,
|
|
423
|
-
"Wall-clock ceiling per worker (ms)",
|
|
424
|
-
caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
|
|
425
|
-
);
|
|
426
|
-
caps.maxAttemptsPerIssue = await askNumber(
|
|
427
|
-
ctx,
|
|
428
|
-
"Attempts per issue before it escalates",
|
|
429
|
-
caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
|
|
430
|
-
);
|
|
431
|
-
}
|
|
510
|
+
if (!tuneCaps) return { ...a, caps };
|
|
432
511
|
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
const authority = await askAuthority(ctx, prior?.authority ?? SETUP_DEFAULTS.authority);
|
|
437
|
-
|
|
438
|
-
// Outside the caps block: a model is not a ceiling, and an operator who left
|
|
439
|
-
// the caps alone may still want workers on a cheaper model.
|
|
440
|
-
const answeredModel = await ask(
|
|
512
|
+
// Spelled out rather than looped: adding a cap should fail to compile here,
|
|
513
|
+
// not silently go unasked.
|
|
514
|
+
caps.maxConcurrentWorkers = await askNumber(
|
|
441
515
|
ctx,
|
|
442
|
-
"
|
|
443
|
-
|
|
516
|
+
"Max concurrent workers",
|
|
517
|
+
caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
|
|
444
518
|
);
|
|
445
|
-
|
|
446
|
-
|
|
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) => {
|
|
447
550
|
const telegram = detectTelegram();
|
|
448
|
-
let telegramChatId =
|
|
551
|
+
let telegramChatId = a.telegramChatId;
|
|
449
552
|
if (telegram.available && telegram.hasToken) {
|
|
450
553
|
if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
|
|
451
554
|
const usePaired = await ctx.ui.confirm(
|
|
@@ -470,32 +573,128 @@ async function collectAnswers(
|
|
|
470
573
|
"Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
|
|
471
574
|
);
|
|
472
575
|
|
|
473
|
-
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(
|
|
474
679
|
ctx,
|
|
475
|
-
|
|
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."),
|
|
476
683
|
);
|
|
477
684
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
reportScope,
|
|
492
|
-
// Asked last, and asked with the real path in the question — which needs the
|
|
493
|
-
// rest of the answers to derive, so the decision is folded in below.
|
|
494
|
-
writeOrchestratorBrief: false,
|
|
495
|
-
};
|
|
496
|
-
if (telegramChatId !== undefined) answers.telegramChatId = telegramChatId;
|
|
497
|
-
if (workerModel !== undefined) answers.workerModel = workerModel;
|
|
498
|
-
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);
|
|
499
698
|
}
|
|
500
699
|
|
|
501
700
|
/** The dry run, rendered. Same routing code the loop uses, so this is what the
|
|
@@ -533,14 +732,50 @@ async function tryPreview(project: string): Promise<string[]> {
|
|
|
533
732
|
}
|
|
534
733
|
}
|
|
535
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
|
+
|
|
536
742
|
/**
|
|
537
|
-
* 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.
|
|
538
769
|
*
|
|
539
770
|
* The invariant that makes this safe to run against a live tracker: nothing is
|
|
540
771
|
* written or created before the confirm below returns true. Reading the config,
|
|
541
772
|
* asking questions, `checkTokenScopes`, `planLabels` and `previewQueue` are all
|
|
542
773
|
* reads. The four mutations — `createMissingLabels`, `saveConfig`,
|
|
543
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.
|
|
544
779
|
*/
|
|
545
780
|
async function setup(ctx: CommandContext, projectArg: string | undefined): Promise<void> {
|
|
546
781
|
const path = configPath();
|
|
@@ -552,14 +787,15 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
552
787
|
ctx.ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
|
|
553
788
|
}
|
|
554
789
|
|
|
555
|
-
let
|
|
790
|
+
let collected: CollectedSetup;
|
|
556
791
|
try {
|
|
557
|
-
|
|
792
|
+
collected = await collectSetup(ctx, existing, projectArg);
|
|
558
793
|
} catch (err) {
|
|
559
794
|
if (!(err instanceof Cancelled)) throw err;
|
|
560
795
|
ctx.ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
561
796
|
return;
|
|
562
797
|
}
|
|
798
|
+
const { answers, amend } = collected;
|
|
563
799
|
|
|
564
800
|
const scopes = await checkTokenScopes();
|
|
565
801
|
const labels = await planLabels(answers.trackerRepo, answers);
|
|
@@ -567,6 +803,9 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
567
803
|
|
|
568
804
|
ctx.ui.notify(
|
|
569
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)]),
|
|
570
809
|
summarisePlan(answers, scopes, labels, telegram),
|
|
571
810
|
"",
|
|
572
811
|
existing === undefined
|
|
@@ -581,7 +820,7 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
|
|
|
581
820
|
|
|
582
821
|
const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
|
|
583
822
|
const go = await ctx.ui.confirm(
|
|
584
|
-
"Apply this setup?"
|
|
823
|
+
amend === undefined ? "Apply this setup?" : `Apply this change to ${AMEND_AREAS[amend.area].name}?`,
|
|
585
824
|
[
|
|
586
825
|
toCreate.length > 0
|
|
587
826
|
? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
|