@dev-loops/core 1.0.2 → 1.0.3

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.
@@ -4,6 +4,7 @@ import { runChild as defaultRunChild } from "../cli/primitives.mjs";
4
4
  import { parseJsonText } from "./review-threads.mjs";
5
5
  import { parseRepoSlug } from "./repo-slug.mjs";
6
6
  import { guardCommentBodyNoIssuePrIds } from "./comment-id-guard.mjs";
7
+ import { assertGithubWriteStubbedInTestMode } from "./test-mode-write-guard.mjs";
7
8
 
8
9
  /**
9
10
  * Core `gh issue` operations, extracted from the thin CLI wrappers under
@@ -88,6 +89,7 @@ export async function resolveCreateBody(options) {
88
89
  }
89
90
 
90
91
  export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
92
+ assertGithubWriteStubbedInTestMode(run, "issue create", { env });
91
93
  const body = await resolveCreateBody(options);
92
94
  if (typeof body !== "string" || body.trim().length === 0) {
93
95
  const source = options.bodyFile !== undefined ? `--body-file ${options.bodyFile}` : "--body";
@@ -182,6 +184,7 @@ export function buildStateChangeArgs(options) {
182
184
  }
183
185
 
184
186
  export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
187
+ assertGithubWriteStubbedInTestMode(run, "issue edit/close", { env });
185
188
  const { args, edited } = await buildEditArgs(options);
186
189
  // Skip the edit call entirely when --state is the only change requested —
187
190
  // `gh issue edit` with no field flags errors ("no changed fields").
@@ -227,6 +230,7 @@ export async function resolveCommentBody(options) {
227
230
  }
228
231
 
229
232
  export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
233
+ assertGithubWriteStubbedInTestMode(run, "issue comment", { env });
230
234
  const body = await resolveCommentBody(options);
231
235
  // ISSUE/PR-ID GUARD (#1731): a generated comment body must never emit a raw
232
236
  // issue/PR id (fail-closed unless explicitly allowlisted). `allowedRefs` is
@@ -83,10 +83,33 @@ export function dedupeRepoSlugOptions(options) {
83
83
  }
84
84
  return uniqueOptions;
85
85
  }
86
+ // Parses a git remote URL into an <owner/name> slug, but only for github.com
87
+ // remotes. The URI form (scheme://[user@]host[:port]/path) is checked before
88
+ // the scp form (host:path), because the scp regex also matches URIs like
89
+ // https://... — checking scp first would misparse the host out of the scheme.
90
+ function parseGitHubRemoteSlug(url) {
91
+ let host, path;
92
+ const uri = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/);
93
+ const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(.+)$/);
94
+ if (uri) {
95
+ host = uri[1];
96
+ path = uri[2];
97
+ } else if (scp) {
98
+ host = scp[1];
99
+ path = scp[2];
100
+ } else {
101
+ return null;
102
+ }
103
+ if (host.toLowerCase() !== "github.com") return null;
104
+ const seg = path.match(/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
105
+ if (!seg) return null;
106
+ return `${seg[1]}/${seg[2]}`;
107
+ }
108
+
86
109
  /**
87
110
  * Auto-detect <owner/name> from `git remote get-url origin`.
88
111
  * Returns the slug string on success, or null when detection fails
89
- * (no origin remote, not a git repo, or unparseable URL).
112
+ * (no origin remote, not a git repo, non-github.com host, or unparseable URL).
90
113
  * Does NOT throw — callers should add their own context-specific error messages.
91
114
  */
92
115
  export function detectRepoSlug(cwd) {
@@ -96,9 +119,7 @@ export function detectRepoSlug(cwd) {
96
119
  encoding: "utf8",
97
120
  stdio: ["ignore", "pipe", "pipe"],
98
121
  }).trim();
