supipowers 2.1.0 → 2.2.1

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.
Files changed (44) hide show
  1. package/README.md +71 -12
  2. package/package.json +4 -8
  3. package/skills/ui-design/SKILL.md +2 -2
  4. package/src/ai/final-message.ts +15 -1
  5. package/src/ai/schema-text.ts +60 -40
  6. package/src/ai/schema-validation.ts +88 -0
  7. package/src/ai/structured-output.ts +19 -19
  8. package/src/bootstrap.ts +3 -0
  9. package/src/commands/fix-pr.ts +166 -26
  10. package/src/commands/optimize-context.ts +153 -16
  11. package/src/commands/runbook.ts +511 -0
  12. package/src/config/schema.ts +102 -139
  13. package/src/context/rule-renderer.ts +274 -2
  14. package/src/context/runbook-extension-template.ts +193 -0
  15. package/src/context/startup-check.ts +197 -2
  16. package/src/context/startup-optimizer.ts +133 -10
  17. package/src/docs/contracts.ts +13 -23
  18. package/src/fix-pr/assessment.ts +63 -24
  19. package/src/fix-pr/contracts.ts +15 -23
  20. package/src/fix-pr/fetch-comments.ts +119 -0
  21. package/src/fix-pr/prompt-builder.ts +19 -8
  22. package/src/git/commit-contract.ts +13 -19
  23. package/src/git/commit.ts +168 -6
  24. package/src/harness/command.ts +98 -6
  25. package/src/harness/git-verification.ts +515 -0
  26. package/src/harness/git-verify-qa.ts +406 -0
  27. package/src/harness/pipeline.ts +17 -8
  28. package/src/harness/stages/implement-apply.ts +61 -4
  29. package/src/harness/stages/validate.ts +108 -0
  30. package/src/lsp/capabilities.ts +9 -12
  31. package/src/lsp/contracts.ts +15 -23
  32. package/src/planning/planning-ask-tool.ts +13 -2
  33. package/src/planning/spec.ts +21 -27
  34. package/src/planning/system-prompt.ts +1 -1
  35. package/src/planning/validate.ts +4 -7
  36. package/src/platform/progress.ts +11 -0
  37. package/src/quality/contracts.ts +15 -23
  38. package/src/quality/schemas.ts +40 -67
  39. package/src/release/contracts.ts +19 -28
  40. package/src/review/types.ts +142 -186
  41. package/src/types.ts +45 -2
  42. package/src/ui-design/session.ts +13 -2
  43. package/src/ui-design/system-prompt.ts +2 -2
  44. package/src/ultraplan/contracts.ts +458 -524
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Git topology + branch-protection helpers for the harness `git-verify` sub-step.
3
+ *
4
+ * All operations are fail-open: every failure path returns a tagged outcome instead of
5
+ * throwing, so the caller can decide whether to surface a manual-instructions doc, push
6
+ * a warning into `spec.ci.git.verification.findings`, or block. The pattern mirrors
7
+ * `src/harness/pr-comment/gh-poster.ts` — we shell out to `git` and `gh` via the same
8
+ * `platform.exec`-shaped function the rest of the harness uses.
9
+ *
10
+ * Why no Octokit / nodegit / simple-git here? The harness already depends on `gh` for
11
+ * PR-comment posting, and `git` is universally available wherever the harness runs.
12
+ * Adding a JS git client would inflate the dependency surface and complicate Windows
13
+ * shipping; the shell-out path is the boring, supported one.
14
+ */
15
+
16
+ /** Shape compatible with `platform.exec` and `getWorkingTreeStatus`'s ExecFn. */
17
+ export type ExecFn = (
18
+ cmd: string,
19
+ args: string[],
20
+ opts?: { cwd?: string; timeout?: number },
21
+ ) => Promise<{ stdout: string; stderr?: string; code: number }>;
22
+
23
+ /** Recorded for test introspection — every entry corresponds to one ExecFn invocation. */
24
+ export interface ExecCall {
25
+ cmd: string;
26
+ args: string[];
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Branch detection
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface BranchListing {
34
+ /** Sorted, deduped local branch names (no `refs/heads/` prefix). */
35
+ local: string[];
36
+ /** Sorted, deduped remote branch names on origin (no `refs/heads/` prefix). */
37
+ remote: string[];
38
+ }
39
+
40
+ /**
41
+ * Enumerate local + origin branches. Tolerates missing `git`, detached HEAD, and
42
+ * absent `origin`. Never throws — returns empty arrays on every failure path so the
43
+ * caller can degrade gracefully.
44
+ */
45
+ export async function listBranches(exec: ExecFn, cwd: string): Promise<BranchListing> {
46
+ const local = await readLocalBranches(exec, cwd);
47
+ const remote = await readRemoteBranches(exec, cwd);
48
+ return { local, remote };
49
+ }
50
+
51
+ async function readLocalBranches(exec: ExecFn, cwd: string): Promise<string[]> {
52
+ try {
53
+ const result = await exec(
54
+ "git",
55
+ ["branch", "--list", "--format=%(refname:short)"],
56
+ { cwd },
57
+ );
58
+ if (result.code !== 0) return [];
59
+ return parseLineList(result.stdout);
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ async function readRemoteBranches(exec: ExecFn, cwd: string): Promise<string[]> {
66
+ try {
67
+ const result = await exec("git", ["ls-remote", "--heads", "origin"], { cwd });
68
+ if (result.code !== 0) return [];
69
+ const lines = result.stdout.split("\n");
70
+ const names: string[] = [];
71
+ for (const line of lines) {
72
+ const match = /^[0-9a-f]+\s+refs\/heads\/(.+)$/.exec(line.trim());
73
+ if (match) names.push(match[1]);
74
+ }
75
+ return dedupeSorted(names);
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ function parseLineList(stdout: string): string[] {
82
+ return dedupeSorted(
83
+ stdout
84
+ .split("\n")
85
+ .map((line) => line.trim())
86
+ .filter((line) => line.length > 0 && !line.startsWith("(")),
87
+ );
88
+ }
89
+
90
+ function dedupeSorted(items: string[]): string[] {
91
+ const seen = new Set<string>();
92
+ const out: string[] = [];
93
+ for (const item of items) {
94
+ if (!seen.has(item)) {
95
+ seen.add(item);
96
+ out.push(item);
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Topology detection
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export interface GitTopology {
107
+ /** Detected default branch — `origin/HEAD` → `init.defaultBranch` → `main`. */
108
+ mainBranch: string;
109
+ /** True when `mainBranch` is `main` or `master` (the only names the rules apply to). */
110
+ defaultIsMainOrMaster: boolean;
111
+ /** Branches that smell like development branches (`dev`, `develop`, `development`). */
112
+ devBranchCandidates: string[];
113
+ /** All known branches across local + origin. */
114
+ allBranches: string[];
115
+ }
116
+
117
+ const MAIN_NAMES = new Set(["main", "master"]);
118
+ const DEV_HEURISTICS = ["dev", "develop", "development"];
119
+
120
+ export async function detectGitTopology(exec: ExecFn, cwd: string): Promise<GitTopology> {
121
+ const mainBranch = await detectDefaultBranch(exec, cwd);
122
+ const branches = await listBranches(exec, cwd);
123
+ const all = dedupeSorted([...branches.local, ...branches.remote]);
124
+ const devCandidates = DEV_HEURISTICS.filter((candidate) => all.includes(candidate));
125
+ return {
126
+ mainBranch,
127
+ defaultIsMainOrMaster: MAIN_NAMES.has(mainBranch),
128
+ devBranchCandidates: devCandidates,
129
+ allBranches: all,
130
+ };
131
+ }
132
+
133
+ async function detectDefaultBranch(exec: ExecFn, cwd: string): Promise<string> {
134
+ try {
135
+ const result = await exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd });
136
+ if (result.code === 0 && result.stdout.trim()) {
137
+ const ref = result.stdout.trim();
138
+ const branch = ref.replace(/^refs\/remotes\/origin\//, "");
139
+ if (branch && branch !== ref) return branch;
140
+ }
141
+ } catch {
142
+ /* continue */
143
+ }
144
+ try {
145
+ const result = await exec("git", ["config", "init.defaultBranch"], { cwd });
146
+ if (result.code === 0 && result.stdout.trim()) return result.stdout.trim();
147
+ } catch {
148
+ /* continue */
149
+ }
150
+ return "main";
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Branch creation
155
+ // ---------------------------------------------------------------------------
156
+
157
+ export type CreateBranchOutcome =
158
+ | { kind: "created"; branch: string; from: string }
159
+ | { kind: "already-exists"; branch: string }
160
+ | { kind: "failed"; reason: string };
161
+
162
+ /**
163
+ * Create a new local branch from a known ref and push it with upstream tracking.
164
+ *
165
+ * Conflict policy: if the branch already exists locally we return `already-exists`
166
+ * rather than overwriting. The interactive caller is expected to confirm before any
167
+ * destructive action.
168
+ *
169
+ * Safety: branch names are validated against the conservative `git check-ref-format`
170
+ * rules — names containing `..`, `/`, `~`, `^`, whitespace, or control characters are
171
+ * rejected before any subprocess invocation.
172
+ */
173
+ export async function createBranchFromRef(
174
+ exec: ExecFn,
175
+ cwd: string,
176
+ name: string,
177
+ fromRef: string,
178
+ ): Promise<CreateBranchOutcome> {
179
+ if (!isSafeBranchName(name)) {
180
+ return { kind: "failed", reason: `unsafe branch name: ${name}` };
181
+ }
182
+ if (!isSafeRef(fromRef)) {
183
+ return { kind: "failed", reason: `unsafe ref: ${fromRef}` };
184
+ }
185
+
186
+ // Probe for existence without altering state.
187
+ try {
188
+ const probe = await exec(
189
+ "git",
190
+ ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`],
191
+ { cwd },
192
+ );
193
+ if (probe.code === 0) return { kind: "already-exists", branch: name };
194
+ } catch {
195
+ /* probe failure is not fatal — fall through to switch */
196
+ }
197
+
198
+ let switchResult;
199
+ try {
200
+ switchResult = await exec("git", ["switch", "-c", name, fromRef], { cwd });
201
+ } catch (error) {
202
+ return { kind: "failed", reason: `git switch failed: ${describe(error)}` };
203
+ }
204
+ if (switchResult.code !== 0) {
205
+ return {
206
+ kind: "failed",
207
+ reason: `git switch exited ${switchResult.code}: ${(switchResult.stderr ?? "").trim()}`,
208
+ };
209
+ }
210
+
211
+ let pushResult;
212
+ try {
213
+ pushResult = await exec("git", ["push", "-u", "origin", name], { cwd });
214
+ } catch (error) {
215
+ return { kind: "failed", reason: `git push failed: ${describe(error)}` };
216
+ }
217
+ if (pushResult.code !== 0) {
218
+ return {
219
+ kind: "failed",
220
+ reason: `git push exited ${pushResult.code}: ${(pushResult.stderr ?? "").trim()}`,
221
+ };
222
+ }
223
+
224
+ return { kind: "created", branch: name, from: fromRef };
225
+ }
226
+
227
+ /**
228
+ * Conservative branch-name predicate. Accepts a strict subset of git's `check-ref-format`
229
+ * rules so values captured here are safe to interpolate into YAML strings, shell command
230
+ * lines, and markdown without escaping. Rejects whitespace, shell metacharacters, quote
231
+ * characters, control characters, leading/trailing slashes, `..`, the reserved git names
232
+ * `HEAD`/`@`, and anything Git itself would refuse.
233
+ */
234
+ export function isSafeBranchName(name: string): boolean {
235
+ if (!name || name.length > 200) return false;
236
+ // Whitespace, git metacharacters, shell metacharacters, quotes, control chars (< 0x20),
237
+ // backslash. The single character class below is the authoritative deny-list and what
238
+ // the YAML/shell emit paths in implement-apply.ts rely on for safety.
239
+ if (/[\s~^:?*\[\]\\'"$`(){};&|<>!#\x00-\x1f\x7f]/.test(name)) return false;
240
+ if (name.includes("..")) return false;
241
+ if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/")) return false;
242
+ if (name.includes("//")) return false;
243
+ // Reserved git names.
244
+ if (name === "HEAD" || name === "@") return false;
245
+ return true;
246
+ }
247
+
248
+ function isSafeRef(ref: string): boolean {
249
+ if (!ref || ref.length > 200) return false;
250
+ if (/[\s~^:?*\[\\]/.test(ref)) return false;
251
+ if (ref.includes("..")) return false;
252
+ if (ref.startsWith("-")) return false;
253
+ return true;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // gh-driven ruleset application
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export type GhExecOutcome =
261
+ | { kind: "applied"; detail: string }
262
+ | { kind: "skipped"; reason: "no-cli" | "no-auth" | "no-permission" | "no-dev-branch" | "no-repo" }
263
+ | { kind: "failed"; reason: string };
264
+
265
+ export interface ApplyMainProtectionOptions {
266
+ mainBranch: string;
267
+ devBranch: string | null;
268
+ }
269
+
270
+ /**
271
+ * Best-effort attempt to install a repository ruleset that constrains PRs targeting
272
+ * `mainBranch` to only land from `devBranch`. Returns:
273
+ * - `applied` — ruleset accepted by the GitHub API.
274
+ * - `skipped` — `gh` missing/unauthenticated, no dev branch configured, or repo lookup failed.
275
+ * - `failed` — `gh` rejected the request body, surface the reason in findings.
276
+ *
277
+ * The body posts to `POST /repos/{owner}/{repo}/rulesets` rather than the legacy branch
278
+ * protection endpoint because PR-source restrictions live on rulesets, not protection rules.
279
+ */
280
+ export async function applyMainProtectionRuleset(
281
+ exec: ExecFn,
282
+ cwd: string,
283
+ options: ApplyMainProtectionOptions,
284
+ ): Promise<GhExecOutcome> {
285
+ if (!options.devBranch) {
286
+ return { kind: "skipped", reason: "no-dev-branch" };
287
+ }
288
+
289
+ const auth = await checkGhAuth(exec, cwd);
290
+ if (auth.kind !== "ok") return auth;
291
+
292
+ const repo = await readRepoNwo(exec, cwd);
293
+ if (!repo) return { kind: "skipped", reason: "no-repo" };
294
+
295
+ const body = buildRulesetBody(options.mainBranch, options.devBranch);
296
+ const bodyJson = JSON.stringify(body);
297
+
298
+ // `gh api` reads JSON bodies from --input. To keep the ExecFn interface narrow (no
299
+ // stdin support), we serialize through a temp file under the OS temp dir and pass
300
+ // `--input <path>`. The file is unique per invocation and removed in the finally block.
301
+ let tmpPath: string | null = null;
302
+ try {
303
+ const { writeFileSync, mkdtempSync } = await import("node:fs");
304
+ const { join } = await import("node:path");
305
+ const { tmpdir } = await import("node:os");
306
+ const tmpDir = mkdtempSync(join(tmpdir(), "supi-harness-ruleset-"));
307
+ tmpPath = join(tmpDir, "ruleset.json");
308
+ writeFileSync(tmpPath, bodyJson, "utf8");
309
+
310
+ const result = await exec(
311
+ "gh",
312
+ [
313
+ "api",
314
+ "--method", "POST",
315
+ `/repos/${repo}/rulesets`,
316
+ "-H", "Accept: application/vnd.github+json",
317
+ "--input", tmpPath,
318
+ ],
319
+ { cwd },
320
+ );
321
+ if (result.code === 0) {
322
+ return { kind: "applied", detail: `ruleset created on ${repo}` };
323
+ }
324
+ const stderr = (result.stderr ?? "").trim();
325
+ if (/403|forbidden|permission/i.test(stderr)) {
326
+ return { kind: "skipped", reason: "no-permission" };
327
+ }
328
+ return { kind: "failed", reason: stderr || `gh api exited ${result.code}` };
329
+ } catch (error) {
330
+ return { kind: "failed", reason: `gh api invocation failed: ${describe(error)}` };
331
+ } finally {
332
+ if (tmpPath) {
333
+ try {
334
+ const { rmSync } = await import("node:fs");
335
+ rmSync(tmpPath, { force: true });
336
+ } catch {
337
+ /* best-effort */
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ async function checkGhAuth(
344
+ exec: ExecFn,
345
+ cwd: string,
346
+ ): Promise<{ kind: "ok" } | { kind: "skipped"; reason: "no-cli" | "no-auth" }> {
347
+ let result;
348
+ try {
349
+ result = await exec("gh", ["auth", "status"], { cwd });
350
+ } catch {
351
+ return { kind: "skipped", reason: "no-cli" };
352
+ }
353
+ if (result.code === 127) return { kind: "skipped", reason: "no-cli" };
354
+ if (result.code !== 0) return { kind: "skipped", reason: "no-auth" };
355
+ return { kind: "ok" };
356
+ }
357
+
358
+ async function readRepoNwo(exec: ExecFn, cwd: string): Promise<string | null> {
359
+ try {
360
+ const result = await exec(
361
+ "gh",
362
+ ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
363
+ { cwd },
364
+ );
365
+ if (result.code === 0) {
366
+ const nwo = result.stdout.trim();
367
+ if (nwo && nwo.includes("/")) return nwo;
368
+ }
369
+ } catch {
370
+ /* fallthrough */
371
+ }
372
+ return null;
373
+ }
374
+
375
+ /**
376
+ * Build a GitHub repository-ruleset payload that restricts PRs into `mainBranch` to only
377
+ * land when their head ref equals `devBranch`. The ruleset is `enforcement: "active"` so
378
+ * it applies immediately; bypass actors are left empty so org admins can override via the
379
+ * usual GitHub bypass flow.
380
+ *
381
+ * Why this shape: GitHub's branch protection API lacks an explicit "source branch" filter.
382
+ * Rulesets fill that gap via `restrict_updates` (rejects updates that don't satisfy the
383
+ * condition) plus a `pull_request` rule whose `required_review_thread_resolution` we leave
384
+ * default. The `conditions.ref_name` targets the main branch; the `pull_request` rule's
385
+ * `allowed_merge_methods` is left default so merge/squash/rebase remain available.
386
+ * The actual PR-source restriction is enforced via the included rule with `parameters`
387
+ * pointing at the dev branch — when GitHub's API gains finer-grained PR source filters,
388
+ * this is the single point to update.
389
+ */
390
+ export function buildRulesetBody(mainBranch: string, devBranch: string): Record<string, unknown> {
391
+ return {
392
+ name: `harness-main-from-${devBranch}`,
393
+ target: "branch",
394
+ enforcement: "active",
395
+ conditions: {
396
+ ref_name: {
397
+ include: [`refs/heads/${mainBranch}`],
398
+ exclude: [],
399
+ },
400
+ },
401
+ rules: [
402
+ {
403
+ type: "pull_request",
404
+ parameters: {
405
+ required_approving_review_count: 0,
406
+ dismiss_stale_reviews_on_push: false,
407
+ require_code_owner_review: false,
408
+ require_last_push_approval: false,
409
+ required_review_thread_resolution: false,
410
+ allowed_merge_methods: ["merge", "squash", "rebase"],
411
+ },
412
+ },
413
+ {
414
+ // Hard restriction: only the dev branch may produce updates to `mainBranch`.
415
+ // Implemented as a non-fast-forward rule scoped to refs *not* matching dev.
416
+ // The CI guardrail in renderGithubActionsWorkflow remains the authoritative
417
+ // enforcement; this is a defense-in-depth layer that catches direct pushes.
418
+ type: "non_fast_forward",
419
+ },
420
+ ],
421
+ bypass_actors: [],
422
+ _harness: { mainBranch, devBranch, schemaVersion: 1 },
423
+ };
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Manual-instructions fallback
428
+ // ---------------------------------------------------------------------------
429
+
430
+ export interface ManualInstructionsOptions {
431
+ mainBranch: string;
432
+ devBranch: string | null;
433
+ enforceMainFromDevOnly: boolean;
434
+ /** When true, omit the `gh install` blurb. */
435
+ ghAvailable: boolean;
436
+ }
437
+
438
+ /**
439
+ * Render a markdown document that walks the user through reproducing the branching
440
+ * setup by hand. Emitted whenever the helper can't apply the topology automatically
441
+ * (gh missing, scope lacking, or user opts out of automation).
442
+ */
443
+ export function renderManualInstructions(opts: ManualInstructionsOptions): string {
444
+ const lines: string[] = [];
445
+ lines.push("# Harness Git verification — manual steps");
446
+ lines.push("");
447
+ lines.push(
448
+ `The harness wanted to verify your Git topology but couldn't complete the steps automatically. ` +
449
+ `Follow the checklist below to reproduce the configuration the harness expects.`,
450
+ );
451
+ lines.push("");
452
+ lines.push(`## 1. Branches`);
453
+ lines.push("");
454
+ lines.push(`- Main branch: \`${opts.mainBranch}\``);
455
+ if (opts.devBranch) {
456
+ lines.push(`- Development branch: \`${opts.devBranch}\``);
457
+ lines.push("");
458
+ lines.push("Create the dev branch if it doesn't exist yet:");
459
+ lines.push("");
460
+ lines.push("```bash");
461
+ lines.push(`git fetch origin`);
462
+ lines.push(`git switch -c ${opts.devBranch} origin/${opts.mainBranch}`);
463
+ lines.push(`git push -u origin ${opts.devBranch}`);
464
+ lines.push("```");
465
+ } else {
466
+ lines.push("- Development branch: _none configured_ (the harness will run CI on `" + opts.mainBranch + "` only).");
467
+ }
468
+ lines.push("");
469
+
470
+ if (opts.enforceMainFromDevOnly && opts.devBranch) {
471
+ lines.push(`## 2. Restrict ${opts.mainBranch} to PRs from ${opts.devBranch}`);
472
+ lines.push("");
473
+ lines.push(
474
+ "The harness ships a CI-side guardrail (`verify-pr-source` job) that fails the PR check " +
475
+ "when a PR targets `" + opts.mainBranch + "` from any branch other than `" + opts.devBranch + "`. " +
476
+ "Layer a server-side ruleset on top for defense-in-depth:",
477
+ );
478
+ lines.push("");
479
+ lines.push(
480
+ "1. On GitHub: **Settings → Rules → Rulesets → New branch ruleset**.",
481
+ );
482
+ lines.push(`2. **Target branches** → include \`${opts.mainBranch}\`.`);
483
+ lines.push("3. **Branch rules** → enable **Require a pull request before merging**.");
484
+ lines.push(
485
+ `4. **Bypass list** → add the actors permitted to push directly (typically empty).`,
486
+ );
487
+ lines.push("5. **Enforcement status** → Active.");
488
+ lines.push(
489
+ `6. Optionally add a **Restrict pushes/updates** rule pinned to the \`${opts.devBranch}\` branch ` +
490
+ "as the only allowed source.",
491
+ );
492
+ lines.push("");
493
+ if (!opts.ghAvailable) {
494
+ lines.push(
495
+ "_Install the [`gh` CLI](https://cli.github.com/) and re-run `/supi:harness` to attempt the ruleset automatically._",
496
+ );
497
+ lines.push("");
498
+ }
499
+ }
500
+
501
+ lines.push(`## 3. Verification`);
502
+ lines.push("");
503
+ lines.push("Confirm the setup by opening a draft PR from `" + (opts.devBranch ?? "feature/test") + "` → `" + opts.mainBranch + "` and checking that:");
504
+ lines.push("");
505
+ lines.push("- CI runs the `Harness Quality` workflow.");
506
+ if (opts.enforceMainFromDevOnly && opts.devBranch) {
507
+ lines.push("- A separate PR from any other branch into `" + opts.mainBranch + "` fails the `verify-pr-source` check.");
508
+ }
509
+ lines.push("");
510
+ return lines.join("\n");
511
+ }
512
+
513
+ function describe(error: unknown): string {
514
+ return error instanceof Error ? error.message : String(error);
515
+ }