omp-conductor 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
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 } from "node:fs";
|
|
14
|
+
import { configPath, loadConfig, saveConfig } from "./config.ts";
|
|
15
|
+
import {
|
|
16
|
+
armConductor,
|
|
17
|
+
formatStatus,
|
|
18
|
+
isPaused,
|
|
19
|
+
previewQueue,
|
|
20
|
+
setPaused,
|
|
21
|
+
statusSnapshot,
|
|
22
|
+
type QueuePreview,
|
|
23
|
+
} from "./daemon.ts";
|
|
24
|
+
import {
|
|
25
|
+
ORCHESTRATOR_BRIEF_NAME,
|
|
26
|
+
REPORT_SCOPE_CHOICES,
|
|
27
|
+
SETUP_DEFAULTS,
|
|
28
|
+
buildConfig,
|
|
29
|
+
checkTokenScopes,
|
|
30
|
+
createMissingLabels,
|
|
31
|
+
detectTelegram,
|
|
32
|
+
orchestratorBriefPath,
|
|
33
|
+
planLabels,
|
|
34
|
+
summarisePlan,
|
|
35
|
+
writeOrchestratorBrief,
|
|
36
|
+
type SetupAnswers,
|
|
37
|
+
} from "./setup.ts";
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_CAPS,
|
|
40
|
+
DEFAULT_REPORT_SCOPE,
|
|
41
|
+
type Caps,
|
|
42
|
+
type ConductorConfig,
|
|
43
|
+
type ProjectConfig,
|
|
44
|
+
type ReportScope,
|
|
45
|
+
} from "./types.ts";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The slice of the omp extension API this plugin actually touches, mirroring
|
|
49
|
+
* `RegisteredCommand` / `ExtensionUIContext` from `@oh-my-pi/pi-coding-agent`.
|
|
50
|
+
*
|
|
51
|
+
* Declared here rather than imported because the harness is a peer dependency:
|
|
52
|
+
* the package has to type-check without it installed. Structural typing means
|
|
53
|
+
* the real API object satisfies this on the way in, and narrowing the surface
|
|
54
|
+
* to the five members the wizard uses keeps the coupling visible.
|
|
55
|
+
*/
|
|
56
|
+
interface Completion {
|
|
57
|
+
value: string;
|
|
58
|
+
label: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface CommandContext {
|
|
63
|
+
ui: {
|
|
64
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
65
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
66
|
+
/**
|
|
67
|
+
* Single-line text prompt. Resolves `undefined` when the operator dismisses
|
|
68
|
+
* the dialog, which the wizard treats as "abandon, change nothing".
|
|
69
|
+
*
|
|
70
|
+
* The harness has no pre-filled variant, so `placeholder` carries the
|
|
71
|
+
* default and submitting an empty line accepts it.
|
|
72
|
+
*/
|
|
73
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
74
|
+
/**
|
|
75
|
+
* Single-choice list. Resolves the chosen option's **label**, or
|
|
76
|
+
* `undefined` when the operator dismisses it — so callers map labels back to
|
|
77
|
+
* their own values rather than trusting the index.
|
|
78
|
+
*/
|
|
79
|
+
select(
|
|
80
|
+
title: string,
|
|
81
|
+
options: { label: string; description?: string }[],
|
|
82
|
+
dialogOptions?: { initialIndex?: number },
|
|
83
|
+
): Promise<string | undefined>;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface PluginApi {
|
|
88
|
+
registerCommand(
|
|
89
|
+
name: string,
|
|
90
|
+
options: {
|
|
91
|
+
description?: string;
|
|
92
|
+
getArgumentCompletions?: (argumentPrefix: string) => Completion[] | null;
|
|
93
|
+
handler: (args: string, ctx: CommandContext) => Promise<void>;
|
|
94
|
+
},
|
|
95
|
+
): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const SUBCOMMANDS: Completion[] = [
|
|
99
|
+
{ value: "setup", label: "setup", description: "onboarding wizard: config, labels, dry run, then arm" },
|
|
100
|
+
{ value: "status", label: "status", description: "pause state, caps, active runs, today's usage" },
|
|
101
|
+
{ value: "pause", label: "pause", description: "stop claiming new work" },
|
|
102
|
+
{ value: "resume", label: "resume", description: "allow claiming again" },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const USAGE = [
|
|
106
|
+
"/conductor setup [project] create or update a project, then arm after you confirm",
|
|
107
|
+
"/conductor status [project] pause state, caps, active runs, today's usage",
|
|
108
|
+
"/conductor pause stop claiming new work",
|
|
109
|
+
"/conductor resume allow claiming again",
|
|
110
|
+
].join("\n");
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Dismissing any dialog abandons the whole wizard.
|
|
114
|
+
*
|
|
115
|
+
* Thrown rather than returned as a sentinel: the prompt sequence runs to a
|
|
116
|
+
* dozen questions, and a cancellation check after each one would bury the
|
|
117
|
+
* shape of the conversation under branching.
|
|
118
|
+
*/
|
|
119
|
+
class Cancelled extends Error {
|
|
120
|
+
constructor() {
|
|
121
|
+
super("setup cancelled");
|
|
122
|
+
this.name = "Cancelled";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The spelling `config.ts` validates tracker repos against. Checked here too
|
|
127
|
+
* so a typo is fixed in the dialog instead of in an error an hour later. */
|
|
128
|
+
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One text answer. The placeholder shows the default and an empty submission
|
|
132
|
+
* takes it, because the harness has no pre-filled input dialog — so "Enter
|
|
133
|
+
* accepts what you see" is the contract the whole wizard is built on.
|
|
134
|
+
*/
|
|
135
|
+
async function ask(ctx: CommandContext, title: string, fallback: string): Promise<string> {
|
|
136
|
+
const raw = await ctx.ui.input(title, fallback.length > 0 ? fallback : undefined);
|
|
137
|
+
if (raw === undefined) throw new Cancelled();
|
|
138
|
+
const trimmed = raw.trim();
|
|
139
|
+
return trimmed.length > 0 ? trimmed : fallback;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Re-asks until the answer passes `check`, which returns the complaint or
|
|
144
|
+
* `undefined`. Bounded at three tries: a dialog that cannot be escaped is worse
|
|
145
|
+
* than one that gives up and leaves the config alone.
|
|
146
|
+
*/
|
|
147
|
+
async function askValid(
|
|
148
|
+
ctx: CommandContext,
|
|
149
|
+
title: string,
|
|
150
|
+
fallback: string,
|
|
151
|
+
check: (value: string) => string | undefined,
|
|
152
|
+
): Promise<string> {
|
|
153
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
154
|
+
const value = await ask(ctx, title, fallback);
|
|
155
|
+
const problem = check(value);
|
|
156
|
+
if (problem === undefined) return value;
|
|
157
|
+
ctx.ui.notify(problem, "warning");
|
|
158
|
+
}
|
|
159
|
+
throw new Cancelled();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A cap. Unparseable input keeps the current value rather than writing a NaN
|
|
163
|
+
* the validator would later reject — the operator sees why, immediately. */
|
|
164
|
+
async function askNumber(ctx: CommandContext, title: string, fallback: number): Promise<number> {
|
|
165
|
+
const raw = await ask(ctx, title, String(fallback));
|
|
166
|
+
const value = Number(raw);
|
|
167
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
168
|
+
ctx.ui.notify(`"${raw}" is not a non-negative number — keeping ${fallback}.`, "warning");
|
|
169
|
+
return fallback;
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Pre-push gates as one comma-separated line, `cmd @ cwd` for a subdirectory:
|
|
176
|
+
* `bun run check, bun test @ server`.
|
|
177
|
+
*
|
|
178
|
+
* ponytail: the ceiling is a command containing a comma or a literal " @ ",
|
|
179
|
+
* which this would split wrongly. Rare in a lint or test invocation, and the
|
|
180
|
+
* config file is hand-editable. Upgrade path is `ctx.ui.editor()`, a real
|
|
181
|
+
* multi-line buffer, once someone hits it.
|
|
182
|
+
*/
|
|
183
|
+
async function askGates(
|
|
184
|
+
ctx: CommandContext,
|
|
185
|
+
repoName: string,
|
|
186
|
+
seed: { cmd: string; cwd: string }[],
|
|
187
|
+
): Promise<{ cmd: string; cwd: string }[]> {
|
|
188
|
+
const shown = seed.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
|
|
189
|
+
const raw = await ask(ctx, `Pre-push gates for ${repoName} — exactly what CI runs, comma separated`, shown);
|
|
190
|
+
|
|
191
|
+
const gates: { cmd: string; cwd: string }[] = [];
|
|
192
|
+
for (const chunk of raw.split(",")) {
|
|
193
|
+
const entry = chunk.trim();
|
|
194
|
+
if (entry.length === 0) continue;
|
|
195
|
+
const at = entry.lastIndexOf(" @ ");
|
|
196
|
+
if (at === -1) gates.push({ cmd: entry, cwd: "." });
|
|
197
|
+
else gates.push({ cmd: entry.slice(0, at).trim(), cwd: entry.slice(at + 3).trim() });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (gates.length === 0) {
|
|
201
|
+
// Loud, because an unattended push with no gate is how a lint failure
|
|
202
|
+
// reaches the runners at 03:00 with nobody watching.
|
|
203
|
+
ctx.ui.notify(`No gates for ${repoName} — nothing will be verified before a push.`, "warning");
|
|
204
|
+
}
|
|
205
|
+
return gates;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* How loud the orchestrator should be. A list rather than a confirm: "report
|
|
210
|
+
* scope" has no natural yes, and phrasing it as one would bury which answer
|
|
211
|
+
* means silence. The cursor starts on the current setting so Enter re-affirms
|
|
212
|
+
* it, the same contract every other prompt here has.
|
|
213
|
+
*/
|
|
214
|
+
async function askReportScope(ctx: CommandContext, current: ReportScope): Promise<ReportScope> {
|
|
215
|
+
const options = REPORT_SCOPE_CHOICES.map((c) => ({ label: c.label, description: c.description }));
|
|
216
|
+
const at = REPORT_SCOPE_CHOICES.findIndex((c) => c.scope === current);
|
|
217
|
+
const picked = await ctx.ui.select("What should the orchestrator report unprompted?", options, {
|
|
218
|
+
initialIndex: at === -1 ? 0 : at,
|
|
219
|
+
});
|
|
220
|
+
if (picked === undefined) throw new Cancelled();
|
|
221
|
+
|
|
222
|
+
const choice = REPORT_SCOPE_CHOICES.find((c) => c.label === picked);
|
|
223
|
+
if (choice === undefined) {
|
|
224
|
+
// The harness answers with a label we did not offer only if the dialog
|
|
225
|
+
// contract changed under us; keeping the current scope is the answer that
|
|
226
|
+
// changes nothing, and it is said out loud rather than assumed.
|
|
227
|
+
ctx.ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
|
|
228
|
+
return current;
|
|
229
|
+
}
|
|
230
|
+
return choice.scope;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether to render the operator's own brief, and — separately — whether an
|
|
235
|
+
* existing one may be replaced. Two questions on purpose: that file is where a
|
|
236
|
+
* fleet's release and reporting policy ends up, so it is never overwritten by
|
|
237
|
+
* an operator who only meant to re-run setup.
|
|
238
|
+
*/
|
|
239
|
+
async function askOrchestratorBrief(ctx: CommandContext, a: SetupAnswers): Promise<boolean> {
|
|
240
|
+
const path = orchestratorBriefPath(a);
|
|
241
|
+
const wanted = await ctx.ui.confirm(
|
|
242
|
+
`Write an orchestrator brief template to ${path}?`,
|
|
243
|
+
`It is the standing prompt for your supervising session: duties, tiers, and boundaries, ` +
|
|
244
|
+
`plus a release policy and a reporting section that are yours to edit. ` +
|
|
245
|
+
`The conductor never reads it back — it stops at green PRs either way.`,
|
|
246
|
+
);
|
|
247
|
+
if (!wanted) return false;
|
|
248
|
+
if (!existsSync(path)) return true;
|
|
249
|
+
|
|
250
|
+
return await ctx.ui.confirm(
|
|
251
|
+
`Overwrite the existing ${ORCHESTRATOR_BRIEF_NAME}?`,
|
|
252
|
+
`${path} already exists. Overwriting replaces it with the shipped template — any policy you wrote there is lost.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The project these answers would replace, so a re-run pre-fills with what is
|
|
257
|
+
* already there instead of making the operator retype it. */
|
|
258
|
+
function priorProject(existing: ConductorConfig | undefined, name: string | undefined): ProjectConfig | undefined {
|
|
259
|
+
if (existing === undefined) return undefined;
|
|
260
|
+
if (name !== undefined) return existing.projects.find((p) => p.name === name);
|
|
261
|
+
return existing.projects.length === 1 ? existing.projects[0] : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The conversation. Reads only — every answer is collected before anything is
|
|
266
|
+
* checked against GitHub, and long before anything is written.
|
|
267
|
+
*/
|
|
268
|
+
async function collectAnswers(
|
|
269
|
+
ctx: CommandContext,
|
|
270
|
+
existing: ConductorConfig | undefined,
|
|
271
|
+
projectArg: string | undefined,
|
|
272
|
+
): Promise<SetupAnswers> {
|
|
273
|
+
const prior = priorProject(existing, projectArg);
|
|
274
|
+
|
|
275
|
+
const projectName = await askValid(
|
|
276
|
+
ctx,
|
|
277
|
+
"Project name",
|
|
278
|
+
projectArg ?? prior?.name ?? "",
|
|
279
|
+
(v) => (v.length > 0 ? undefined : "A name is required — it is how `/conductor status <name>` finds this project."),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const trackerRepo = await askValid(
|
|
283
|
+
ctx,
|
|
284
|
+
"Tracker repo (owner/repo) — where ready issues live",
|
|
285
|
+
prior?.tracker.repo ?? "",
|
|
286
|
+
(v) => (REPO_RE.test(v) ? undefined : `"${v}" is not owner/repo — e.g. acme/planning.`),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const queueLabel = await ask(
|
|
290
|
+
ctx,
|
|
291
|
+
"Queue label — the human sign-off that makes an issue claimable",
|
|
292
|
+
prior?.queueLabel ?? SETUP_DEFAULTS.queueLabel,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// One confirm instead of three prompts: the namespaced defaults are right for
|
|
296
|
+
// almost everyone, and three dialogs of Enter-to-accept is how a wizard earns
|
|
297
|
+
// its reputation.
|
|
298
|
+
const stateLabels: SetupAnswers["stateLabels"] = { ...(prior?.stateLabels ?? SETUP_DEFAULTS.stateLabels) };
|
|
299
|
+
const customiseStates = await ctx.ui.confirm(
|
|
300
|
+
"State labels",
|
|
301
|
+
`The conductor writes back "${stateLabels.inProgress}", "${stateLabels.blocked}" and ` +
|
|
302
|
+
`"${stateLabels.failed}" so the tracker alone shows live state. Rename them?`,
|
|
303
|
+
);
|
|
304
|
+
if (customiseStates) {
|
|
305
|
+
stateLabels.inProgress = await ask(ctx, "Label for a run in progress", stateLabels.inProgress);
|
|
306
|
+
stateLabels.blocked = await ask(ctx, "Label for a run parked on a human", stateLabels.blocked);
|
|
307
|
+
stateLabels.failed = await ask(ctx, "Label for a run that gave up", stateLabels.failed);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const routingLabelPrefix = await ask(
|
|
311
|
+
ctx,
|
|
312
|
+
"Routing label prefix — an issue picks its checkout with <prefix><repo>",
|
|
313
|
+
prior?.routing.labelPrefix ?? SETUP_DEFAULTS.routingLabelPrefix,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const targetRepos: SetupAnswers["targetRepos"] = [];
|
|
317
|
+
const seeds = Object.values(prior?.routing.repos ?? {});
|
|
318
|
+
for (let i = 0; ; i++) {
|
|
319
|
+
const seed = seeds[i];
|
|
320
|
+
const name = await askValid(
|
|
321
|
+
ctx,
|
|
322
|
+
`Routing key for repo ${i + 1} — the "${routingLabelPrefix}<key>" label an issue carries`,
|
|
323
|
+
seed?.name ?? "",
|
|
324
|
+
(v) => (v.length > 0 ? undefined : "A routing key is required, or no issue can reach this repo."),
|
|
325
|
+
);
|
|
326
|
+
const cloneUrl = await askValid(
|
|
327
|
+
ctx,
|
|
328
|
+
`Clone URL for ${routingLabelPrefix}${name}`,
|
|
329
|
+
seed?.cloneUrl ?? "",
|
|
330
|
+
(v) => (v.length > 0 ? undefined : "A clone URL is required — the daemon mirrors it before every run."),
|
|
331
|
+
);
|
|
332
|
+
const defaultBranch = await ask(
|
|
333
|
+
ctx,
|
|
334
|
+
`Default branch for ${name} — worktrees are cut from it and PRs target it`,
|
|
335
|
+
seed?.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
|
|
336
|
+
);
|
|
337
|
+
targetRepos.push({ name, cloneUrl, defaultBranch, gates: await askGates(ctx, name, seed?.gates ?? []) });
|
|
338
|
+
|
|
339
|
+
const more = await ctx.ui.confirm(
|
|
340
|
+
"Another repo?",
|
|
341
|
+
`${targetRepos.map((r) => r.name).join(", ")} configured. Add another checkout this project routes to?`,
|
|
342
|
+
);
|
|
343
|
+
if (!more) break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const caps: Partial<Caps> = { ...prior?.caps };
|
|
347
|
+
const tuneCaps = await ctx.ui.confirm(
|
|
348
|
+
"Caps",
|
|
349
|
+
`Defaults: ${DEFAULT_CAPS.maxConcurrentWorkers} workers, ` +
|
|
350
|
+
`$${DEFAULT_CAPS.dailySpendUsd}/day, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
|
|
351
|
+
`${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
|
|
352
|
+
`${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue. Change them?`,
|
|
353
|
+
);
|
|
354
|
+
if (tuneCaps) {
|
|
355
|
+
// Spelled out rather than looped: adding a cap should fail to compile here,
|
|
356
|
+
// not silently go unasked.
|
|
357
|
+
caps.maxConcurrentWorkers = await askNumber(
|
|
358
|
+
ctx,
|
|
359
|
+
"Max concurrent workers",
|
|
360
|
+
caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers,
|
|
361
|
+
);
|
|
362
|
+
caps.dailySpendUsd = await askNumber(ctx, "Spend ceiling per rolling day (USD)", caps.dailySpendUsd ?? DEFAULT_CAPS.dailySpendUsd);
|
|
363
|
+
caps.workerMaxTurns = await askNumber(ctx, "Turn ceiling per worker", caps.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns);
|
|
364
|
+
caps.workerWallClockMs = await askNumber(
|
|
365
|
+
ctx,
|
|
366
|
+
"Wall-clock ceiling per worker (ms)",
|
|
367
|
+
caps.workerWallClockMs ?? DEFAULT_CAPS.workerWallClockMs,
|
|
368
|
+
);
|
|
369
|
+
caps.maxAttemptsPerIssue = await askNumber(
|
|
370
|
+
ctx,
|
|
371
|
+
"Attempts per issue before it escalates",
|
|
372
|
+
caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Outside the caps block: a model is not a ceiling, and an operator who left
|
|
377
|
+
// the caps alone may still want workers on a cheaper model.
|
|
378
|
+
const answeredModel = await ask(
|
|
379
|
+
ctx,
|
|
380
|
+
"Worker model pattern (blank = harness default)",
|
|
381
|
+
prior?.workerModel ?? "",
|
|
382
|
+
);
|
|
383
|
+
const workerModel = answeredModel.trim().length > 0 ? answeredModel.trim() : undefined;
|
|
384
|
+
|
|
385
|
+
const telegram = detectTelegram();
|
|
386
|
+
let telegramChatId = prior?.escalation.telegramChatId;
|
|
387
|
+
if (telegram.available && telegram.hasToken) {
|
|
388
|
+
if (telegramChatId === undefined && telegram.pairedOwnerId !== undefined) {
|
|
389
|
+
const usePaired = await ctx.ui.confirm(
|
|
390
|
+
"Tier-2 escalations",
|
|
391
|
+
`omp-telegram is paired with chat ${telegram.pairedOwnerId}. Page it when a run is stuck?`,
|
|
392
|
+
);
|
|
393
|
+
if (usePaired) telegramChatId = telegram.pairedOwnerId;
|
|
394
|
+
} else {
|
|
395
|
+
const answered = await ask(ctx, "Telegram chat id for tier-2 escalations (blank for none)", telegramChatId ?? "");
|
|
396
|
+
telegramChatId = answered.length > 0 ? answered : undefined;
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
// Not an error: tier 2 degrades to a comment, which is the documented fallback.
|
|
400
|
+
ctx.ui.notify(
|
|
401
|
+
`No usable omp-telegram install at ${telegram.stateDir} — tier-2 escalations will comment on the issue.`,
|
|
402
|
+
"info",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const fallbackToIssueComment = await ctx.ui.confirm(
|
|
407
|
+
"Escalation fallback",
|
|
408
|
+
"Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const reportScope = await askReportScope(ctx, prior?.reporting?.scope ?? DEFAULT_REPORT_SCOPE);
|
|
412
|
+
|
|
413
|
+
const answers: SetupAnswers = {
|
|
414
|
+
projectName,
|
|
415
|
+
trackerRepo,
|
|
416
|
+
queueLabel,
|
|
417
|
+
stateLabels,
|
|
418
|
+
routingLabelPrefix,
|
|
419
|
+
targetRepos,
|
|
420
|
+
caps,
|
|
421
|
+
fallbackToIssueComment,
|
|
422
|
+
reportScope,
|
|
423
|
+
// Asked last, and asked with the real path in the question — which needs the
|
|
424
|
+
// rest of the answers to derive, so the decision is folded in below.
|
|
425
|
+
writeOrchestratorBrief: false,
|
|
426
|
+
};
|
|
427
|
+
if (telegramChatId !== undefined) answers.telegramChatId = telegramChatId;
|
|
428
|
+
if (workerModel !== undefined) answers.workerModel = workerModel;
|
|
429
|
+
return { ...answers, writeOrchestratorBrief: await askOrchestratorBrief(ctx, answers) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The dry run, rendered. Same routing code the loop uses, so this is what the
|
|
433
|
+
* next tick would actually do — not a description of it. */
|
|
434
|
+
function formatPreview(p: QueuePreview): string[] {
|
|
435
|
+
const lines = [
|
|
436
|
+
`state ${p.paused ? "paused" : "armed"}`,
|
|
437
|
+
p.ready.length === 0
|
|
438
|
+
? "Would pick up: nothing — the queue is empty."
|
|
439
|
+
: `Would pick up ${p.ready.length} issue(s), caps permitting:`,
|
|
440
|
+
];
|
|
441
|
+
for (const r of p.ready) lines.push(` #${r.number} → ${r.repo} ${r.branch} ${r.title}`);
|
|
442
|
+
|
|
443
|
+
if (p.unroutable.length > 0) {
|
|
444
|
+
lines.push("", `Cannot route ${p.unroutable.length} issue(s) — these escalate instead of running:`);
|
|
445
|
+
for (const u of p.unroutable) {
|
|
446
|
+
lines.push(` #${u.number} ${u.reason} [${u.labels.join(", ") || "no labels"}] ${u.title}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return lines;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The dry run needs a saved config to read, and before the confirm there may
|
|
454
|
+
* not be one — that is the normal first run, not a failure. So the reason is
|
|
455
|
+
* reported inline and the wizard carries on; the post-write preview is the one
|
|
456
|
+
* that always has something to say.
|
|
457
|
+
*/
|
|
458
|
+
async function tryPreview(project: string): Promise<string[]> {
|
|
459
|
+
try {
|
|
460
|
+
return formatPreview(await previewQueue(project));
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
463
|
+
return [` (not available yet: ${message.split("\n")[0] ?? message})`];
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The onboarding wizard.
|
|
469
|
+
*
|
|
470
|
+
* The invariant that makes this safe to run against a live tracker: nothing is
|
|
471
|
+
* written or created before the confirm below returns true. Reading the config,
|
|
472
|
+
* asking questions, `checkTokenScopes`, `planLabels` and `previewQueue` are all
|
|
473
|
+
* reads. The four mutations — `createMissingLabels`, `saveConfig`,
|
|
474
|
+
* `writeOrchestratorBrief`, `armConductor` — all live after it. Keep it that way.
|
|
475
|
+
*/
|
|
476
|
+
async function setup(ctx: CommandContext, projectArg: string | undefined): Promise<void> {
|
|
477
|
+
const path = configPath();
|
|
478
|
+
// A config that exists but does not parse is a fault to report, never
|
|
479
|
+
// something to quietly replace: overwriting it would delete every project it
|
|
480
|
+
// describes. Absence, by contrast, is just the first run.
|
|
481
|
+
const existing = existsSync(path) ? loadConfig() : undefined;
|
|
482
|
+
if (existing === undefined) {
|
|
483
|
+
ctx.ui.notify(`No config at ${path} yet — let's make one. Nothing is written until you confirm.`, "info");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let answers: SetupAnswers;
|
|
487
|
+
try {
|
|
488
|
+
answers = await collectAnswers(ctx, existing, projectArg);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (!(err instanceof Cancelled)) throw err;
|
|
491
|
+
ctx.ui.notify("Setup cancelled — nothing was changed.", "info");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const scopes = await checkTokenScopes();
|
|
496
|
+
const labels = await planLabels(answers.trackerRepo, answers);
|
|
497
|
+
const telegram = detectTelegram();
|
|
498
|
+
|
|
499
|
+
ctx.ui.notify(
|
|
500
|
+
[
|
|
501
|
+
summarisePlan(answers, scopes, labels, telegram),
|
|
502
|
+
"",
|
|
503
|
+
existing === undefined
|
|
504
|
+
? "Dry run: available once the config is written."
|
|
505
|
+
: "Dry run against the CURRENTLY SAVED config:",
|
|
506
|
+
...(existing === undefined ? [] : await tryPreview(answers.projectName)),
|
|
507
|
+
"",
|
|
508
|
+
"Nothing has been changed yet.",
|
|
509
|
+
].join("\n"),
|
|
510
|
+
"info",
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
const toCreate = labels.filter((l) => !l.exists).map((l) => l.name);
|
|
514
|
+
const go = await ctx.ui.confirm(
|
|
515
|
+
"Apply this setup?",
|
|
516
|
+
[
|
|
517
|
+
toCreate.length > 0
|
|
518
|
+
? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
|
|
519
|
+
: "Creates no labels.",
|
|
520
|
+
`Writes ${path}, creates the state database and clears the pause flag.`,
|
|
521
|
+
scopes.missing.length > 0
|
|
522
|
+
? `WARNING: the gh token is missing ${scopes.missing.join(", ")} — the daemon will fail to label issues.`
|
|
523
|
+
: "",
|
|
524
|
+
answers.writeOrchestratorBrief
|
|
525
|
+
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
526
|
+
: "",
|
|
527
|
+
"Issues are only claimed once the daemon runs.",
|
|
528
|
+
]
|
|
529
|
+
.filter((s) => s.length > 0)
|
|
530
|
+
.join(" "),
|
|
531
|
+
);
|
|
532
|
+
if (!go) {
|
|
533
|
+
ctx.ui.notify("Left untouched — no labels created, no config written, nothing armed.", "info");
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Labels first: a config pointing at labels that do not exist is a daemon
|
|
538
|
+
// that starts and then fails on its first claim.
|
|
539
|
+
const created = await createMissingLabels(answers.trackerRepo, labels);
|
|
540
|
+
saveConfig(buildConfig(answers, existing));
|
|
541
|
+
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers) : undefined;
|
|
542
|
+
armConductor();
|
|
543
|
+
|
|
544
|
+
ctx.ui.notify(
|
|
545
|
+
[
|
|
546
|
+
created.length > 0 ? `Created label(s): ${created.join(", ")}` : "No labels needed creating.",
|
|
547
|
+
`Wrote ${path} and armed the conductor.`,
|
|
548
|
+
briefPath === undefined
|
|
549
|
+
? "No orchestrator brief written — the conductor stops at green PRs; merges and releases stay human."
|
|
550
|
+
: `Wrote ${briefPath} — edit its "Releases" and "Reporting" sections; nothing here reads them back.`,
|
|
551
|
+
"",
|
|
552
|
+
"Dry run against the config just written:",
|
|
553
|
+
...(await tryPreview(answers.projectName)),
|
|
554
|
+
"",
|
|
555
|
+
"Start the loop with `omp-conductor daemon`, or `omp-conductor daemon --once` for a single tick.",
|
|
556
|
+
].join("\n"),
|
|
557
|
+
"info",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export default function conductorPlugin(pi: PluginApi): void {
|
|
562
|
+
pi.registerCommand("conductor", {
|
|
563
|
+
description: "Dispatch ready issues to omp coding sessions",
|
|
564
|
+
getArgumentCompletions: (prefix) => SUBCOMMANDS.filter((s) => s.value.startsWith(prefix.trim())),
|
|
565
|
+
|
|
566
|
+
handler: async (args, ctx) => {
|
|
567
|
+
// findProject() throws when the config holds several projects and none is
|
|
568
|
+
// named, so the project name rides along as an optional second word.
|
|
569
|
+
const [sub, project] = args.trim().split(/\s+/);
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
switch (sub) {
|
|
573
|
+
case "setup":
|
|
574
|
+
await setup(ctx, project);
|
|
575
|
+
break;
|
|
576
|
+
|
|
577
|
+
case "status":
|
|
578
|
+
ctx.ui.notify(formatStatus(statusSnapshot(project)), "info");
|
|
579
|
+
break;
|
|
580
|
+
|
|
581
|
+
case "pause":
|
|
582
|
+
setPaused(true);
|
|
583
|
+
ctx.ui.notify("Conductor paused — no new work will be claimed.", "info");
|
|
584
|
+
break;
|
|
585
|
+
|
|
586
|
+
case "resume":
|
|
587
|
+
setPaused(false);
|
|
588
|
+
ctx.ui.notify("Conductor resumed — work will be claimed on the next tick.", "info");
|
|
589
|
+
break;
|
|
590
|
+
|
|
591
|
+
default:
|
|
592
|
+
ctx.ui.notify(
|
|
593
|
+
`${sub ? `Unknown subcommand "${sub}".` : "Pick a subcommand."}\n\n${USAGE}` +
|
|
594
|
+
(isPaused() ? "\n\nThe conductor is currently paused." : ""),
|
|
595
|
+
sub ? "warning" : "info",
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
} catch (err) {
|
|
599
|
+
// Config problems arrive as a single readable message listing every
|
|
600
|
+
// fault, which is more use to the operator than a stack.
|
|
601
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
}
|