omp-conductor 0.15.6 → 0.15.8

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.
@@ -617,9 +617,14 @@ export { TELEGRAM_APPROVAL_TOOL };
617
617
  * Fails closed in the only sense that helps a fleet: the duties still run, but
618
618
  * the turn is told the approval primitive is missing *before* it can reach the
619
619
  * step that needs one. The fallback named here is the one the floor already
620
- * documents for a `telegram_ask` that never delivered — re-deliver with
621
- * `telegram_send` — so a session has one answer to "the ask did not happen",
622
- * not two. The last clause is the actual hazard #114 exposed: a turn that knows
620
+ * documents for a `telegram_ask` that never delivered — re-deliver the same
621
+ * question — so a session has one answer to "the ask did not happen", not two.
622
+ * It names `omp-conductor message` rather than `telegram_send` because this
623
+ * rule only ever reaches a locally injected turn: there is no inbound message
624
+ * to inherit a chat and topic from, and a bare send would answer a forum fleet
625
+ * in its main chat (#366).
626
+ *
627
+ * The last clause is the actual hazard #114 exposed: a turn that knows
623
628
  * it must ask, and cannot, is one inference away from recording an approval
624
629
  * nobody gave.
625
630
  *
@@ -630,7 +635,7 @@ export { TELEGRAM_APPROVAL_TOOL };
630
635
  */
631
636
  export const TICK_APPROVAL_UNAVAILABLE_RULE =
632
637
  `The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
633
- `If you have an amendment to propose, deliver the question with telegram_send, prefix its text with "QUESTION:", and wait for your operator's reply on a later turn. ` +
638
+ `If you have an amendment to propose, deliver the question with \`omp-conductor message --text "QUESTION: <the question>"\` — it resolves this project's own Telegram chat and topic — and wait for your operator's reply on a later turn. ` +
634
639
  `A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
635
640
  `Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
636
641
 
@@ -1639,17 +1644,25 @@ function serialiseTelegramPayload(value: unknown): string {
1639
1644
  }
1640
1645
  }
1641
1646
 