99
- const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
100
- if (!match) return null;
101
- return `${match[1]}/${match[2]}`;
122
+ return parseGitHubRemoteSlug(url);
102
123
  } catch {
103
124
  return null;
104
125
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Fail-closed test-mode guard for GitHub WRITE helpers.
3
+ *
4
+ * A GitHub write helper (issue/PR create/edit/close/comment/merge) must never
5
+ * reach a live GitHub write path from a test. The real incident: an unstubbed
6
+ * `bun run verify` drove a dedup test's followUpDraft through
7
+ * applyFollowUpIssues -> ensureFollowUpIssue -> createIssue and FILED A REAL
8
+ * ISSUE against the repo. The DI stub seam existed; the test simply did not
9
+ * thread it, and nothing failed closed to stop the live write.
10
+ *
11
+ * This guard is TEST-MODE ONLY. In production (`NODE_ENV !== "test"`) it is a
12
+ * no-op — it never changes production write behavior (non-goal: no production
13
+ * behavior change; no general network sandbox). It only asserts, when a test is
14
+ * running, that the write is stubbed by one of the two sanctioned seams:
15
+ *
16
+ * Test mode is derived from the EXECUTING PROCESS's own env (`process.env`),
17
+ * never from the write-target `env` a caller passes in. A caller can build a
18
+ * sparse `env` (e.g. `{ GH_TOKEN }` with no `NODE_ENV`) to shape the `gh`
19
+ * child's environment; if that sparse env decided test mode, it could turn
20
+ * the guard off from under a test simply by omitting `NODE_ENV`. Reading the
21
+ * real process env instead makes that impossible: a caller-supplied env can
22
+ * never disable the guard.
23
+ *
24
+ * (a) In-process DI seam: the helper's `run`/`runChild` was replaced with a
25
+ * stub (e.g. `makeGhMock`), so it is no longer the live child-exec seam.
26
+ * (b) Process-boundary seam: a subprocess test installed a fake `gh` on PATH
27
+ * (`writeGhStub`) and attests it via `DEV_LOOPS_GH_STUB` in the child env.
28
+ *
29
+ * Anything else in test mode fails closed at the call site, before any network
30
+ * call, with an actionable error naming both seams.
31
+ */
32
+
33
+ import { runChild as liveRunChild } from "../cli/primitives.mjs";
34
+
35
+ /**
36
+ * Env var a process-boundary gh stub sets to attest that `gh` itself is a stub
37
+ * (set automatically by the `writeGhStub` test helper). Only consulted in test
38
+ * mode, so it is not a production escape hatch.
39
+ */
40
+ export const GH_STUB_ATTESTATION_ENV = "DEV_LOOPS_GH_STUB";
41
+
42
+ /**
43
+ * True when `run` is the live child-exec seam — i.e. NO in-process DI stub was
44
+ * injected. `null`/`undefined` (a helper with no run seam, e.g. the spawn-based
45
+ * create-pr path) is treated as live too, so it fails closed rather than open.
46
+ * @param {unknown} run
47
+ */
48
+ export function isLiveExecutor(run) {
49
+ return run == null || run === liveRunChild;
50
+ }
51
+
52
+ /**
53
+ * Assert a GitHub write is stubbed when running under a test. No-op unless the
54
+ * EXECUTING PROCESS is in test mode (`processEnv.NODE_ENV === "test"`) — never
55
+ * decided by the caller-supplied write-target `env`, so a sparse `env` (e.g.
56
+ * missing `NODE_ENV`) cannot bypass the guard. Throws (code
57
+ * `GH_WRITE_UNSTUBBED_IN_TEST`) when the write would reach the live path with
58
+ * neither sanctioned stub seam present.
59
+ *
60
+ * @param {unknown} run - the helper's `run`/`runChild` seam (omit for spawn-only
61
+ * helpers with no in-process seam).
62
+ * @param {string} op - human-readable op name, e.g. `"issue create"`.
63
+ * @param {{ env?: NodeJS.ProcessEnv, processEnv?: NodeJS.ProcessEnv }} [opts]
64
+ * `env` is the write-target env (consulted only for the stub attestation);
65
+ * `processEnv` (defaults to `process.env`, injectable for tests) is the
66
+ * real executing process's env and is the sole source of test-mode.
67
+ */
68
+ export function assertGithubWriteStubbedInTestMode(run, op, { env = process.env, processEnv = process.env } = {}) {
69
+ if (processEnv?.NODE_ENV !== "test") return; // production / non-test: never guards
70
+ if (!isLiveExecutor(run)) return; // (a) in-process DI stub injected
71
+ if (processEnv?.[GH_STUB_ATTESTATION_ENV] || env?.[GH_STUB_ATTESTATION_ENV]) return; // (b) process-boundary gh stub
72
+ throw Object.assign(
73
+ new Error(
74
+ `GitHub write helper "${op}" was called in test mode without an injected stub — ` +
75
+ `refusing to reach a live GitHub write path (a test must never mutate the real repo). ` +
76
+ `Inject the in-process DI seam (pass { run } / { runChild }, e.g. makeGhMock) or stub gh ` +
77
+ `at the process boundary (writeGhStub, which sets ${GH_STUB_ATTESTATION_ENV}). See issue 2216.`,
78
+ ),
79
+ { code: "GH_WRITE_UNSTUBBED_IN_TEST" },
80
+ );
81
+ }
@@ -8,9 +8,65 @@
8
8
  * Pure and side-effect free.
9
9
  */
10
10
 
11
- /** The repository these gate guards apply to. */
11
+ /**
12
+ * The dev-loops repo itself. Retained ONLY for the Pi extension's
13
+ * (`extension/post-merge-update.ts`) dev-loops-repo SELF-UPDATE scoping (`markPendingUpdate` /
14
+ * `queueIfEligible`), which is legitimately anchored to this one repo, not to whatever repo the
15
+ * harness happens to run in. Both harness guard suites — the Claude PreToolUse Bash gate
16
+ * (`decideBashGate` in `hook-decisions.mjs`) and the Pi extension's `gh pr ready`/`gh pr merge`
17
+ * guards (`post-merge-update.ts`) — now resolve the managed repo dynamically via
18
+ * `deriveInManagedRepo` below instead of comparing against this hardcoded slug, so every guard
19
+ * also applies in a dev-loops-managed consumer repo.
20
+ */
12
21
  export const TARGET_REPO_SLUG = "mfittko/dev-loops";
13
22
 
23
+ /** `.devloops` config file extensions checked (in order) to decide whether a repo root is
24
+ * dev-loops-managed. Shared by every harness that resolves `inManagedContext` from the
25
+ * filesystem (`fs.existsSync(path.join(repoRoot, \`.devloops${ext}\`))`). */
26
+ export const DEVLOOPS_CONFIG_VARIANTS = ["", ".yaml", ".yml", ".json"];
27
+
28
+ /**
29
+ * Whether the current repo counts as "managed" for guard-gating purposes: inside a
30
+ * dev-loops-managed context (a `.devloops` config exists at its root) AND, when the managed
31
+ * repo's identity resolves, the cwd repo IS that managed repo. FAIL CLOSED: inside a managed
32
+ * context whose identity can't be resolved (`managedRepoSlug` null), the guard suite still
33
+ * applies rather than silently allowing everything.
34
+ * @param {Object} [params]
35
+ * @param {boolean} [params.inManagedContext] - Whether a `.devloops` config exists at the repo root.
36
+ * @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the managed repo, or null.
37
+ * @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo, or null.
38
+ * @returns {boolean}
39
+ */
40
+ export function deriveInManagedRepo({ inManagedContext = false, managedRepoSlug = null, repoSlug = null } = {}) {
41
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
42
+ const cwdSlug = (repoSlug ?? "").trim().toLowerCase() || null;
43
+ return Boolean(inManagedContext) && (managedSlug === null || cwdSlug === managedSlug);
44
+ }
45
+
46
+ /**
47
+ * Whether an explicit `--repo`/`-R` (or `GH_REPO=`) target is PROVABLY a different repo than the
48
+ * managed one — both slugs must resolve and differ. An unresolvable managed slug never proves
49
+ * foreign-ness (fail closed: the guard stays active rather than waving an explicit flag through).
50
+ * @param {string|null} explicitRepo
51
+ * @param {string|null} managedRepoSlug
52
+ * @returns {boolean}
53
+ */
54
+ export function explicitRepoProvenForeign(explicitRepo, managedRepoSlug) {
55
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
56
+ const explicit = (explicitRepo ?? "").trim().toLowerCase() || null;
57
+ if (managedSlug === null || explicit === null) {
58
+ return false;
59
+ }
60
+ // Both sides must be clean owner/name identities before we can prove foreign: a
61
+ // managed slug that bypassed the normalizer (e.g. a hostile `acme/widgets;id` test
62
+ // double) can't be trusted to prove anything about the explicit `--repo` — fail
63
+ // closed (the managed-repo guard still applies) rather than wave it through.
64
+ if (!isCleanRepoSlug(managedSlug) || !isCleanRepoSlug(explicit)) {
65
+ return false;
66
+ }
67
+ return explicit !== managedSlug;
68
+ }
69
+
14
70
  /** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
15
71
  export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
16
72
 
@@ -21,7 +77,7 @@ export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
21
77
  * strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
22
78
  * the gate. Used by all segment-splitting sites (DRY).
23
79
  */
24
- const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
80
+ const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||&|\n|\r)\s*/;
25
81
 
