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.
@@ -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
+ }
@@ -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 on purpose (#122): every one of these is an act the
463
- * package can recognise anywhere, not a step in one project's release topology.
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
- prior.roadmap ?? "",
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 = (ui: WizardUi, a: SetupAnswers, probes: SetupProbes) => Promise<SetupAnswers>;
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 = { gates: async () => [], prose: async () => ({}) };
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 seed =
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: { name: string; cloneUrl: string; defaultBranch: string; gates: { cmd: string; cwd: string }[] }[];
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;