omp-conductor 0.15.6 → 0.15.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 +99 -27
- package/package.json +1 -1
- package/schema/config.schema.json +39 -0
- package/src/briefs/orchestrator.md +34 -10
- package/src/briefs/policy.md +8 -3
- package/src/cli.ts +61 -1
- package/src/config-schema.ts +20 -0
- package/src/config.ts +43 -0
- package/src/fleet.ts +14 -35
- package/src/lifecycle.ts +164 -21
- package/src/orchestrator-tick.ts +96 -7
- package/src/reports.ts +47 -0
- package/src/setup-discover.ts +425 -0
- package/src/setup-wizard.ts +106 -10
- package/src/setup.ts +25 -1
- package/src/store.ts +5 -1
- package/src/types.ts +34 -0
- package/src/verbs/actions.ts +407 -4
- package/src/verbs/protocol.ts +2 -2
- package/src/verbs/server.ts +141 -27
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/** Deterministic, fail-open facts used to pre-fill the setup interview. */
|
|
2
|
+
|
|
3
|
+
export const DISCOVERY_TIMEOUT_MS = 5_000;
|
|
4
|
+
|
|
5
|
+
export interface DiscoveryRunResult {
|
|
6
|
+
exitCode: number;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
timedOut?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DiscoveryRunner = (
|
|
13
|
+
argv: readonly string[],
|
|
14
|
+
opts?: { cwd?: string },
|
|
15
|
+
) => Promise<DiscoveryRunResult>;
|
|
16
|
+
|
|
17
|
+
export interface DiscoveredRepoFacts {
|
|
18
|
+
defaultBranch?: string;
|
|
19
|
+
labels?: string[];
|
|
20
|
+
environments?: string[];
|
|
21
|
+
requiredChecks?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DiscoveredFacts extends DiscoveredRepoFacts {
|
|
25
|
+
trackerRepo?: string;
|
|
26
|
+
routingKey?: string;
|
|
27
|
+
cloneUrl?: string;
|
|
28
|
+
artefacts?: string[];
|
|
29
|
+
roadmaps?: string[];
|
|
30
|
+
evidence?: string[];
|
|
31
|
+
skipped?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DiscoveryTarget {
|
|
35
|
+
cwd: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function jsonObject(raw: string): Record<string, unknown> | undefined {
|
|
39
|
+
try {
|
|
40
|
+
const value: unknown = JSON.parse(raw);
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
42
|
+
? (value as Record<string, unknown>)
|
|
43
|
+
: undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse `gh repo view --json defaultBranchRef`. */
|
|
50
|
+
export function defaultBranchFrom(raw: string): string | undefined {
|
|
51
|
+
const ref = jsonObject(raw)?.defaultBranchRef;
|
|
52
|
+
if (typeof ref !== "object" || ref === null || Array.isArray(ref)) return undefined;
|
|
53
|
+
const name = (ref as Record<string, unknown>).name;
|
|
54
|
+
return typeof name === "string" && name.trim() !== "" ? name.trim() : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse `gh label list --json name`. */
|
|
58
|
+
export function labelsFrom(raw: string): string[] | undefined {
|
|
59
|
+
try {
|
|
60
|
+
const value: unknown = JSON.parse(raw);
|
|
61
|
+
if (!Array.isArray(value)) return undefined;
|
|
62
|
+
const names: string[] = [];
|
|
63
|
+
for (const row of value) {
|
|
64
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) return undefined;
|
|
65
|
+
const name = (row as Record<string, unknown>).name;
|
|
66
|
+
if (typeof name !== "string" || name.trim() === "") return undefined;
|
|
67
|
+
names.push(name.trim());
|
|
68
|
+
}
|
|
69
|
+
return names;
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parse GitHub's environments REST response. */
|
|
76
|
+
export function environmentsFrom(raw: string): string[] | undefined {
|
|
77
|
+
const rows = jsonObject(raw)?.environments;
|
|
78
|
+
if (!Array.isArray(rows)) return undefined;
|
|
79
|
+
const names: string[] = [];
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) return undefined;
|
|
82
|
+
const name = (row as Record<string, unknown>).name;
|
|
83
|
+
if (typeof name !== "string" || name.trim() === "") return undefined;
|
|
84
|
+
names.push(name.trim());
|
|
85
|
+
}
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Parse branch protection, or the branch's latest check-runs fallback. */
|
|
90
|
+
export function requiredChecksFrom(raw: string): string[] | undefined {
|
|
91
|
+
const parsed = jsonObject(raw);
|
|
92
|
+
if (parsed === undefined) return undefined;
|
|
93
|
+
const names: string[] = [];
|
|
94
|
+
const protection = parsed.required_status_checks;
|
|
95
|
+
if (protection === null) return [];
|
|
96
|
+
if (typeof protection === "object" && protection !== null && !Array.isArray(protection)) {
|
|
97
|
+
const status = protection as Record<string, unknown>;
|
|
98
|
+
if (!Array.isArray(status.contexts) && !Array.isArray(status.checks)) return undefined;
|
|
99
|
+
if (Array.isArray(status.contexts)) {
|
|
100
|
+
for (const context of status.contexts) {
|
|
101
|
+
if (typeof context !== "string" || context.trim() === "") return undefined;
|
|
102
|
+
names.push(context.trim());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(status.checks)) {
|
|
106
|
+
for (const check of status.checks) {
|
|
107
|
+
if (typeof check !== "object" || check === null || Array.isArray(check)) return undefined;
|
|
108
|
+
const context = (check as Record<string, unknown>).context;
|
|
109
|
+
if (typeof context !== "string" || context.trim() === "") return undefined;
|
|
110
|
+
names.push(context.trim());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...new Set(names)];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const runs = parsed.check_runs;
|
|
117
|
+
if (!Array.isArray(runs)) return undefined;
|
|
118
|
+
for (const run of runs) {
|
|
119
|
+
if (typeof run !== "object" || run === null || Array.isArray(run)) return undefined;
|
|
120
|
+
const name = (run as Record<string, unknown>).name;
|
|
121
|
+
if (typeof name !== "string" || name.trim() === "") return undefined;
|
|
122
|
+
names.push(name.trim());
|
|
123
|
+
}
|
|
124
|
+
return [...new Set(names)];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Parse a package manifest into the npm artefact it publishes. */
|
|
128
|
+
export function artefactsFromPackage(raw: string): string[] | undefined {
|
|
129
|
+
const manifest = jsonObject(raw);
|
|
130
|
+
const name = manifest?.name;
|
|
131
|
+
if (manifest?.private === true) return undefined;
|
|
132
|
+
return typeof name === "string" && name.trim() !== "" ? [name.trim()] : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse a recursive Git tree into repository package-manifest paths. */
|
|
136
|
+
export function packagePathsFrom(raw: string): string[] | undefined {
|
|
137
|
+
// A truncated recursive tree is a partial view of a large repository: a
|
|
138
|
+
// single visible manifest cannot be trusted as "the" artefact, so the whole
|
|
139
|
+
// field is unavailable rather than guessed from the slice GitHub returned.
|
|
140
|
+
if (jsonObject(raw)?.truncated === true) return undefined;
|
|
141
|
+
const tree = jsonObject(raw)?.tree;
|
|
142
|
+
if (!Array.isArray(tree)) return undefined;
|
|
143
|
+
const paths: string[] = [];
|
|
144
|
+
for (const row of tree) {
|
|
145
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) return undefined;
|
|
146
|
+
const path = (row as Record<string, unknown>).path;
|
|
147
|
+
if (typeof path !== "string") return undefined;
|
|
148
|
+
if ((path === "package.json" || path.endsWith("/package.json")) && !path.includes("node_modules/")) {
|
|
149
|
+
paths.push(path);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return paths;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Parse open milestones into operator-facing roadmap choices. */
|
|
156
|
+
export function milestonesFrom(raw: string): string[] | undefined {
|
|
157
|
+
try {
|
|
158
|
+
const value: unknown = JSON.parse(raw);
|
|
159
|
+
if (!Array.isArray(value)) return undefined;
|
|
160
|
+
const choices: string[] = [];
|
|
161
|
+
for (const row of value) {
|
|
162
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) return undefined;
|
|
163
|
+
const milestone = row as Record<string, unknown>;
|
|
164
|
+
if (typeof milestone.title !== "string" || milestone.title.trim() === "") return undefined;
|
|
165
|
+
choices.push(
|
|
166
|
+
typeof milestone.html_url === "string" && milestone.html_url.trim() !== ""
|
|
167
|
+
? `${milestone.title.trim()} — ${milestone.html_url.trim()}`
|
|
168
|
+
: milestone.title.trim(),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return choices;
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Parse `gh project list --format json` into operator-facing roadmap choices. */
|
|
178
|
+
export function projectsFrom(raw: string): string[] | undefined {
|
|
179
|
+
const projects = jsonObject(raw)?.projects;
|
|
180
|
+
if (!Array.isArray(projects)) return undefined;
|
|
181
|
+
const choices: string[] = [];
|
|
182
|
+
for (const row of projects) {
|
|
183
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) return undefined;
|
|
184
|
+
const project = row as Record<string, unknown>;
|
|
185
|
+
if (project.closed === true) continue;
|
|
186
|
+
if (typeof project.title !== "string" || project.title.trim() === "") return undefined;
|
|
187
|
+
choices.push(
|
|
188
|
+
typeof project.url === "string" && project.url.trim() !== ""
|
|
189
|
+
? `${project.title.trim()} — ${project.url.trim()}`
|
|
190
|
+
: project.title.trim(),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return choices;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Parse a Git remote URL into the `owner/repo` spelling setup accepts. */
|
|
197
|
+
export function trackerRepoFrom(raw: string): string | undefined {
|
|
198
|
+
const remote = raw.trim().replace(/\/$/, "").replace(/\.git$/, "");
|
|
199
|
+
if (remote === "") return undefined;
|
|
200
|
+
let path: string;
|
|
201
|
+
try {
|
|
202
|
+
path = new URL(remote).pathname;
|
|
203
|
+
} catch {
|
|
204
|
+
const colon = remote.indexOf(":");
|
|
205
|
+
if (colon === -1) return undefined;
|
|
206
|
+
path = remote.slice(colon + 1);
|
|
207
|
+
}
|
|
208
|
+
const parts = path.split("/").filter(Boolean);
|
|
209
|
+
if (parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9._-]+$/.test(part))) return undefined;
|
|
210
|
+
return `${parts[0]}/${parts[1]}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The routing key of a single repository is its clone URL's repository name. */
|
|
214
|
+
export function routingKeyFromCloneUrl(raw: string): string | undefined {
|
|
215
|
+
return trackerRepoFrom(raw)?.split("/")[1];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A bounded subprocess runner. Missing executables and every other fault are data. */
|
|
219
|
+
export const runDiscovery: DiscoveryRunner = async (argv, opts = {}) => {
|
|
220
|
+
try {
|
|
221
|
+
const proc = Bun.spawn([...argv], {
|
|
222
|
+
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
|
223
|
+
stdin: "ignore",
|
|
224
|
+
stdout: "pipe",
|
|
225
|
+
stderr: "pipe",
|
|
226
|
+
});
|
|
227
|
+
let timedOut = false;
|
|
228
|
+
const timer = setTimeout(() => {
|
|
229
|
+
timedOut = true;
|
|
230
|
+
try {
|
|
231
|
+
proc.kill("SIGKILL");
|
|
232
|
+
} catch {
|
|
233
|
+
// The child won the race and already exited.
|
|
234
|
+
}
|
|
235
|
+
}, DISCOVERY_TIMEOUT_MS);
|
|
236
|
+
try {
|
|
237
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
238
|
+
proc.exited,
|
|
239
|
+
new Response(proc.stdout).text(),
|
|
240
|
+
new Response(proc.stderr).text(),
|
|
241
|
+
]);
|
|
242
|
+
return { exitCode, stdout, stderr, ...(timedOut ? { timedOut: true } : {}) };
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return { exitCode: -1, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
async function attempted(
|
|
252
|
+
run: DiscoveryRunner,
|
|
253
|
+
argv: readonly string[],
|
|
254
|
+
opts?: { cwd?: string },
|
|
255
|
+
): Promise<DiscoveryRunResult> {
|
|
256
|
+
try {
|
|
257
|
+
return await run(argv, opts);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return { exitCode: -1, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function failure(result: DiscoveryRunResult): string {
|
|
264
|
+
if (result.timedOut === true) return `timed out after ${DISCOVERY_TIMEOUT_MS / 1_000}s`;
|
|
265
|
+
const lines = result.stderr.trim().split("\n").filter(Boolean);
|
|
266
|
+
return lines.slice(-2).join(" ") || `command exited ${result.exitCode}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parsed<T>(
|
|
270
|
+
label: string,
|
|
271
|
+
result: DiscoveryRunResult,
|
|
272
|
+
parse: (raw: string) => T | undefined,
|
|
273
|
+
skipped: string[],
|
|
274
|
+
): T | undefined {
|
|
275
|
+
if (result.exitCode !== 0) {
|
|
276
|
+
skipped.push(`${label}: ${failure(result)}`);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const value = parse(result.stdout);
|
|
280
|
+
if (value === undefined) skipped.push(`${label}: gh returned a malformed reply`);
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Detailed facts and diagnostics consumed by the wizard's discovery hook. */
|
|
285
|
+
async function discoverRepoFactsDetailed(
|
|
286
|
+
repo: string,
|
|
287
|
+
run: DiscoveryRunner,
|
|
288
|
+
): Promise<DiscoveredFacts> {
|
|
289
|
+
const evidence: string[] = [];
|
|
290
|
+
const skipped: string[] = [];
|
|
291
|
+
try {
|
|
292
|
+
const owner = repo.split("/")[0] ?? repo;
|
|
293
|
+
const [branchReply, labelsReply, environmentsReply, milestonesReply, projectsReply] = await Promise.all([
|
|
294
|
+
attempted(run, ["gh", "repo", "view", repo, "--json", "defaultBranchRef"]),
|
|
295
|
+
attempted(run, ["gh", "label", "list", "--repo", repo, "--limit", "1000", "--json", "name"]),
|
|
296
|
+
attempted(run, ["gh", "api", `repos/${repo}/environments?per_page=100`]),
|
|
297
|
+
attempted(run, ["gh", "api", `repos/${repo}/milestones?state=open&per_page=100`]),
|
|
298
|
+
attempted(run, ["gh", "project", "list", "--owner", owner, "--limit", "100", "--format", "json"]),
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
const defaultBranch = parsed("default branch", branchReply, defaultBranchFrom, skipped);
|
|
302
|
+
const labels = parsed("labels", labelsReply, labelsFrom, skipped);
|
|
303
|
+
const environments = parsed("release environments", environmentsReply, environmentsFrom, skipped);
|
|
304
|
+
const milestones = parsed("open milestones", milestonesReply, milestonesFrom, skipped);
|
|
305
|
+
const projects = parsed("Projects boards", projectsReply, projectsFrom, skipped);
|
|
306
|
+
|
|
307
|
+
if (defaultBranch !== undefined) evidence.push(`default branch "${defaultBranch}" — gh repo view ${repo}`);
|
|
308
|
+
if (labels !== undefined) evidence.push(`labels (${labels.join(", ") || "none"}) — gh label list --repo ${repo}`);
|
|
309
|
+
if (environments !== undefined) {
|
|
310
|
+
evidence.push(`release environments (${environments.join(", ") || "none"}) — GitHub environments for ${repo}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let requiredChecks: string[] | undefined;
|
|
314
|
+
let artefacts: string[] | undefined;
|
|
315
|
+
if (defaultBranch !== undefined) {
|
|
316
|
+
const branch = encodeURIComponent(defaultBranch);
|
|
317
|
+
const [protectionReply, treeReply] = await Promise.all([
|
|
318
|
+
attempted(run, ["gh", "api", `repos/${repo}/branches/${branch}/protection`]),
|
|
319
|
+
attempted(run, ["gh", "api", `repos/${repo}/git/trees/${branch}?recursive=1`]),
|
|
320
|
+
]);
|
|
321
|
+
requiredChecks = parsed("required checks", protectionReply, requiredChecksFrom, []);
|
|
322
|
+
let checkEvidence = `branch protection for ${repo}@${defaultBranch}`;
|
|
323
|
+
if (requiredChecks === undefined) {
|
|
324
|
+
const runsReply = await attempted(run, ["gh", "api", `repos/${repo}/commits/${branch}/check-runs?per_page=100`]);
|
|
325
|
+
requiredChecks = parsed("required checks", runsReply, requiredChecksFrom, skipped);
|
|
326
|
+
checkEvidence = `latest check runs for ${repo}@${defaultBranch}`;
|
|
327
|
+
}
|
|
328
|
+
if (requiredChecks !== undefined) {
|
|
329
|
+
evidence.push(`merge checks (${requiredChecks.join(", ") || "none"}) — ${checkEvidence}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let packagePaths: string[] | undefined;
|
|
333
|
+
if (jsonObject(treeReply.stdout)?.truncated === true) {
|
|
334
|
+
skipped.push(
|
|
335
|
+
"release artefacts: the repository's recursive Git tree was truncated, so the visible manifests may be a partial view; the published artefact is left for you to type",
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
packagePaths = parsed("release artefacts", treeReply, packagePathsFrom, skipped);
|
|
339
|
+
}
|
|
340
|
+
if (packagePaths?.length === 1) {
|
|
341
|
+
const manifest = await attempted(run, [
|
|
342
|
+
"gh",
|
|
343
|
+
"api",
|
|
344
|
+
"-H",
|
|
345
|
+
"Accept: application/vnd.github.raw+json",
|
|
346
|
+
`repos/${repo}/contents/${packagePaths[0]}`,
|
|
347
|
+
]);
|
|
348
|
+
artefacts = parsed("release artefacts", manifest, artefactsFromPackage, skipped);
|
|
349
|
+
if (artefacts !== undefined) {
|
|
350
|
+
evidence.push(`release artefact "${artefacts[0]}" — ${packagePaths[0]} package name`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const roadmaps = projects === undefined && milestones === undefined
|
|
356
|
+
? undefined
|
|
357
|
+
: [...new Set([...(projects ?? []), ...(milestones ?? [])])];
|
|
358
|
+
if (roadmaps !== undefined && roadmaps.length > 0) {
|
|
359
|
+
evidence.push(`roadmap candidates — GitHub Projects and open milestones for ${repo}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
...(defaultBranch === undefined ? {} : { defaultBranch }),
|
|
364
|
+
...(labels === undefined ? {} : { labels }),
|
|
365
|
+
...(environments === undefined ? {} : { environments }),
|
|
366
|
+
...(requiredChecks === undefined ? {} : { requiredChecks }),
|
|
367
|
+
...(artefacts === undefined ? {} : { artefacts }),
|
|
368
|
+
...(roadmaps === undefined ? {} : { roadmaps }),
|
|
369
|
+
evidence,
|
|
370
|
+
skipped,
|
|
371
|
+
};
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return {
|
|
374
|
+
evidence,
|
|
375
|
+
skipped: [...skipped, `repo discovery: ${err instanceof Error ? err.message : String(err)}`],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Discover the four exact repository facts exposed as the module's pure API. */
|
|
381
|
+
export async function discoverRepoFacts(
|
|
382
|
+
repo: string,
|
|
383
|
+
run: DiscoveryRunner = runDiscovery,
|
|
384
|
+
): Promise<DiscoveredRepoFacts> {
|
|
385
|
+
const found = await discoverRepoFactsDetailed(repo, run);
|
|
386
|
+
return {
|
|
387
|
+
...(found.defaultBranch === undefined ? {} : { defaultBranch: found.defaultBranch }),
|
|
388
|
+
...(found.labels === undefined ? {} : { labels: found.labels }),
|
|
389
|
+
...(found.environments === undefined ? {} : { environments: found.environments }),
|
|
390
|
+
...(found.requiredChecks === undefined ? {} : { requiredChecks: found.requiredChecks }),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Discover `owner/repo` from the checkout's origin, or return no value. */
|
|
395
|
+
export async function discoverTrackerRepo(
|
|
396
|
+
cwd: string,
|
|
397
|
+
run: DiscoveryRunner = runDiscovery,
|
|
398
|
+
): Promise<string | undefined> {
|
|
399
|
+
const result = await attempted(run, ["git", "remote", "get-url", "origin"], { cwd });
|
|
400
|
+
return result.exitCode === 0 ? trackerRepoFrom(result.stdout) : undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Compose checkout and GitHub facts for the wizard's single discovery hook. */
|
|
404
|
+
export async function discoverFacts(
|
|
405
|
+
target: DiscoveryTarget,
|
|
406
|
+
run: DiscoveryRunner = runDiscovery,
|
|
407
|
+
): Promise<DiscoveredFacts> {
|
|
408
|
+
const origin = await attempted(run, ["git", "remote", "get-url", "origin"], { cwd: target.cwd });
|
|
409
|
+
if (origin.exitCode !== 0) {
|
|
410
|
+
return { evidence: [], skipped: [`tracker repo: ${failure(origin)}`] };
|
|
411
|
+
}
|
|
412
|
+
const trackerRepo = trackerRepoFrom(origin.stdout);
|
|
413
|
+
if (trackerRepo === undefined) {
|
|
414
|
+
return { evidence: [], skipped: ["tracker repo: origin is not an owner/repo Git remote"] };
|
|
415
|
+
}
|
|
416
|
+
const cloneUrl = origin.stdout.trim();
|
|
417
|
+
const repo = await discoverRepoFactsDetailed(trackerRepo, run);
|
|
418
|
+
return {
|
|
419
|
+
...repo,
|
|
420
|
+
trackerRepo,
|
|
421
|
+
cloneUrl,
|
|
422
|
+
routingKey: routingKeyFromCloneUrl(cloneUrl),
|
|
423
|
+
evidence: [`tracker repo "${trackerRepo}" — git remote get-url origin`, ...(repo.evidence ?? [])],
|
|
424
|
+
};
|
|
425
|
+
}
|
package/src/setup-wizard.ts
CHANGED
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
} from "./setup-host.ts";
|
|
43
43
|
import { runGraphInstall, runHostInstall } from "./setup-install.ts";
|
|
44
44
|
import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts";
|
|
45
|
+
import {
|
|
46
|
+
discoverFacts,
|
|
47
|
+
type DiscoveredFacts,
|
|
48
|
+
type DiscoveryTarget,
|
|
49
|
+
} from "./setup-discover.ts";
|
|
45
50
|
import {
|
|
46
51
|
AMEND_AREAS,
|
|
47
52
|
BASE_FRESHNESS_CHOICES,
|
|
@@ -459,10 +464,12 @@ async function askAuthority(
|
|
|
459
464
|
* shape cannot be added without a question to ask about it — an unasked shape
|
|
460
465
|
* would silently take the deny default and read as a decision afterwards.
|
|
461
466
|
*
|
|
462
|
-
* No fleet vocabulary here
|
|
463
|
-
*
|
|
467
|
+
* No fleet-specific vocabulary here (#122): these are generic release acts.
|
|
468
|
+
* Version preparation becomes executable only when a repo declares its version
|
|
469
|
+
* file; package names, suite pins and deployment topology still stay in policy.
|
|
464
470
|
*/
|
|
465
471
|
const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]: string } = {
|
|
472
|
+
"version-bump-pr": "open and land an exact version-only pull request before tagging",
|
|
466
473
|
"git-tag": "create git tags (`git tag v1.2.3`)",
|
|
467
474
|
"git-push-tags": "push tags to the remote (`git push --follow-tags`)",
|
|
468
475
|
"package-publish": "publish packages (`npm publish` and equivalents)",
|
|
@@ -489,14 +496,26 @@ async function askJudgment(
|
|
|
489
496
|
ui: WizardUi,
|
|
490
497
|
grants: ResolvedGrants,
|
|
491
498
|
prior: OperatorJudgment,
|
|
499
|
+
roadmapChoices: readonly string[] = [],
|
|
492
500
|
): Promise<OperatorJudgment> {
|
|
493
501
|
// Always asked. A tracker shows what is open, never what matters, and an
|
|
494
502
|
// orchestrator that cannot rank work grooms by recency — which is how a stale
|
|
495
503
|
// issue outranks the thing being shipped this month.
|
|
504
|
+
let roadmapSeed = prior.roadmap ?? "";
|
|
505
|
+
if (roadmapSeed === "" && roadmapChoices.length > 0) {
|
|
506
|
+
const other = "Type a different roadmap or priority";
|
|
507
|
+
const picked = await ui.select(
|
|
508
|
+
"Roadmap candidates found in GitHub",
|
|
509
|
+
[...roadmapChoices.map((label) => ({ label })), { label: other }],
|
|
510
|
+
{ initialIndex: 0 },
|
|
511
|
+
);
|
|
512
|
+
if (picked === undefined) throw new Cancelled();
|
|
513
|
+
roadmapSeed = picked === other ? "" : picked;
|
|
514
|
+
}
|
|
496
515
|
const roadmap = await ask(
|
|
497
516
|
ui,
|
|
498
517
|
"Where does the roadmap live, and what is the current priority?",
|
|
499
|
-
|
|
518
|
+
roadmapSeed,
|
|
500
519
|
);
|
|
501
520
|
const judgment: OperatorJudgment = { ...prior, ...(roadmap.length === 0 ? {} : { roadmap }) };
|
|
502
521
|
|
|
@@ -735,7 +754,12 @@ function priorProject(existing: ConductorConfig | undefined, name: string | unde
|
|
|
735
754
|
* prompts or the defaults they pre-fill from: the value shown is always the value
|
|
736
755
|
* that would otherwise be carried through.
|
|
737
756
|
*/
|
|
738
|
-
type AreaAsker = (
|
|
757
|
+
type AreaAsker = (
|
|
758
|
+
ui: WizardUi,
|
|
759
|
+
a: SetupAnswers,
|
|
760
|
+
probes: SetupProbes,
|
|
761
|
+
discovered?: DiscoveredFacts,
|
|
762
|
+
) => Promise<SetupAnswers>;
|
|
739
763
|
|
|
740
764
|
/**
|
|
741
765
|
* The repo-reading half of onboarding, injected rather than called directly.
|
|
@@ -747,6 +771,8 @@ type AreaAsker = (ui: WizardUi, a: SetupAnswers, probes: SetupProbes) => Promise
|
|
|
747
771
|
* peer, a private repo, or a model that answered in prose.
|
|
748
772
|
*/
|
|
749
773
|
export interface SetupProbes {
|
|
774
|
+
/** Finds exact checkout/GitHub facts. `{}` seeds nothing. */
|
|
775
|
+
discover(target: DiscoveryTarget): Promise<DiscoveredFacts>;
|
|
750
776
|
/** Proposes a repo's pre-push gates by reading its CI. `[]` seeds nothing. */
|
|
751
777
|
gates(ui: WizardUi, target: ProbeTarget): Promise<ProbedGate[]>;
|
|
752
778
|
/**
|
|
@@ -758,12 +784,77 @@ export interface SetupProbes {
|
|
|
758
784
|
|
|
759
785
|
/** Reads each repo to propose answers. */
|
|
760
786
|
export const DEFAULT_PROBES: SetupProbes = {
|
|
787
|
+
discover: (target) => discoverFacts(target),
|
|
761
788
|
gates: (ui, target) => probeGates(ui, target),
|
|
762
789
|
prose: (ui, a) => proseFromRepos(ui, a),
|
|
763
790
|
};
|
|
764
791
|
|
|
765
792
|
/** `--no-ai`, and every wizard test: nothing is read, nothing is proposed. */
|
|
766
|
-
export const NO_PROBES: SetupProbes = {
|
|
793
|
+
export const NO_PROBES: SetupProbes = {
|
|
794
|
+
discover: async () => ({}),
|
|
795
|
+
gates: async () => [],
|
|
796
|
+
prose: async () => ({}),
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
/** Exact facts replace only fresh defaults; saved operator answers always win. */
|
|
800
|
+
function seedFromDiscovery(seed: SetupAnswers, found: DiscoveredFacts): SetupAnswers {
|
|
801
|
+
const labels = new Map((found.labels ?? []).map((name) => [name.toLowerCase(), name]));
|
|
802
|
+
const trackerRepo = seed.trackerRepo || found.trackerRepo || "";
|
|
803
|
+
const targetRepos =
|
|
804
|
+
seed.targetRepos.length > 0 ||
|
|
805
|
+
found.routingKey === undefined ||
|
|
806
|
+
found.cloneUrl === undefined
|
|
807
|
+
? seed.targetRepos
|
|
808
|
+
: [
|
|
809
|
+
{
|
|
810
|
+
name: found.routingKey,
|
|
811
|
+
cloneUrl: found.cloneUrl,
|
|
812
|
+
defaultBranch: found.defaultBranch ?? SETUP_DEFAULTS.defaultBranch,
|
|
813
|
+
gates: [],
|
|
814
|
+
},
|
|
815
|
+
];
|
|
816
|
+
return {
|
|
817
|
+
...seed,
|
|
818
|
+
trackerRepo,
|
|
819
|
+
queueLabel: labels.get(seed.queueLabel.toLowerCase()) ?? seed.queueLabel,
|
|
820
|
+
stateLabels: {
|
|
821
|
+
inProgress: labels.get(seed.stateLabels.inProgress.toLowerCase()) ?? seed.stateLabels.inProgress,
|
|
822
|
+
blocked: labels.get(seed.stateLabels.blocked.toLowerCase()) ?? seed.stateLabels.blocked,
|
|
823
|
+
failed: labels.get(seed.stateLabels.failed.toLowerCase()) ?? seed.stateLabels.failed,
|
|
824
|
+
},
|
|
825
|
+
targetRepos,
|
|
826
|
+
policy: {
|
|
827
|
+
merge: {
|
|
828
|
+
...seed.policy.merge,
|
|
829
|
+
requiredChecks: found.requiredChecks ?? seed.policy.merge.requiredChecks,
|
|
830
|
+
},
|
|
831
|
+
release: {
|
|
832
|
+
...seed.policy.release,
|
|
833
|
+
artefacts: found.artefacts ?? seed.policy.release.artefacts,
|
|
834
|
+
environments: found.environments ?? seed.policy.release.environments,
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function showDiscovery(ui: WizardUi, found: DiscoveredFacts): void {
|
|
841
|
+
if ((found.evidence?.length ?? 0) > 0) {
|
|
842
|
+
ui.notify(
|
|
843
|
+
[
|
|
844
|
+
"Discovered setup defaults:",
|
|
845
|
+
...(found.evidence ?? []).map((line) => ` ${line}`),
|
|
846
|
+
"Every value remains editable at its prompt.",
|
|
847
|
+
].join("\n"),
|
|
848
|
+
"info",
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if ((found.skipped?.length ?? 0) > 0) {
|
|
852
|
+
ui.notify(
|
|
853
|
+
["Some setup discovery was skipped; today's typed defaults remain:", ...(found.skipped ?? []).map((line) => ` ${line}`)].join("\n"),
|
|
854
|
+
"warning",
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
767
858
|
|
|
768
859
|
/**
|
|
769
860
|
* The brief's two prose halves, drafted against **every** configured repo.
|
|
@@ -1047,14 +1138,14 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
|
1047
1138
|
|
|
1048
1139
|
/** The two ownership questions, then the mechanical gate one shape at a time:
|
|
1049
1140
|
* together they are what decides what an unattended fleet may do unasked. */
|
|
1050
|
-
const askAuthorityArea: AreaAsker = async (ui, a) => {
|
|
1141
|
+
const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
1051
1142
|
const authority = await askAuthority(ui, a.authority);
|
|
1052
1143
|
const releaseGrants = await askReleaseGrants(ui, a.releaseGrants);
|
|
1053
1144
|
// Asked here because it is the same decision one layer down: the grants say
|
|
1054
1145
|
// who may act, and these say where that permission stops. Amending authority
|
|
1055
1146
|
// therefore re-asks the boundary, which is the point — a grant widened without
|
|
1056
1147
|
// restating the boundary is how a delegated release loses its end.
|
|
1057
|
-
const judgment = await askJudgment(ui, releaseGrants, a.judgment ?? {});
|
|
1148
|
+
const judgment = await askJudgment(ui, releaseGrants, a.judgment ?? {}, discovered?.roadmaps);
|
|
1058
1149
|
return { ...a, authority, releaseGrants, judgment };
|
|
1059
1150
|
};
|
|
1060
1151
|
|
|
@@ -1432,12 +1523,17 @@ async function collectAnswers(
|
|
|
1432
1523
|
probes: SetupProbes,
|
|
1433
1524
|
opts: { added?: boolean } = {},
|
|
1434
1525
|
): Promise<SetupAnswers> {
|
|
1526
|
+
// Discovery is a fresh-interview default only. Re-runs start from the saved
|
|
1527
|
+
// project, so an operator-edited value is never re-guessed.
|
|
1528
|
+
const discovered = prior === undefined ? await probes.discover({ cwd: process.cwd() }) : {};
|
|
1529
|
+
showDiscovery(ui, discovered);
|
|
1435
1530
|
// Added projects seed under projects/<name>/; a first install and a re-interview
|
|
1436
1531
|
// of an existing project keep the flat (or already-on-disk) roots.
|
|
1437
|
-
const
|
|
1532
|
+
const baseSeed =
|
|
1438
1533
|
prior === undefined
|
|
1439
1534
|
? defaultAnswers(projectArg ?? "", { added: opts.added === true })
|
|
1440
1535
|
: answersFromProject(prior);
|
|
1536
|
+
const seed = prior === undefined ? seedFromDiscovery(baseSeed, discovered) : baseSeed;
|
|
1441
1537
|
|
|
1442
1538
|
const projectName = await askValid(
|
|
1443
1539
|
ui,
|
|
@@ -1462,8 +1558,8 @@ async function collectAnswers(
|
|
|
1462
1558
|
// routed repo, under one root.
|
|
1463
1559
|
a = await askGraph(ui, a, probes);
|
|
1464
1560
|
a = await askCaps(ui, a, probes);
|
|
1465
|
-
a = await askAuthorityArea(ui, a, probes);
|
|
1466
|
-
a = await askPolicy(ui, a, probes);
|
|
1561
|
+
a = await askAuthorityArea(ui, a, probes, discovered);
|
|
1562
|
+
a = await askPolicy(ui, a, probes, discovered);
|
|
1467
1563
|
a = await askWorkerModel(ui, a, probes);
|
|
1468
1564
|
a = await askEscalation(ui, a, probes);
|
|
1469
1565
|
a = await askReporting(ui, a, probes);
|
package/src/setup.ts
CHANGED
|
@@ -131,7 +131,14 @@ export interface SetupAnswers {
|
|
|
131
131
|
queueLabel: string;
|
|
132
132
|
stateLabels: { inProgress: string; blocked: string; failed: string };
|
|
133
133
|
routingLabelPrefix: string;
|
|
134
|
-
targetRepos: {
|
|
134
|
+
targetRepos: {
|
|
135
|
+
name: string;
|
|
136
|
+
cloneUrl: string;
|
|
137
|
+
defaultBranch: string;
|
|
138
|
+
gates: { cmd: string; cwd: string }[];
|
|
139
|
+
migrations?: { dir: string };
|
|
140
|
+
release?: { versionFile: string };
|
|
141
|
+
}[];
|
|
135
142
|
caps: Partial<Caps>;
|
|
136
143
|
/**
|
|
137
144
|
* Model pattern for worker sessions, in omp's model/role syntax. Absent means
|
|
@@ -170,6 +177,13 @@ export interface SetupAnswers {
|
|
|
170
177
|
* leave one to be defaulted by whichever reader gets there first.
|
|
171
178
|
*/
|
|
172
179
|
policy: ProjectPolicy;
|
|
180
|
+
/**
|
|
181
|
+
* Hand-edited recovery merge authorizations carried through setup unchanged.
|
|
182
|
+
* The wizard never grants one; forgetting them during an unrelated amend
|
|
183
|
+
* would strand an in-progress recovery, while inventing one would weaken the
|
|
184
|
+
* merge provenance gate.
|
|
185
|
+
*/
|
|
186
|
+
recoveryMerges?: ProjectConfig["recoveryMerges"];
|
|
173
187
|
/**
|
|
174
188
|
* Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
|
|
175
189
|
* part of the config — the brief is the operator's file, and the conductor
|
|
@@ -702,6 +716,8 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
702
716
|
...(graphRoot === undefined || graphRoot.length === 0
|
|
703
717
|
? {}
|
|
704
718
|
: { graphProject: graphProjectPath(graphRoot, r.name) }),
|
|
719
|
+
...(r.migrations === undefined ? {} : { migrations: { ...r.migrations } }),
|
|
720
|
+
...(r.release === undefined ? {} : { release: { ...r.release } }),
|
|
705
721
|
};
|
|
706
722
|
}
|
|
707
723
|
|
|
@@ -749,6 +765,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
749
765
|
// Written out in full for the same reason: the file then says what a merge
|
|
750
766
|
// and a release require without anyone having to know a default (#129).
|
|
751
767
|
policy: clonePolicy(a.policy),
|
|
768
|
+
...(a.recoveryMerges === undefined
|
|
769
|
+
? {}
|
|
770
|
+
: { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
|
|
752
771
|
// Written out even when it is the default, so an operator amending the
|
|
753
772
|
// volume has a line in the file to point at. Opting into availability makes
|
|
754
773
|
// the schedule explicit and daily; omitting it preserves the preset's
|
|
@@ -918,6 +937,8 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
918
937
|
cloneUrl: r.cloneUrl,
|
|
919
938
|
defaultBranch: r.defaultBranch,
|
|
920
939
|
gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
|
|
940
|
+
...(r.migrations === undefined ? {} : { migrations: { ...r.migrations } }),
|
|
941
|
+
...(r.release === undefined ? {} : { release: { ...r.release } }),
|
|
921
942
|
})),
|
|
922
943
|
caps: { ...p.caps },
|
|
923
944
|
fallbackToIssueComment: p.escalation.fallbackToIssueComment,
|
|
@@ -938,6 +959,9 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
938
959
|
if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
|
|
939
960
|
if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
|
|
940
961
|
if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
|
|
962
|
+
if (p.recoveryMerges !== undefined) {
|
|
963
|
+
answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
|
|
964
|
+
}
|
|
941
965
|
if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
|
|
942
966
|
if (p.reporting?.digest.timezone !== undefined) {
|
|
943
967
|
answers.reportingTimezone = p.reporting.digest.timezone;
|