26
82
  /**
27
83
  * Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
@@ -67,8 +123,32 @@ export function trimToNull(value) {
67
123
  return trimmed ? trimmed : null;
68
124
  }
69
125
 
126
+ /**
127
+ * Strict GitHub owner/name identity shape: each of the two path segments is
128
+ * `[A-Za-z0-9._-]+` — the character set GitHub itself allows in an owner or repo name. A slug
129
+ * outside this shape (a shell metacharacter, whitespace, path traversal, or an extra `/` segment)
130
+ * can never be a real GitHub identity.
131
+ */
132
+ const CLEAN_REPO_SLUG_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
133
+
134
+ /**
135
+ * Whether `slug` is a clean `owner/name` GitHub identity (see `CLEAN_REPO_SLUG_RE`). Exported so a
136
+ * sink that interpolates a repo slug into a shell command string (e.g. the Pi extension's
137
+ * `gateCommand` in `extension/post-merge-update.ts`) can defense-in-depth guard the interpolation,
138
+ * on top of `normalizeGitHubRepoSlug` already enforcing this shape at the source.
139
+ * @param {string|null|undefined} slug @returns {boolean}
140
+ */
141
+ export function isCleanRepoSlug(slug) {
142
+ return typeof slug === "string" && CLEAN_REPO_SLUG_RE.test(slug);
143
+ }
144
+
70
145
  /**
71
146
  * Normalize a git remote URL into an `owner/name` slug (lowercased), or null.
147
+ * A hostile remote (e.g. `git@github.com:acme/widgets;id`) never yields a slug carrying the
148
+ * injected metacharacters — the extracted candidate must match `CLEAN_REPO_SLUG_RE` or this
149
+ * returns null instead, so every caller (both harnesses' managed-repo scope checks, and any sink
150
+ * that interpolates the slug into a shell command) sees either a real GitHub identity or null,
151
+ * never shell-metacharacter-bearing text.
72
152
  * @param {string} remoteUrl
73
153
  * @returns {string|null}
74
154
  */