1642
- function telegramInterruptFromTool(
1647
+ /**
1648
+ * A direct Telegram call, whatever surface it arrived on: the mounted tool, or
1649
+ * the same device written through `xd://`. Both spellings reach the same bot,
1650
+ * so every gate below reads them the same way.
1651
+ */
1652
+ function directTelegramCall(
1643
1653
  toolName: string,
1644
1654
  input: Record<string, unknown>,
1645
- ): TelegramInterrupt | undefined {
1655
+ ): { kind: TelegramInterrupt["kind"]; payload: Record<string, unknown>; reaction: boolean } | undefined {
1646
1656
  let kind: TelegramInterrupt["kind"];
1657
+ let reaction: boolean;
1647
1658
  let payload: Record<string, unknown> = input;
1648
1659
 
1649
1660
  if (toolName === TELEGRAM_APPROVAL_TOOL) {
1650
1661
  kind = "question";
1662
+ reaction = false;
1651
1663
  } else if (toolName === "telegram_send" || toolName === "telegram_react") {
1652
1664
  kind = "message";
1665
+ reaction = toolName === "telegram_react";
1653
1666
  } else if (toolName === "write") {
1654
1667
  const path = input["path"];
1655
1668
  if (
@@ -1660,6 +1673,7 @@ function telegramInterruptFromTool(
1660
1673
  return undefined;
1661
1674
  }
1662
1675
  kind = path === "xd://telegram_ask" ? "question" : "message";
1676
+ reaction = path === "xd://telegram_react";
1663
1677
  const content = input["content"];
1664
1678
  if (typeof content === "string") {
1665
1679
  try {
@@ -1672,6 +1686,46 @@ function telegramInterruptFromTool(
1672
1686
  } else {
1673
1687
  return undefined;
1674
1688
  }
1689
+ return { kind, payload, reaction };
1690
+ }
1691
+
1692
+ /** A named target that is really named: an empty string targets nothing. */
1693
+ function targetNamed(value: unknown): boolean {
1694
+ if (typeof value === "number") return Number.isFinite(value);
1695
+ return typeof value === "string" && value.trim() !== "";
1696
+ }
1697
+
1698
+ /**
1699
+ * The one `telegram_send` shape that silently leaves a forum topic: `thread_id`
1700
+ * falls back to the active topic *only while `chat_id` is omitted*, so naming
1701
+ * the chat alone delivers into the main chat instead of the thread the operator
1702
+ * wrote in (#366). This package cannot rewrite another plugin's arguments, so
1703
+ * the unsafe shape is refused with both safe ones spelled out — a reply the
1704
+ * operator never sees in the thread they asked in is indistinguishable from
1705
+ * silence, and it leaks the answer to everyone reading the main chat.
1706
+ *
1707
+ * Returns `undefined` for every other shape, including a call that names both.
1708
+ */
1709
+ export function telegramTopicRoutingRefusal(payload: Record<string, unknown>): string | undefined {
1710
+ if (!targetNamed(payload["chat_id"])) return undefined;
1711
+ if (targetNamed(payload["thread_id"]) || targetNamed(payload["message_thread_id"])) return undefined;
1712
+ return (
1713
+ "Blocked: this project's Telegram is topic-routed, and thread_id defaults to the active topic only while chat_id is omitted — " +
1714
+ "naming chat_id alone delivers into the main chat instead of the topic the message arrived in (#366). " +
1715
+ "Nothing was sent. Answer in the active message's own topic by omitting BOTH chat_id and thread_id, or pass both together. " +
1716
+ 'On a locally injected tick there is no active topic to keep: run `omp-conductor message --text "<the message>"`, ' +
1717
+ "which resolves this project's own chat and topic from config."
1718
+ );
1719
+ }
1720
+
1721
+ function telegramInterruptFromTool(
1722
+ toolName: string,
1723
+ input: Record<string, unknown>,
1724
+ ): TelegramInterrupt | undefined {
1725
+ const call = directTelegramCall(toolName, input);
1726
+ if (call === undefined) return undefined;
1727
+ let kind = call.kind;
1728
+ const payload = call.payload;
1675
1729
  if (
1676
1730
  kind === "message" &&
1677
1731
  typeof payload["text"] === "string" &&
@@ -1725,6 +1779,34 @@ function telegramInterruptFromTool(
1725
1779
 
1726
1780
  type TelegramInterruptBlock = { block: true; reason: string };
1727
1781
 
1782
+ /**
1783
+ * Topic routing is checked for **every** direct Telegram *delivery* this
1784
+ * session makes, autonomous or not: the incident was a reply to a waiting
1785
+ * human, which the autonomous gate below deliberately exempts (#366). A
1786
+ * flat-chat project has no thread to lose and is left exactly as it was, and a
1787
+ * reaction addresses an exact `message_id` — it carries no thread argument at
1788
+ * all, so there is nothing there to get wrong.
1789
+ */
1790
+ function telegramRoutingBlock(
1791
+ configuredProject: string | undefined,
1792
+ event: { toolName: string; input: Record<string, unknown> },
1793
+ ): TelegramInterruptBlock | undefined {
1794
+ const call = directTelegramCall(event.toolName, event.input);
1795
+ if (call === undefined || call.reaction) return undefined;
1796
+ let topicRouted: boolean;
1797
+ try {
1798
+ topicRouted = findProject(loadConfig(), configuredProject).escalation.telegramTopicId !== undefined;
1799
+ } catch {
1800
+ // An unreadable or ambiguous config says nothing about topics. The
1801
+ // autonomous gate already fails closed on it for locally injected ticks,
1802
+ // and a human-waiting reply must not become unanswerable because of it.
1803
+ return undefined;
1804
+ }
1805
+ if (!topicRouted) return undefined;
1806
+ const reason = telegramTopicRoutingRefusal(call.payload);
1807
+ return reason === undefined ? undefined : { block: true, reason };
1808
+ }
1809
+
1728
1810
  /**
1729
1811
  * A locally injected tick is an autonomous actor. Its direct Telegram calls
1730
1812
  * therefore pass through the same category and availability decision as daemon
@@ -2447,7 +2529,14 @@ export default function orchestratorTickExtension(pi: TickApi): void {
2447
2529
  ctx: unknown,
2448
2530
  ) => TelegramInterruptBlock | undefined,
2449
2531
  ): void;
2450
- }).on("tool_call", (event) => autonomousTelegramInterruptBlock(pi, session, event));
2532
+ }).on(
2533
+ "tool_call",
2534
+ (event) =>
2535
+ // Routing first: a call that cannot reach the right thread is refused
2536
+ // whether or not the availability policy would have let it through.
2537
+ telegramRoutingBlock(configuredProject, event) ??
2538
+ autonomousTelegramInterruptBlock(pi, session, event),
2539
+ );
2451
2540
  };
2452
2541
 
2453
2542
  pi.on("session_start", (_event, ctx) => {
package/src/reports.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  */
38
38
 
39
39
  import { availabilityDisposition, interruptDisposition } from "./availability.ts";
40
- import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
40
+ import { readTelegramToken, resolveProjectTopicId, sendTelegram, TelegramSendError } from "./escalate.ts";
41
41
  import { localDayKey } from "./digest-schedule.ts";
42
42
  import {
43
43
  DIGEST_BACKLOG_LIMIT,
@@ -284,10 +284,57 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
284
284
  "definitive",
285
285
  );
286
286
  }
287
- return await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
287
+ return await sendTelegram(token, chatId, text, { topicId: resolveProjectTopicId(p) });
288
288
  };
289
289
  }
290
290
 
291
+ /**
292
+ * One direct message to the operator, addressed from config rather than from
293
+ * whichever chat last spoke to the session (#366).
294
+ *
295
+ * `telegram_send` keeps the active forum topic only while it names no chat, and
296
+ * a locally injected tick has no active message to inherit one from — so a
297
+ * fleet whose Telegram is a topic had no path from an autonomous turn into its
298
+ * own thread, and answers surfaced in the main chat instead. This addresses the
299
+ * project's configured chat and topic, and it is deliberately *not* a way past
300
+ * the operator's interrupt policy: the same availability decision an autonomous
301
+ * Telegram tool call gets is applied here, and a deferred message is durably
302
+ * held for the digest or the working-hours catch-up rather than sent anyway.
303
+ *
304
+ * The `QUESTION:` marker is the floor's own spelling for "this needs an
305
+ * answer", so one prefix means one category whichever surface it leaves by.
306
+ */
307
+ export type OperatorMessageOutcome =
308
+ | { kind: "sent"; category: InterruptCategory }
309
+ | { kind: "held"; category: InterruptCategory; noticeId: string; reason: "availability" | "digest" };
310
+
311
+ export function operatorMessageCategory(text: string): InterruptCategory {
312
+ return /^\s*QUESTION:\s/i.test(text) ? "decision-needed" : "material";
313
+ }
314
+
315
+ export async function deliverOperatorMessage(
316
+ project: ProjectConfig,
317
+ text: string,
318
+ deps: { store: Store; at: number; noticeId: string; send?: ReportSend },
319
+ ): Promise<OperatorMessageOutcome> {
320
+ const category = operatorMessageCategory(text);
321
+ const disposition = interruptDisposition(project.reporting, category, deps.at);
322
+ if (disposition !== "interrupt") {
323
+ deps.store.addHeldNotice({
324
+ id: deps.noticeId,
325
+ project: project.name,
326
+ category,
327
+ summary: text.split("\n", 1)[0]!.slice(0, 240),
328
+ detail: text,
329
+ createdAt: deps.at,
330
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
331
+ });
332
+ return { kind: "held", category, noticeId: deps.noticeId, reason: disposition };
333
+ }
334
+ await (deps.send ?? telegramReportSend(project))(text);
335
+ return { kind: "sent", category };
336
+ }
337
+
291
338
  /** Keep the mechanical catch-up comfortably inside Telegram's report wrapper. */
292
339
  const AVAILABILITY_REPORT_BODY_LIMIT = 3_200;
293
340
  const AVAILABILITY_REPORT_KEY_PREFIX = "availability/";
@@ -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
+ }