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/setup.ts
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The onboarding core behind `/conductor setup`.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is headless and synchronously testable: the plugin owns the
|
|
5
|
+
* dialogs, this module owns the decisions. That split is the point — a wizard
|
|
6
|
+
* whose logic lives inside UI callbacks can only be verified by driving a TUI,
|
|
7
|
+
* so in practice it never is, and the first thing a new user touches is the
|
|
8
|
+
* least tested code in the package.
|
|
9
|
+
*
|
|
10
|
+
* Two functions here mutate something outside the process: `createMissingLabels`
|
|
11
|
+
* and `writeOrchestratorBrief`. Everything else reads, or computes. That is what
|
|
12
|
+
* lets the plugin show a complete plan before asking for consent, and it is a
|
|
13
|
+
* property worth preserving — check it before adding a function.
|
|
14
|
+
*
|
|
15
|
+
* ponytail: `gh` is shelled out to per call rather than shared with the tracker
|
|
16
|
+
* adapter's private `gh()`, which throws on non-zero exit. Setup needs the
|
|
17
|
+
* opposite: an unauthenticated or unreachable `gh` is a finding to report in the
|
|
18
|
+
* plan, not an exception thrown mid-prompt. The ceiling is one duplicated 15-line
|
|
19
|
+
* spawn helper; the upgrade path, if a third caller appears, is to lift both into
|
|
20
|
+
* `src/gh.ts` exposing `gh()` (throwing) over `ghTry()` (classifying).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { configPath, resolveCaps, stateDir } from "./config.ts";
|
|
27
|
+
import {
|
|
28
|
+
CONFIG_VERSION,
|
|
29
|
+
DEFAULT_CAPS,
|
|
30
|
+
type Caps,
|
|
31
|
+
type ConductorConfig,
|
|
32
|
+
type ProjectConfig,
|
|
33
|
+
type ReportScope,
|
|
34
|
+
type RepoTarget,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
import { renderBrief } from "./worker.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every decision the wizard needs, in one plain object. Collected by the UI,
|
|
40
|
+
* consumed by `buildConfig`, so the prompt order can change without touching
|
|
41
|
+
* the shape of the config that comes out.
|
|
42
|
+
*/
|
|
43
|
+
export interface SetupAnswers {
|
|
44
|
+
projectName: string;
|
|
45
|
+
/** Tracker repo as `owner/repo` — the only spelling `gh` takes without a host. */
|
|
46
|
+
trackerRepo: string;
|
|
47
|
+
queueLabel: string;
|
|
48
|
+
stateLabels: { inProgress: string; blocked: string; failed: string };
|
|
49
|
+
routingLabelPrefix: string;
|
|
50
|
+
targetRepos: { name: string; cloneUrl: string; defaultBranch: string; gates: { cmd: string; cwd: string }[] }[];
|
|
51
|
+
caps: Partial<Caps>;
|
|
52
|
+
/**
|
|
53
|
+
* Model pattern for worker sessions, in omp's model/role syntax. Absent means
|
|
54
|
+
* the harness default, which is the answer for anyone who has not deliberately
|
|
55
|
+
* pinned one.
|
|
56
|
+
*/
|
|
57
|
+
workerModel?: string;
|
|
58
|
+
telegramChatId?: string;
|
|
59
|
+
fallbackToIssueComment: boolean;
|
|
60
|
+
/** How loud the supervising orchestrator session should be. */
|
|
61
|
+
reportScope: ReportScope;
|
|
62
|
+
/**
|
|
63
|
+
* Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
|
|
64
|
+
* part of the config — the brief is the operator's file, and the conductor
|
|
65
|
+
* never reads it back — but it is a decision the wizard has to carry from the
|
|
66
|
+
* prompt that asked it to the step that acts on it.
|
|
67
|
+
*/
|
|
68
|
+
writeOrchestratorBrief: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What `gh auth status` says the active token may do. */
|
|
72
|
+
export interface ScopeCheck {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
login?: string;
|
|
75
|
+
scopes: string[];
|
|
76
|
+
missing: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One tracker label the daemon depends on, and whether it is already there. */
|
|
80
|
+
export interface LabelPlan {
|
|
81
|
+
name: string;
|
|
82
|
+
/** Six hex digits, no leading `#` — what `gh label create --color` wants. */
|
|
83
|
+
colour: string;
|
|
84
|
+
description: string;
|
|
85
|
+
exists: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Defaults the wizard pre-fills. Exported so the prompts and this module
|
|
89
|
+
* cannot drift apart: one spelling of "ready-for-agent" in the package. */
|
|
90
|
+
export const SETUP_DEFAULTS = {
|
|
91
|
+
queueLabel: "ready-for-agent",
|
|
92
|
+
stateLabels: { inProgress: "agent:in-progress", blocked: "agent:blocked", failed: "agent:failed" },
|
|
93
|
+
routingLabelPrefix: "repo:",
|
|
94
|
+
defaultBranch: "main",
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The two report scopes as the operator meets them, described once. The wizard
|
|
99
|
+
* shows these labels, the plan summary quotes the description, and the rendered
|
|
100
|
+
* brief spells the same two options out — so "material" cannot come to mean one
|
|
101
|
+
* thing in the dialog and another in the session that has to honour it.
|
|
102
|
+
*/
|
|
103
|
+
export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string; description: string }[] = [
|
|
104
|
+
{
|
|
105
|
+
scope: "material",
|
|
106
|
+
label: "Material events",
|
|
107
|
+
description: "escalations, plus green PRs, second failures, and anything that stops the fleet",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
scope: "escalations",
|
|
111
|
+
label: "Escalations only",
|
|
112
|
+
description: "escalations when they happen, plus one daily digest — silent otherwise",
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** The operator's own brief, rendered into the project's workspace root. */
|
|
117
|
+
export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
|
|
118
|
+
|
|
119
|
+
/** Shipped in `files[]`, so this resolves in an installed package too. */
|
|
120
|
+
const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md");
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `repo` writes labels and closes issues; `project` moves cards on the board.
|
|
124
|
+
* Both are load-bearing for an unattended loop, so a missing one is reported
|
|
125
|
+
* rather than discovered at 03:00 as a run that claimed work it cannot label.
|
|
126
|
+
*/
|
|
127
|
+
const REQUIRED_SCOPES = ["repo", "project"] as const;
|
|
128
|
+
|
|
129
|
+
/** GitHub's own palette, so the tracker reads at a glance: green means queued,
|
|
130
|
+
* blue means moving, amber means waiting on you, red means it gave up. */
|
|
131
|
+
const LABEL_COLOURS = {
|
|
132
|
+
queue: "0e8a16",
|
|
133
|
+
inProgress: "1d76db",
|
|
134
|
+
blocked: "fbca04",
|
|
135
|
+
failed: "b60205",
|
|
136
|
+
} as const;
|
|
137
|
+
|
|
138
|
+
interface GhResult {
|
|
139
|
+
code: number;
|
|
140
|
+
stdout: string;
|
|
141
|
+
stderr: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Runs `gh` and classifies instead of throwing. stdin is a closed stream so a
|
|
146
|
+
* command that would read it sees EOF rather than hanging on an open pipe.
|
|
147
|
+
*/
|
|
148
|
+
async function gh(argv: string[]): Promise<GhResult> {
|
|
149
|
+
try {
|
|
150
|
+
const proc = Bun.spawn(["gh", ...argv], {
|
|
151
|
+
stdin: new Blob([""]),
|
|
152
|
+
stdout: "pipe",
|
|
153
|
+
stderr: "pipe",
|
|
154
|
+
});
|
|
155
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
156
|
+
new Response(proc.stdout).text(),
|
|
157
|
+
new Response(proc.stderr).text(),
|
|
158
|
+
proc.exited,
|
|
159
|
+
]);
|
|
160
|
+
return { code, stdout, stderr };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// `gh` missing from PATH lands here. A diagnosis, not a crash: the wizard
|
|
163
|
+
// is mid-conversation with a human and should say what is wrong.
|
|
164
|
+
return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Bounds `gh` stderr to the two lines that carry the diagnosis. A durable
|
|
170
|
+
* contract rather than a rename: every error this module raises passes through
|
|
171
|
+
* here, so an unexpectedly chatty `gh` build can never dump an arbitrary
|
|
172
|
+
* subprocess transcript into a message the caller shows or logs.
|
|
173
|
+
*/
|
|
174
|
+
function briefly(stderr: string): string {
|
|
175
|
+
const lines = stderr
|
|
176
|
+
.split("\n")
|
|
177
|
+
.map((l) => l.trim())
|
|
178
|
+
.filter((l) => l.length > 0);
|
|
179
|
+
return lines.slice(0, 2).join(" / ") || "(no output)";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Reads the active `gh` token's scopes without ever handling the token itself.
|
|
184
|
+
*
|
|
185
|
+
* `gh auth status` masks the token in its own output and this function returns
|
|
186
|
+
* only the parsed scope names and login, so no credential can escape through
|
|
187
|
+
* the return value, a log line, or a thrown message — hence the deliberate
|
|
188
|
+
* absence of any `throw` here.
|
|
189
|
+
*
|
|
190
|
+
* ponytail: the first `Token scopes:` line wins. A `gh` configured for both
|
|
191
|
+
* github.com and a GHES host prints one block each, and this credits the first.
|
|
192
|
+
* Upgrade path: pass `--hostname` once the tracker learns about non-default hosts.
|
|
193
|
+
*/
|
|
194
|
+
export async function checkTokenScopes(): Promise<ScopeCheck> {
|
|
195
|
+
// Older gh writes the status block to stderr, newer to stdout; read both
|
|
196
|
+
// rather than guessing at the installed version.
|
|
197
|
+
const r = await gh(["auth", "status"]);
|
|
198
|
+
const text = `${r.stdout}\n${r.stderr}`;
|
|
199
|
+
|
|
200
|
+
const scopeLine = /Token scopes:\s*(.*)/.exec(text);
|
|
201
|
+
const scopes = (scopeLine?.[1] ?? "")
|
|
202
|
+
.split(",")
|
|
203
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
204
|
+
.filter((s) => s.length > 0 && s !== "none");
|
|
205
|
+
|
|
206
|
+
// "account <login>" is gh >= 2.40, "as <login>" everything before it.
|
|
207
|
+
const loginMatch = /Logged in to \S+ (?:account|as) (\S+)/.exec(text);
|
|
208
|
+
const login = loginMatch?.[1];
|
|
209
|
+
|
|
210
|
+
const missing = REQUIRED_SCOPES.filter((s) => !scopes.includes(s));
|
|
211
|
+
const check: ScopeCheck = { ok: r.code === 0 && missing.length === 0, scopes, missing };
|
|
212
|
+
if (login !== undefined) check.login = login;
|
|
213
|
+
return check;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The four labels the loop reads and writes, and which already exist. Read-only:
|
|
218
|
+
* this is what the operator is shown before being asked to consent to creation.
|
|
219
|
+
*/
|
|
220
|
+
export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
|
|
221
|
+
const wanted: Omit<LabelPlan, "exists">[] = [
|
|
222
|
+
{
|
|
223
|
+
name: a.queueLabel,
|
|
224
|
+
colour: LABEL_COLOURS.queue,
|
|
225
|
+
description: "Signed off by a human as ready for the conductor to claim",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: a.stateLabels.inProgress,
|
|
229
|
+
colour: LABEL_COLOURS.inProgress,
|
|
230
|
+
description: "A conductor worker is running on this issue",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: a.stateLabels.blocked,
|
|
234
|
+
colour: LABEL_COLOURS.blocked,
|
|
235
|
+
description: "Parked by the conductor — waiting on a human answer",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: a.stateLabels.failed,
|
|
239
|
+
colour: LABEL_COLOURS.failed,
|
|
240
|
+
description: "The conductor gave up on this issue after its retry budget",
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
const existing = await listLabelNames(trackerRepo);
|
|
245
|
+
|
|
246
|
+
// GitHub label names are unique case-insensitively, so an operator who answers
|
|
247
|
+
// "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
|
|
248
|
+
// creation is pending that would then fail.
|
|
249
|
+
const seen = new Set<string>();
|
|
250
|
+
const plan: LabelPlan[] = [];
|
|
251
|
+
for (const w of wanted) {
|
|
252
|
+
const key = w.name.toLowerCase();
|
|
253
|
+
if (seen.has(key)) continue;
|
|
254
|
+
seen.add(key);
|
|
255
|
+
plan.push({ ...w, exists: existing.has(key) });
|
|
256
|
+
}
|
|
257
|
+
return plan;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Lower-cased label names currently on the repo. Throws rather than guessing:
|
|
261
|
+
* a repo whose labels cannot be listed cannot be serviced either. */
|
|
262
|
+
async function listLabelNames(trackerRepo: string): Promise<Set<string>> {
|
|
263
|
+
const r = await gh(["label", "list", "--repo", trackerRepo, "--limit", "200", "--json", "name"]);
|
|
264
|
+
if (r.code !== 0) {
|
|
265
|
+
throw new Error(`Cannot list labels in ${trackerRepo}: ${briefly(r.stderr)}`);
|
|
266
|
+
}
|
|
267
|
+
let parsed: unknown;
|
|
268
|
+
try {
|
|
269
|
+
parsed = JSON.parse(r.stdout || "[]") as unknown;
|
|
270
|
+
} catch {
|
|
271
|
+
throw new Error(`Cannot read the label list for ${trackerRepo}: gh returned output that is not JSON.`);
|
|
272
|
+
}
|
|
273
|
+
const names = new Set<string>();
|
|
274
|
+
if (Array.isArray(parsed)) {
|
|
275
|
+
for (const entry of parsed) {
|
|
276
|
+
const name = (entry as { name?: unknown } | null)?.name;
|
|
277
|
+
if (typeof name === "string" && name.length > 0) names.add(name.toLowerCase());
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return names;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The single mutating step in this module, and the plugin calls it only after
|
|
285
|
+
* the operator has confirmed the printed plan.
|
|
286
|
+
*
|
|
287
|
+
* Returns the names actually created — a label that already existed is not in
|
|
288
|
+
* the list, because reporting it as created would be a lie the operator would
|
|
289
|
+
* have to verify by hand. A concurrent creation (or a label list that was
|
|
290
|
+
* stale by the time we got here) is success, not an error: the end state the
|
|
291
|
+
* caller asked for holds either way.
|
|
292
|
+
*/
|
|
293
|
+
export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]): Promise<string[]> {
|
|
294
|
+
const created: string[] = [];
|
|
295
|
+
for (const label of plan) {
|
|
296
|
+
if (label.exists) continue;
|
|
297
|
+
const r = await gh([
|
|
298
|
+
"label",
|
|
299
|
+
"create",
|
|
300
|
+
label.name,
|
|
301
|
+
"--repo",
|
|
302
|
+
trackerRepo,
|
|
303
|
+
"--color",
|
|
304
|
+
label.colour,
|
|
305
|
+
"--description",
|
|
306
|
+
label.description,
|
|
307
|
+
]);
|
|
308
|
+
if (r.code === 0) {
|
|
309
|
+
created.push(label.name);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (/already exists/i.test(r.stderr)) continue;
|
|
313
|
+
throw new Error(`Could not create label "${label.name}" in ${trackerRepo}: ${briefly(r.stderr)}`);
|
|
314
|
+
}
|
|
315
|
+
return created;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The single `ProjectConfig` the answers describe.
|
|
320
|
+
*
|
|
321
|
+
* Split out of `buildConfig` so the plan summary can show the exact project
|
|
322
|
+
* that would be written — including the derived worktree and mirror paths —
|
|
323
|
+
* without assembling a whole config and indexing back into its array.
|
|
324
|
+
*/
|
|
325
|
+
function buildProject(a: SetupAnswers): ProjectConfig {
|
|
326
|
+
const dir = stateDir();
|
|
327
|
+
|
|
328
|
+
const repos: Record<string, RepoTarget> = {};
|
|
329
|
+
for (const r of a.targetRepos) {
|
|
330
|
+
repos[r.name] = {
|
|
331
|
+
name: r.name,
|
|
332
|
+
cloneUrl: r.cloneUrl,
|
|
333
|
+
defaultBranch: r.defaultBranch,
|
|
334
|
+
gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const escalation: ProjectConfig["escalation"] = { fallbackToIssueComment: a.fallbackToIssueComment };
|
|
339
|
+
if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
|
|
340
|
+
escalation.telegramChatId = a.telegramChatId.trim();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const caps: Partial<Caps> = {};
|
|
344
|
+
// Drops `undefined` members so an unanswered cap is absent from the JSON
|
|
345
|
+
// rather than present-and-null, which the validator would have to reject.
|
|
346
|
+
for (const [key, value] of Object.entries(a.caps) as [keyof Caps, number | undefined][]) {
|
|
347
|
+
if (typeof value === "number") caps[key] = value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
name: a.projectName,
|
|
352
|
+
tracker: { kind: "github", repo: a.trackerRepo },
|
|
353
|
+
queueLabel: a.queueLabel,
|
|
354
|
+
stateLabels: { ...a.stateLabels },
|
|
355
|
+
routing: { labelPrefix: a.routingLabelPrefix, repos },
|
|
356
|
+
caps,
|
|
357
|
+
...(a.workerModel !== undefined && a.workerModel.trim().length > 0
|
|
358
|
+
? { workerModel: a.workerModel.trim() }
|
|
359
|
+
: {}),
|
|
360
|
+
escalation,
|
|
361
|
+
reporting: { scope: a.reportScope },
|
|
362
|
+
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
363
|
+
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
364
|
+
workspaceRoot: join(dir, "worktrees"),
|
|
365
|
+
mirrorRoot: join(dir, "mirrors"),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Turns answers into a config the real validator accepts.
|
|
371
|
+
*
|
|
372
|
+
* `existing` is how re-running setup for one project stays safe: a same-named
|
|
373
|
+
* project is replaced in place (keeping its position), everything else is
|
|
374
|
+
* carried through untouched. Rebuilding from answers alone would silently
|
|
375
|
+
* delete a neighbour's entire configuration.
|
|
376
|
+
*/
|
|
377
|
+
export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): ConductorConfig {
|
|
378
|
+
const project = buildProject(a);
|
|
379
|
+
const previous = existing?.projects ?? [];
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
version: CONFIG_VERSION,
|
|
383
|
+
// Answered caps land on the project, not here: on a re-run the global block
|
|
384
|
+
// is also the baseline every other project inherits, and one project's
|
|
385
|
+
// answers must never quietly re-budget its neighbour. An existing global
|
|
386
|
+
// block is kept as-is so a hand-tuned default survives setup.
|
|
387
|
+
defaults: { ...DEFAULT_CAPS, ...(existing?.defaults ?? {}) },
|
|
388
|
+
projects: previous.some((p) => p.name === project.name)
|
|
389
|
+
? previous.map((p) => (p.name === project.name ? project : p))
|
|
390
|
+
: [...previous, project],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Where the operator's own brief lands: beside the worktrees, under the state
|
|
396
|
+
* directory, so it is on the same disk the fleet already owns and survives a
|
|
397
|
+
* reinstall of the package. Derived from the answers rather than fixed, so a
|
|
398
|
+
* project that ever gains a chosen workspace root keeps its brief with it.
|
|
399
|
+
*/
|
|
400
|
+
export function orchestratorBriefPath(a: SetupAnswers): string {
|
|
401
|
+
return join(buildProject(a).workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The shipped template with this project's real values in it.
|
|
406
|
+
*
|
|
407
|
+
* Only the coordinates and the chosen scope are substituted: the policy text is
|
|
408
|
+
* left exactly as shipped, because from here on the file is the operator's to
|
|
409
|
+
* edit and nothing in this package reads it back.
|
|
410
|
+
*/
|
|
411
|
+
export function renderOrchestratorBrief(a: SetupAnswers): string {
|
|
412
|
+
return renderBrief(readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8"), {
|
|
413
|
+
PROJECT: a.projectName,
|
|
414
|
+
TRACKER_REPO: a.trackerRepo,
|
|
415
|
+
QUEUE_LABEL: a.queueLabel,
|
|
416
|
+
REPORT_SCOPE: a.reportScope,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Writes the rendered brief and returns where it went.
|
|
422
|
+
*
|
|
423
|
+
* Unconditional by design: the "do not clobber my edits" decision belongs to the
|
|
424
|
+
* operator, is asked in the wizard, and arrives here as
|
|
425
|
+
* `answers.writeOrchestratorBrief`. A second existence check in here would make
|
|
426
|
+
* that dialog's answer un-actionable — an operator who says "yes, overwrite it"
|
|
427
|
+
* must get an overwrite.
|
|
428
|
+
*/
|
|
429
|
+
export function writeOrchestratorBrief(a: SetupAnswers): string {
|
|
430
|
+
const path = orchestratorBriefPath(a);
|
|
431
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
432
|
+
writeFileSync(path, renderOrchestratorBrief(a));
|
|
433
|
+
return path;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* What can be told about an omp-telegram install without opening a socket.
|
|
438
|
+
*
|
|
439
|
+
* A named contract rather than an inferred one: consumers depend on the shape
|
|
440
|
+
* of the answer, not on the identity of the function that produced it.
|
|
441
|
+
*/
|
|
442
|
+
export interface TelegramPresence {
|
|
443
|
+
/** An omp-telegram state directory exists at all. */
|
|
444
|
+
available: boolean;
|
|
445
|
+
stateDir: string;
|
|
446
|
+
/** A bot token line is present. Never, under any circumstance, its value. */
|
|
447
|
+
hasToken: boolean;
|
|
448
|
+
/** Set only when exactly one chat is paired, so the wizard cannot offer to
|
|
449
|
+
* page a chat that belongs to somebody else. */
|
|
450
|
+
pairedOwnerId?: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Whether omp-telegram is installed beside us, and who it is paired with.
|
|
455
|
+
*
|
|
456
|
+
* Only ever reports the *presence* of a token, never its value — a wizard that
|
|
457
|
+
* echoes a bot token into a plan summary has leaked it to the scrollback, the
|
|
458
|
+
* terminal's history and any screen recording of the session.
|
|
459
|
+
*/
|
|
460
|
+
export function detectTelegram(): TelegramPresence {
|
|
461
|
+
const override = process.env["OMP_TELEGRAM_STATE_DIR"]?.trim();
|
|
462
|
+
const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
463
|
+
|
|
464
|
+
const envPath = join(dir, ".env");
|
|
465
|
+
const accessPath = join(dir, "access.json");
|
|
466
|
+
const available = existsSync(envPath) || existsSync(accessPath);
|
|
467
|
+
if (!available) return { available: false, stateDir: dir, hasToken: false };
|
|
468
|
+
|
|
469
|
+
const result: TelegramPresence = {
|
|
470
|
+
available: true,
|
|
471
|
+
stateDir: dir,
|
|
472
|
+
hasToken: hasTelegramToken(envPath),
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const owner = pairedOwner(accessPath);
|
|
476
|
+
if (owner !== undefined) result.pairedOwnerId = owner;
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
|
|
481
|
+
* compared against emptiness and then dropped on the floor. */
|
|
482
|
+
function hasTelegramToken(envPath: string): boolean {
|
|
483
|
+
let raw: string;
|
|
484
|
+
try {
|
|
485
|
+
raw = readFileSync(envPath, "utf8");
|
|
486
|
+
} catch {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
for (const line of raw.split("\n")) {
|
|
490
|
+
const trimmed = line.trim();
|
|
491
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
|
492
|
+
const match = /^(?:export\s+)?TELEGRAM_BOT_TOKEN\s*=\s*(.*)$/.exec(trimmed);
|
|
493
|
+
if (!match) continue;
|
|
494
|
+
let value = (match[1] ?? "").trim();
|
|
495
|
+
const quote = value[0];
|
|
496
|
+
if (value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
497
|
+
value = value.slice(1, -1);
|
|
498
|
+
}
|
|
499
|
+
if (value.length > 0) return true;
|
|
500
|
+
}
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The paired owner id, but only when there is exactly one. Several entries
|
|
506
|
+
* means the wizard cannot know which human owns escalations, and guessing
|
|
507
|
+
* would route a tier-2 page to a stranger.
|
|
508
|
+
*/
|
|
509
|
+
function pairedOwner(accessPath: string): string | undefined {
|
|
510
|
+
let parsed: unknown;
|
|
511
|
+
try {
|
|
512
|
+
parsed = JSON.parse(readFileSync(accessPath, "utf8")) as unknown;
|
|
513
|
+
} catch {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
const allowFrom = (parsed as { allowFrom?: unknown } | null)?.allowFrom;
|
|
517
|
+
if (!Array.isArray(allowFrom) || allowFrom.length !== 1) return undefined;
|
|
518
|
+
const only = allowFrom[0] as unknown;
|
|
519
|
+
if (typeof only === "string" && only.trim().length > 0) return only.trim();
|
|
520
|
+
if (typeof only === "number" && Number.isFinite(only)) return String(only);
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Everything that would change, as plain text, with no side effects at all.
|
|
526
|
+
*
|
|
527
|
+
* This is the consent screen. If it does not name a change, the operator did
|
|
528
|
+
* not agree to it — so every mutating step the plugin can take afterwards is
|
|
529
|
+
* listed here first, including the labels that would be created and the
|
|
530
|
+
* transport a tier-2 page would take.
|
|
531
|
+
*/
|
|
532
|
+
export function summarisePlan(
|
|
533
|
+
a: SetupAnswers,
|
|
534
|
+
scopes: ScopeCheck,
|
|
535
|
+
labels: LabelPlan[],
|
|
536
|
+
tg: TelegramPresence,
|
|
537
|
+
): string {
|
|
538
|
+
const project = buildProject(a);
|
|
539
|
+
// Caps are shown against the shipped baseline: the summary describes what
|
|
540
|
+
// these answers mean on their own, before any hand-edited global block.
|
|
541
|
+
const effective = resolveCaps(project, DEFAULT_CAPS);
|
|
542
|
+
const states = Object.values(a.stateLabels).join(", ");
|
|
543
|
+
|
|
544
|
+
const lines: string[] = [];
|
|
545
|
+
|
|
546
|
+
if (scopes.missing.length > 0) {
|
|
547
|
+
lines.push(
|
|
548
|
+
`!! MISSING TOKEN SCOPE: ${scopes.missing.join(", ")}`,
|
|
549
|
+
scopes.login === undefined
|
|
550
|
+
? " `gh` reported no authenticated account — run `gh auth login` first."
|
|
551
|
+
: ` Signed in as ${scopes.login} with: ${scopes.scopes.join(", ") || "(no scopes reported)"}`,
|
|
552
|
+
" Without `repo` the daemon cannot label or close issues; without `project` it cannot move cards.",
|
|
553
|
+
" Fix with: gh auth refresh -s repo,project",
|
|
554
|
+
"",
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
lines.push(
|
|
559
|
+
`project ${a.projectName}`,
|
|
560
|
+
`config ${configPath()}`,
|
|
561
|
+
`tracker ${a.trackerRepo}`,
|
|
562
|
+
`github user ${scopes.login ?? "(not authenticated)"}`,
|
|
563
|
+
`queue query open issues in ${a.trackerRepo} labelled "${a.queueLabel}", minus anything already`,
|
|
564
|
+
` labelled ${states}, routed by one "${a.routingLabelPrefix}<repo>" label`,
|
|
565
|
+
`worktrees ${project.workspaceRoot}`,
|
|
566
|
+
`mirrors ${project.mirrorRoot}`,
|
|
567
|
+
"",
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const toCreate = labels.filter((l) => !l.exists);
|
|
571
|
+
const present = labels.filter((l) => l.exists);
|
|
572
|
+
lines.push(
|
|
573
|
+
toCreate.length === 0
|
|
574
|
+
? `labels all ${labels.length} already exist in ${a.trackerRepo} — nothing to create`
|
|
575
|
+
: `labels would CREATE ${toCreate.length} in ${a.trackerRepo}:`,
|
|
576
|
+
);
|
|
577
|
+
for (const l of toCreate) lines.push(` + ${l.name} #${l.colour} ${l.description}`);
|
|
578
|
+
if (present.length > 0 && toCreate.length > 0) {
|
|
579
|
+
lines.push(` already present: ${present.map((l) => l.name).join(", ")}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
lines.push("", `routing "${a.routingLabelPrefix}<repo>" on an issue selects one of:`);
|
|
583
|
+
if (a.targetRepos.length === 0) {
|
|
584
|
+
lines.push(" (none — nothing can be routed, setup will refuse this)");
|
|
585
|
+
}
|
|
586
|
+
for (const r of a.targetRepos) {
|
|
587
|
+
lines.push(` ${a.routingLabelPrefix}${r.name} ${r.cloneUrl} (${r.defaultBranch})`);
|
|
588
|
+
for (const g of r.gates) lines.push(` gate: ${g.cmd} [cwd ${g.cwd}]`);
|
|
589
|
+
if (r.gates.length === 0) {
|
|
590
|
+
lines.push(" gate: none — nothing is verified before a push");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
lines.push("", "caps (effective)");
|
|
595
|
+
for (const [key, value] of Object.entries(effective)) {
|
|
596
|
+
const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
|
|
597
|
+
lines.push(` ${key.padEnd(22)}${String(value)}${answered}`);
|
|
598
|
+
}
|
|
599
|
+
if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
|
|
600
|
+
lines.push(` ${"worker model".padEnd(22)}${a.workerModel.trim()} (answered)`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
lines.push("", "escalation");
|
|
604
|
+
if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
|
|
605
|
+
lines.push(
|
|
606
|
+
` tier 2 Telegram chat ${a.telegramChatId.trim()}` +
|
|
607
|
+
(tg.hasToken ? "" : " — WARNING: no bot token found, this will not send"),
|
|
608
|
+
` via omp-telegram at ${tg.stateDir}`,
|
|
609
|
+
);
|
|
610
|
+
} else {
|
|
611
|
+
lines.push(
|
|
612
|
+
" tier 2 issue comment only",
|
|
613
|
+
tg.available && tg.hasToken
|
|
614
|
+
? ` note omp-telegram is installed at ${tg.stateDir} but no chat was chosen`
|
|
615
|
+
: ` note no omp-telegram install found at ${tg.stateDir}`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
lines.push(` fallback ${a.fallbackToIssueComment ? "comment on the issue as well" : "disabled"}`);
|
|
619
|
+
|
|
620
|
+
const chosen = REPORT_SCOPE_CHOICES.find((c) => c.scope === a.reportScope);
|
|
621
|
+
const briefPath = orchestratorBriefPath(a);
|
|
622
|
+
lines.push(
|
|
623
|
+
"",
|
|
624
|
+
"reporting",
|
|
625
|
+
` scope ${a.reportScope} — ${chosen?.description ?? "unknown scope"}`,
|
|
626
|
+
);
|
|
627
|
+
if (a.writeOrchestratorBrief) {
|
|
628
|
+
lines.push(
|
|
629
|
+
existsSync(briefPath)
|
|
630
|
+
? ` brief would OVERWRITE ${briefPath}`
|
|
631
|
+
: ` brief would write ${briefPath}`,
|
|
632
|
+
" yours to edit afterwards — release policy lives there, not in this package",
|
|
633
|
+
);
|
|
634
|
+
} else {
|
|
635
|
+
lines.push(
|
|
636
|
+
existsSync(briefPath)
|
|
637
|
+
? ` brief not written — ${briefPath} is left exactly as it is`
|
|
638
|
+
: ` brief not written — no orchestrator brief at ${briefPath}`,
|
|
639
|
+
" the package still stops at green PRs; releases stay a human action",
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return lines.join("\n");
|
|
644
|
+
}
|