@@ -91,7 +171,8 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
91
171
  if (!match) {
92
172
  continue;
93
173
  }
94
- return trimToNull(match[1])?.toLowerCase() ?? null;
174
+ const slug = trimToNull(match[1])?.toLowerCase() ?? null;
175
+ return isCleanRepoSlug(slug) ? slug : null;
95
176
  }
96
177
 
97
178
  return null;
@@ -486,6 +567,25 @@ export function extractRepoFlagsFromGhPrCreateSegments(command) {
486
567
  return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
487
568
  }
488
569
 
570
+ /**
571
+ * Return `{ segment, explicitRepo }` for every `gh pr merge` segment (ignoring --help/-h) —
572
+ * PreToolUse gate scope check use only, so a proven-foreign leading segment can't shield a later
573
+ * managed one. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
574
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
575
+ */
576
+ export function extractRepoFlagsFromGhPrMergeSegments(command) {
577
+ return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "merge");
578
+ }
579
+
580
+ /**
581
+ * Return `{ segment, explicitRepo }` for every `gh pr ready` segment (ignoring --help/-h) —
582
+ * PreToolUse gate scope check use only. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
583
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
584
+ */
585
+ export function extractRepoFlagsFromGhPrReadySegments(command) {
586
+ return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "ready");
587
+ }
588
+
489
589
  /** @param {string} command @returns {number|null} */
