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.
- package/README.md +103 -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 +62 -1
- package/src/config-schema.ts +20 -0
- package/src/config.ts +43 -0
- package/src/escalate.ts +69 -1
- package/src/fleet.ts +19 -39
- package/src/lifecycle.ts +164 -21
- package/src/orchestrator-tick.ts +96 -7
- package/src/reports.ts +49 -2
- package/src/setup-discover.ts +425 -0
- package/src/setup-wizard.ts +108 -34
- 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
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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
|
|
621
|
-
*
|
|
622
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
+
}
|