490
590
  export function extractPrNumberFromGhPrMerge(command) {
491
591
  return extractPrNumberFromGhPrVerb(command, "merge");
@@ -574,13 +674,22 @@ export function extractGhApiEndpointSegments(command) {
574
674
  return out;
575
675
  }
576
676
 
577
- /** The `gh api` segments whose endpoint is the target repo's URL path. Matches the absolute
578
- * slug-embedded form (`repos/mfittko/dev-loops/...`) and the bare relative form (`issues/...`),
579
- * which gh api resolves against the cwd repo — the decideBashGate call site gates the relative form
580
- * on `inTargetRepo`. */
581
- function targetGhApiPathRegex(suffix) {
582
- const slug = TARGET_REPO_SLUG.replace("/", "\\/");
583
- return new RegExp(`(?:repos/${slug}/|^)${suffix}`);
677
+ /** The `gh api` segments whose endpoint is the dev-loops-managed repo's URL path. Matches the
678
+ * absolute slug-embedded form (`repos/<managedSlug>/...`) when `managedSlug` resolves, plus the
679
+ * bare relative form (`issues/...`), which gh api resolves against the cwd repo — the
680
+ * decideBashGate call site gates the relative form on `inManagedRepo`. When `managedSlug` is null
681
+ * (the managed repo's identity could not be resolved in a managed context — AC4's fail-closed
682
+ * case), the absolute arm matches ANY owner/repo rather than a specific slug: identity is unknown,
683
+ * so an absolute write must be denied regardless of which repo it targets, not waved through for
684
+ * lack of a slug to compare against. The absolute arm fully regex-escapes a resolved slug (a `.`
685
+ * in a legitimate repo name must match literally, not as a wildcard) and matches
686
+ * case-insensitively (GitHub repo identity is case-insensitive). */
687
+ function managedGhApiPathRegex(suffix, managedSlug) {
688
+ if (!managedSlug) {
689
+ return new RegExp(`(?:repos/[^/]+/[^/]+/|^)${suffix}`, "i");
690
+ }
691
+ const slug = managedSlug.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
692
+ return new RegExp(`(?:repos/${slug}/|^)${suffix}`, "i");
584
693
  }
585
694
 
586
695
  /** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
@@ -617,15 +726,18 @@ function ghApiSegmentHasWriteMethod(segment) {
617
726
  }
618
727
 
619
728
  /**
620
- * SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the target
621
- * repo — the ad-hoc sub-issue mutation that must flow through the sanctioned `manage-sub-issues`
622
- * wrapper instead. Actor-independent (the issue's decided policy): the main agent gets no reserved
623
- * direct path to sub-issue writes. Anchored on the target repo's URL path segment AND an explicit write
624
- * method, so a `gh api` read or another repo's `sub_issues` write passes through (no false deny).
625
- * @param {string} command @returns {boolean}
729
+ * SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
730
+ * dev-loops-managed repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
731
+ * `manage-sub-issues` wrapper instead. Actor-independent (the issue's decided policy): the main
732
+ * agent gets no reserved direct path to sub-issue writes. Anchored on the managed repo's URL path
733
+ * segment AND an explicit write method, so a `gh api` read or another repo's `sub_issues` write
734
+ * passes through (no false deny).
735
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
736
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
737
+ * @returns {boolean}
626
738
  */
627
- export function commandContainsSubIssueAdHocBypass(command) {
628
- const re = targetGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`);
739
+ export function commandContainsSubIssueAdHocBypass(command, managedSlug = null) {
740
+ const re = managedGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`, managedSlug);
629
741
  return extractGhApiEndpointSegments(command).some(
630
742
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
631
743
  );
@@ -633,12 +745,15 @@ export function commandContainsSubIssueAdHocBypass(command) {
633
745
 
634
746
  /**
635
747
  * COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (REST half): raw `gh api` POST to
636
- * `.../pulls/<n>/comments/<m>/replies` on the target repo — the ad-hoc thread reply that must flow
637
- * through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved direct reply path.
638
- * @param {string} command @returns {boolean}
748
+ * `.../pulls/<n>/comments/<m>/replies` on the dev-loops-managed repo — the ad-hoc thread reply
749
+ * that must flow through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
750
+ * direct reply path.
751
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
752
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
753
+ * @returns {boolean}
639
754
  */
640
- export function commandContainsReplyResolveBypass(command) {
641
- const re = targetGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`);
755
+ export function commandContainsReplyResolveBypass(command, managedSlug = null) {
756
+ const re = managedGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`, managedSlug);
642
757
  return extractGhApiEndpointSegments(command).some(
643
758
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
644
759
  );
@@ -657,12 +772,14 @@ export function commandContainsGraphqlResolveReviewThread(command) {
657
772
 
658
773
  /**
659
774
  * COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (REST half): raw `gh api` write to
660
- * `.../pulls/<n>/requested_reviewers` on the target repo — the ad-hoc Copilot review request that must
661
- * flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
662
- * @param {string} command @returns {boolean}
775
+ * `.../pulls/<n>/requested_reviewers` on the dev-loops-managed repo — the ad-hoc Copilot review
776
+ * request that must flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
777
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
778
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
779
+ * @returns {boolean}
663
780
  */
664
- export function commandContainsCopilotRequestBypass(command) {
665
- const re = targetGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`);
781
+ export function commandContainsCopilotRequestBypass(command, managedSlug = null) {
782
+ const re = managedGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`, managedSlug);
666
783
  return extractGhApiEndpointSegments(command).some(
667
784
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
668
785
  );
@@ -0,0 +1,152 @@
1
+ /**
2
+ * child-launch-bound.mjs — dev-loop execution-cap fail-fast child-launch
3
+ * bound (epic decided approach): a bounded, deterministic primitive that
4
+ * attempts a child launch AT MOST ONCE, queries the harness-supported-model
5
+ * inventory AT MOST ONCE and ONLY after a failed launch, and always produces
6
+ * a durable blocker record on failure — never a retry, a silent model
7
+ * substitution, or a dispatch without the requested override.
8
+ *
9
+ * Pure and offline: launch/query behavior is fully caller-injected
10
+ * (attemptLaunch/querySupportedModels); this module never imports a runtime
11
+ * harness adapter, reads a file, or performs I/O of its own.
12
+ */
13
+
14
+ /**
15
+ * Dev-loop harnesses this bound recognizes; any other value fails closed.
16
+ * This primitive only carries the harness label through the request/blocker
17
+ * — it does not branch on it or depend on per-harness capability data (e.g.
18
+ * HARNESS_DEFAULT_CAPABILITIES), so it stays agnostic across pi, claude, and
19
+ * codex.
20
+ */
21
+ const HARNESS_VALUES = Object.freeze(["pi", "claude", "codex"]);
22
+
23
+ /** @param {unknown} value @returns {boolean} */
24
+ function isNonEmptyString(value) {
25
+ return typeof value === "string" && value.trim().length > 0;
26
+ }
27
+
28
+ /**
29
+ * Validate + normalize the child-launch request. Fails closed (TypeError) on
30
+ * any malformed/empty field, at the trust boundary of this function.
31
+ * @param {object} request
32
+ * @returns {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}}
33
+ */
34
+ function validateRequest(request) {
35
+ if (!request || typeof request !== "object") {
36
+ throw new TypeError("enforceChildLaunchBound requires a request object");
37
+ }
38
+ const { run, roleOrAngle, model, harness } = request;
39
+ if (!isNonEmptyString(run)) throw new TypeError("request.run must be a non-empty string");
40
+ if (!isNonEmptyString(roleOrAngle)) throw new TypeError("request.roleOrAngle must be a non-empty string");
41
+ if (!isNonEmptyString(model)) throw new TypeError("request.model must be a non-empty string");
42
+ if (!HARNESS_VALUES.includes(harness)) {
43
+ throw new TypeError(`request.harness must be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(harness)}`);
44
+ }
45
+ return { run: run.trim(), roleOrAngle: roleOrAngle.trim(), model: model.trim(), harness };
46
+ }
47
+
48
+ // Note: querySupportedModels may return { supported: [...] }, a bare
49
+ // array, or a Set — normalizing all three into one Set here keeps the
50
+ // caller-facing contract flexible without adding a second exported shape.
51
+ /** @param {{supported?: unknown}|unknown[]|Set<string>} result @returns {Set<string>} */
52
+ function toSupportedSet(result) {
53
+ const list = result && typeof result === "object" && !Array.isArray(result) && !(result instanceof Set)
54
+ ? result.supported
55
+ : result;
56
+ if (list instanceof Set) return list;
57
+ if (Array.isArray(list)) return new Set(list);
58
+ return new Set();
59
+ }
60
+
61
+ /**
62
+ * Enforce the bounded, deterministic child-launch fail-fast protocol.
63
+ *
64
+ * Scope: this bounds ONE invocation — one launch attempt plus at most one
65
+ * inventory query for that same call. It holds no state across calls, so
66
+ * cross-call terminality (never re-launching the same `(run, roleOrAngle,
67
+ * model)` after a prior call already returned a blocked verdict) is the
68
+ * caller's/coordinator's contract, not something a persisted token here
69
+ * enforces.
70
+ *
71
+ * Deadline contract: `deadlineMs` is a MEASUREMENT bound, not a per-call
72
+ * timeout. The function times the elapsed wall-clock span of the one launch
73
+ * attempt plus (on failure) the one inventory query, and reports
74
+ * `withinDeadline` fail-closed (`elapsedMs <= deadlineMs`) — it never cancels
75
+ * or races `attemptLaunch`/`querySupportedModels` against the clock. The
76
+ * <=60s guarantee holds only because each injected operation is a single
77
+ * bounded call, never a retry loop, inside this function; enforcing a hard
78
+ * per-operation timeout/cancellation on a slow `attemptLaunch` or
79
+ * `querySupportedModels` implementation is the caller's operation-budget
80
+ * responsibility and is intentionally out of scope for this pure primitive.
81
+ *
82
+ * @param {object} options
83
+ * @param {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}} options.request
84
+ * @param {(request:object)=>({ok:true,launch:*}|{ok:false,reason:string})} options.attemptLaunch
85
+ * Called AT MOST ONCE.
86
+ * @param {(request:object)=>({supported:string[]}|string[]|Set<string>)} options.querySupportedModels
87
+ * Called AT MOST ONCE, and only after a failed launch.
88
+ * @param {()=>number} [options.now] - injectable clock, default Date.now.
89
+ * @param {number} [options.deadlineMs] - measurement bound in ms, default 60000.
90
+ * @returns {object} `{ ok: true, launch, events, elapsedMs, withinDeadline }`
91
+ * on success, or the durable blocker `{ ok: false, verdict: "blocked",
92
+ * reason, launchFailureDetail, request, modelSupported, elapsedMs,
93
+ * withinDeadline, events }` on failure. `launchFailureDetail` is the failed
94
+ * `attemptLaunch` result verbatim (its own reason plus any error/message it
95
+ * carried), preserved alongside the normalized `reason`.
96
+ */
97
+ export function enforceChildLaunchBound({ request, attemptLaunch, querySupportedModels, now = () => Date.now(), deadlineMs = 60000 } = {}) {
98
+ const normalizedRequest = validateRequest(request);
99
+ if (typeof attemptLaunch !== "function") {
100
+ throw new TypeError("enforceChildLaunchBound requires attemptLaunch to be a function");
101
+ }
102
+ if (typeof querySupportedModels !== "function") {
103
+ throw new TypeError("enforceChildLaunchBound requires querySupportedModels to be a function");
104
+ }
105
+
106
+ const events = [];
107
+ const start = now();
108
+
109
+ // EXACTLY one launch attempt, ever — no retry, no model substitution.
110
+ const launchResult = attemptLaunch(normalizedRequest);
111
+ events.push({ type: "launch_attempt" });
112
+
113
+ if (launchResult && launchResult.ok === true) {
114
+ // Success: never query the inventory, never attempt a second launch.
115
+ const elapsedMs = now() - start;
116
+ return { ok: true, launch: launchResult.launch, events, elapsedMs, withinDeadline: elapsedMs <= deadlineMs };
117
+ }
118
+
119
+ // Launch failed: query the harness's supported-model inventory EXACTLY
120
+ // once, only now (never before a launch attempt, never more than once).
121
+ const inventory = querySupportedModels(normalizedRequest);
122
+ events.push({ type: "inventory_query" });
123
+ const modelSupported = toSupportedSet(inventory).has(normalizedRequest.model);
124
+
125
+ const launchReason = launchResult && typeof launchResult.reason === "string" ? launchResult.reason : null;
126
+ const reason = launchReason === "unresolvable"
127
+ ? "child_model_unresolvable"
128
+ : !modelSupported
129
+ ? "child_model_unsupported"
130
+ : "child_launch_failed_model_supported";
131
+ // Verbatim passthrough of the failed launch result (its own reason plus any
132
+ // error/message/detail it carried) — never inspected or altered — so a
133
+ // caller can see the exact failure detail alongside the normalized reason.
134
+ const launchFailureDetail = launchResult && typeof launchResult === "object" ? launchResult : null;
135
+
136
+ const elapsedMs = now() - start;
137
+ // Measurement, not enforcement: a slow adapter still returns the blocker
138
+ // (fail-closed) rather than being cancelled or retried against the clock.
139
+ const withinDeadline = elapsedMs <= deadlineMs;
140
+
141
+ return {
142
+ ok: false,
143
+ verdict: "blocked",
144
+ reason,
145
+ launchFailureDetail,
146
+ request: normalizedRequest,
147
+ modelSupported,
148
+ elapsedMs,
149
+ withinDeadline,
150
+ events,
151
+ };
152
+ }
@@ -197,6 +197,7 @@ export function buildSnapshotFromPrFacts({
197
197
  lastCopilotRoundMaxSignal = null,
198
198
  failureDetails = [],
199
199
  excludedFailureDetails,
200
+ copilotBodyFeedbackUnresolved = false,
200
201
  }) {
201
202
  const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
202
203
  const prMerged = prState === "MERGED";
@@ -205,6 +206,9 @@ export function buildSnapshotFromPrFacts({
205
206
  // that never threads an explicit ciStatus (e.g. gate-coordination detection)
206
207
  // still never treats it as a blocking CI failure.
207
208
  const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
209
+ const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
210
+ ? prData.headRefOid.trim()
211
+ : null;
208
212
 
209
213
  return normalizeSnapshot({
210
214
  prExists: true,
@@ -212,6 +216,7 @@ export function buildSnapshotFromPrFacts({
212
216
  prDraft: Boolean(prData?.isDraft),
213
217
  prMerged,
214
218
  prClosed,
219
+ currentHeadSha,
215
220
  copilotReviewRequestStatus,
216
221
  copilotReviewPresent,
217
222
  copilotReviewOnCurrentHead,
@@ -222,6 +227,7 @@ export function buildSnapshotFromPrFacts({
222
227
  ciStatus: ciStatus ?? rollupDerivation.status,
223
228
  failureDetails,
224
229
  excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
230
+ copilotBodyFeedbackUnresolved,
225
231
  });
226
232
  }
227
233
 
@@ -269,6 +275,9 @@ export function normalizeSnapshot(raw) {
269
275
  prDraft: Boolean(raw.prDraft),
270
276
  prMerged: Boolean(raw.prMerged),
271
277
  prClosed: Boolean(raw.prClosed),
278
+ currentHeadSha: typeof raw.currentHeadSha === "string" && raw.currentHeadSha.trim().length > 0
279
+ ? raw.currentHeadSha.trim()
280
+ : null,
272
281
  copilotReviewRequestStatus: VALID_REVIEW_REQUEST_STATUSES.has(raw.copilotReviewRequestStatus)
273
282
  ? raw.copilotReviewRequestStatus
274
283
  : "none",
@@ -288,6 +297,7 @@ export function normalizeSnapshot(raw) {
288
297
  agentFixStatus: raw.agentFixStatus === "applied" ? "applied" : null,
289
298
  failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
290
299
  excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
300
+ copilotBodyFeedbackUnresolved: Boolean(raw.copilotBodyFeedbackUnresolved),
291
301
  };
292
302
  }
293
303
 
@@ -397,7 +407,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
397
407
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
398
408
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
399
409
  const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
400
- const cleanThreads = s.unresolvedThreadCount === 0;
410
+ const cleanThreads = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
401
411
  if (cleanThreads && ciClean) {
402
412
  state = STATE.ROUND_CAP_CLEAN_FALLBACK;
403
413
  } else if (!reviewInFlight) {
@@ -406,11 +416,16 @@ export function interpretLoopState(snapshot, refinementConfig) {
406
416
  // Not clean WITH an in-flight request: leave state undecided for the routing below.
407
417
  }
408
418
 
419
+ // Unresolved feedback includes both inline review threads and a Copilot
420
+ // review-body finding on the current head (a body-only "Changes recommended"
421
+ // review with zero inline threads must not read as clean).
422
+ const unresolvedFeedback = s.unresolvedThreadCount > 0 || s.copilotBodyFeedbackUnresolved;
423
+
409
424
  if (state === undefined) {
410
- if (s.unresolvedThreadCount > 0 && s.agentFixStatus === "applied") {
425
+ if (unresolvedFeedback && s.agentFixStatus === "applied") {
411
426
  // Agent has fixed the code; threads still need reply/resolve on GitHub
412
427
  state = STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE;
413
- } else if (s.unresolvedThreadCount > 0) {
428
+ } else if (unresolvedFeedback) {
414
429
  // Unresolved feedback exists — do not wait; enter fix/reply-resolve handling
415
430
  state = STATE.UNRESOLVED_FEEDBACK_PRESENT;
416
431
  } else if (s.copilotReviewRequestStatus === "requested" || s.copilotReviewRequestStatus === "already-requested") {
@@ -459,7 +474,8 @@ export function interpretLoopState(snapshot, refinementConfig) {
459
474
  const sameHeadCleanConverged = state === STATE.READY_TO_REREQUEST_REVIEW
460
475
  && s.copilotReviewOnCurrentHead
461
476
  && s.unresolvedThreadCount === 0
462
- && s.actionableThreadCount === 0;
477
+ && s.actionableThreadCount === 0
478
+ && !s.copilotBodyFeedbackUnresolved;
463
479
 
464
480
  let nextAction = NEXT_ACTIONS[state];
465
481
  if (sameHeadCleanConverged) {