@dev-loops/core 1.0.2-slim.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.2-slim.0",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -22,24 +22,31 @@
22
22
  "./debt/signal": "./src/debt/debt-signal.mjs",
23
23
  "./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
24
24
  "./github/comment-id-guard": "./src/github/comment-id-guard.mjs",
25
+ "./github/closing-ref-guard": "./src/github/closing-ref-guard.mjs",
25
26
  "./github/gh": "./src/github/gh.mjs",
26
27
  "./github/issue-ops": "./src/github/issue-ops.mjs",
27
28
  "./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
28
29
  "./github/repo-slug": "./src/github/repo-slug.mjs",
29
30
  "./github/review-threads": "./src/github/review-threads.mjs",
31
+ "./github/test-mode-write-guard": "./src/github/test-mode-write-guard.mjs",
30
32
  "./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
31
33
  "./loop/agent-stall": "./src/loop/agent-stall.mjs",
32
34
  "./loop/bash-command-classify": "./src/loop/bash-command-classify.mjs",
35
+ "./loop/child-launch-bound": "./src/loop/child-launch-bound.mjs",
36
+ "./loop/reviewer-unit-bound": "./src/loop/reviewer-unit-bound.mjs",
33
37
  "./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
34
38
  "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
35
39
  "./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
36
40
  "./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
41
+ "./loop/finding-cluster": "./src/loop/finding-cluster.mjs",
42
+ "./loop/fixer-disposition": "./src/loop/fixer-disposition.mjs",
37
43
  "./loop/gate-carry-forward": "./src/loop/gate-carry-forward.mjs",
38
44
  "./loop/gate-evidence-reconcile": "./src/loop/gate-evidence-reconcile.mjs",
39
45
  "./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
40
46
  "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
41
47
  "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
42
48
  "./loop/markdown-sections": "./src/loop/markdown-sections.mjs",
49
+ "./loop/merge-approval": "./src/loop/merge-approval.mjs",
43
50
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
44
51
  "./loop/phase-files": "./src/loop/phase-files.mjs",
45
52
  "./loop/policy-constants": "./src/loop/policy-constants.mjs",
@@ -62,9 +69,11 @@
62
69
  "./loop/queue-state": "./src/loop/queue-state.mjs",
63
70
  "./loop/size-budget-merge-gate": "./src/loop/size-budget-merge-gate.mjs",
64
71
  "./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
72
+ "./loop/execution-record": "./src/loop/execution-record.mjs",
65
73
  "./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
66
74
  "./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
67
75
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
76
+ "./loop/role-budget-bound": "./src/loop/role-budget-bound.mjs",
68
77
  "./loop/run-context": "./src/loop/run-context.mjs",
69
78
  "./loop/run-inspection": "./src/loop/run-inspection.mjs",
70
79
  "./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
@@ -74,6 +83,7 @@
74
83
  "./loop/steering": "./src/loop/steering.mjs",
75
84
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
76
85
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
86
+ "./loop/watcher-exclusivity": "./src/loop/watcher-exclusivity.mjs",
77
87
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
78
88
  "./loop/ui-review-provision": "./src/loop/ui-review-provision.mjs",
79
89
  "./loop/ui-review-drive": "./src/loop/ui-review-drive.mjs",
@@ -114,6 +114,67 @@ export function rewriteGeneratedRepoDocLinks(body) {
114
114
  return String(body).replace(/(\]\(<?)(\.\.\/docs\/)/g, "$1../$2");
115
115
  }
116
116
 
117
+ /**
118
+ * Name of the resolver launcher committed at `.claude/bin/dev-loops-run`. It self-locates a
119
+ * source checkout by walking up from `process.cwd()` (unconditional priority), else by walking
120
+ * up from the launcher's own real binary location, else falls back to the plugin's auto-installed
121
+ * `dev-loops` package, and hard-stops (never falls back to raw `gh`) when none resolve.
122
+ */
123
+ export const WRAPPER_LAUNCHER = "dev-loops-run";
124
+
125
+ /**
126
+ * CLI namespaces the routed `dev-loops <ns> <sub>` form recognizes — the real top-level keys of
127
+ * `SUBCOMMAND_ROUTES` in `cli/index.mjs` (locked by
128
+ * `test/contracts/claude-no-bare-invocation.test.mjs`'s recurrence guard, which reads those keys
129
+ * directly). `release` and `security` are NOT CLI namespaces (no such routes exist); `issue` and
130
+ * `inspect` are and were previously missing here, which left e.g. `dev-loops issue edit …` bare
131
+ * and unrouted in the generated tree.
132
+ */
133
+ export const WRAPPER_NS = "gate|loop|pr|issue|queue|project|inspect|refine";
134
+
135
+ /**
136
+ * Regex source (no flags) matching a bare `node scripts/<dir>/…/<file>.mjs` invocation, at ANY
137
+ * subdirectory depth (one-or-more `dir/` segments), not just one subdirectory level, so a
138
+ * nested wrapper is caught too. The `node`→`scripts/` gap matches ANY whitespace (`\s+`),
139
+ * including a source line-wrap (`node` at a line end, `scripts/…mjs` beginning the next), so a
140
+ * prose-wrapped invocation is routed and the guard test that shares this source catches it too —
141
+ * a single-space-only pattern silently left wrapped invocations bare and passed the guard falsely.
142
+ * The `.mjs` path anchor keeps false positives impossible. Capture group 1 is the matched
143
+ * `scripts/…mjs` path. Exported so the no-bare-invocation guard test can build the identical regex
144
+ * rather than re-deriving it (single source of truth).
145
+ */
146
+ export const BARE_NODE_SCRIPTS_SOURCE = String.raw`\bnode\s+(scripts\/(?:[a-z0-9-]+\/)+[A-Za-z0-9._-]+\.mjs)`;
147
+
148
+ /**
149
+ * Regex source (no flags) matching a bare, unrouted `dev-loops <namespace> <sub>` invocation.
150
+ * Requires an immediate lowercase-starting subcommand (lookahead) so prose (`dev-loops gate.`,
151
+ * `dev-loops gate — …`, `` `dev-loops queue` ``) and the already-pinned `npx dev-loops@<version>`
152
+ * CLI form never match. Exported so the no-bare-invocation guard test can build the identical
153
+ * regex from the same `WRAPPER_NS` rather than re-deriving it (single source of truth).
154
+ */
155
+ export const BARE_DEV_LOOPS_NS_SOURCE = String.raw`\bdev-loops (${WRAPPER_NS}) (?=[a-z])`;
156
+
157
+ /**
158
+ * Route real wrapper invocations in a generated body through the resolver launcher so a
159
+ * plugin-only install (no `scripts/`, no `node_modules`) still resolves every wrapper.
160
+ * Two disjoint forms; args are preserved byte-for-byte:
161
+ * node scripts/<dir>/<file>.mjs … → <launcher> scripts/<dir>/<file>.mjs …
162
+ * dev-loops <namespace> <sub> … → <launcher> cli/index.mjs <namespace> <sub> …
163
+ * The namespace form requires an immediate lowercase-starting subcommand (lookahead), so prose
164
+ * (`dev-loops gate.`, `dev-loops gate — …`, `` `dev-loops queue` ``) and the already-pinned
165
+ * `npx dev-loops@<version>` CLI form are never touched. Idempotent: the rewritten output never
166
+ * re-matches (the launcher name is followed by a path/`cli/index.mjs`, not `scripts/` or a bare
167
+ * namespace token).
168
+ * @param {string} body
169
+ * @param {string} [launcher]
170
+ * @returns {string}
171
+ */
172
+ export function rewriteWrapperInvocation(body, launcher = WRAPPER_LAUNCHER) {
173
+ return String(body)
174
+ .replace(new RegExp(BARE_NODE_SCRIPTS_SOURCE, "g"), `${launcher} $1`)
175
+ .replace(new RegExp(BARE_DEV_LOOPS_NS_SOURCE, "g"), `${launcher} cli/index.mjs $1 `);
176
+ }
177
+
117
178
  /**
118
179
  * Map a single Pi tool name to its Claude tool name(s).
119
180
  * @param {string} name
@@ -184,7 +245,7 @@ function normalizeToolList(value) {
184
245
  */
185
246
  export function transformAgent({ source, raw, version = "latest", config = {} }) {
186
247
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
187
- const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
248
+ const body = rewriteWrapperInvocation(rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version)));
188
249
  const tools = mapTools(normalizeToolList(frontmatter.tools));
189
250
  const model = resolveRoleModel(config, { role: String(frontmatter.name ?? ""), harness: "claude" });
190
251
 
@@ -219,7 +280,7 @@ export function transformAgent({ source, raw, version = "latest", config = {} })
219
280
  */
220
281
  export function transformCommand({ source, raw, version = "latest" }) {
221
282
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
222
- const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
283
+ const body = rewriteWrapperInvocation(rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version)));
223
284
 
224
285
  const lines = ["---"];
225
286
  if (frontmatter.description != null) {
@@ -241,7 +302,7 @@ export function transformCommand({ source, raw, version = "latest" }) {
241
302
  */
242
303
  export function transformSkill({ source, raw, version = "latest" }) {
243
304
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
244
- const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
305
+ const body = rewriteWrapperInvocation(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
245
306
  const tools = mapTools(normalizeToolList(frontmatter["allowed-tools"]));
246
307
 
247
308
  const lines = ["---"];
@@ -12,14 +12,15 @@
12
12
  import { resolveRunId } from "../loop/run-context.mjs";
13
13
  import { isUnderWorktreePath } from "../loop/worktree-guard.mjs";
14
14
  import {
15
+ deriveInManagedRepo,
15
16
  commandContainsGhPrReady,
16
17
  commandContainsGhPrMerge,
17
18
  commandContainsGhPrCreate,
18
19
  extractPrNumberFromGhPrReadyAnywhere,
19
- extractRepoFlagFromGhPrReadyAnywhere,
20
20
  extractPrNumberFromGhPrMergeAnywhere,
21
- extractRepoFlagFromGhPrMergeAnywhere,
22
21
  extractRepoFlagsFromGhPrCreateSegments,
22
+ extractRepoFlagsFromGhPrMergeSegments,
23
+ extractRepoFlagsFromGhPrReadySegments,
23
24
  commandContainsRawExternalWrite,
24
25
  extractRepoFlagsFromExternalWriteSegments,
25
26
  commandContainsGitStash,
@@ -31,7 +32,6 @@ import {
31
32
  commandContainsCopilotSummonComment,
32
33
  commandContainsDetachedWaitTool,
33
34
  commandContainsInlineInterpreter,
34
- TARGET_REPO_SLUG,
35
35
  } from "../loop/bash-command-classify.mjs";
36
36
 
37
37
  /**
@@ -79,6 +79,11 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
79
79
  * @param {Object} params
80
80
  * @param {string} params.command - The Bash command string.
81
81
  * @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
82
+ * @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the dev-loops-managed
83
+ * repo (the repo `inManagedContext` refers to), or null when the identity can't be resolved.
84
+ * @param {boolean} [params.inManagedContext] - Whether the current repo is dev-loops-managed (a
85
+ * `.devloops` config exists at its root). Replaces the old hardcoded-slug `TARGET_REPO_SLUG`
86
+ * comparison so the guard suite applies in any managed consumer repo, not only mfittko/dev-loops.
82
87
  * @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
83
88
  * @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
84
89
  * @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
@@ -89,19 +94,33 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
89
94
  * subagent-only deny would enforce nothing.
90
95
  * @returns {HookDecision}
91
96
  */
92
- export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null, humanMergeOnly = false }) {
97
+ export function decideBashGate({
98
+ command,
99
+ repoSlug = null,
100
+ managedRepoSlug = null,
101
+ inManagedContext = false,
102
+ gatePassed = false,
103
+ gateError = null,
104
+ agentType = null,
105
+ humanMergeOnly = false,
106
+ }) {
93
107
  if (typeof command !== "string") {
94
108
  return ALLOW;
95
109
  }
96
110
  // Normalize (trim + case-fold) so a divergent slug (surrounding whitespace, casing) does not
97
- // silently fail OPEN and disable every guard that depends on inTargetRepo.
98
- const inTargetRepo = (repoSlug ?? "").trim().toLowerCase() === TARGET_REPO_SLUG.trim().toLowerCase();
111
+ // silently fail OPEN. A repo is dev-loops-managed when inManagedContext is true (a .devloops
112
+ // config exists at its root); the managed slug is that repo's resolved identity, which may be
113
+ // unresolvable (null). FAIL CLOSED: inside a managed context whose identity can't be resolved,
114
+ // every guard below still applies (inManagedRepo stays true) rather than silently allowing
115
+ // everything — an unresolvable identity must never disable the guard suite.
116
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
117
+ const inManagedRepo = deriveInManagedRepo({ inManagedContext, managedRepoSlug, repoSlug });
99
118
 
100
119
  // OPS-NO-INLINE-INTERPRETER: inline interpreters (`node -e`/`--eval`/`-p`, `python3 -c`,
101
120
  // heredocs fed to node/python) are barred actor-independently on the target repo — the rule bars
102
121
  // "Coordinator and agent flows"; sanctioned output parsing uses `--jq`/`--silent`, never an
103
122
  // inline interpreter.
104
- if (inTargetRepo && commandContainsInlineInterpreter(command)) {
123
+ if (inManagedRepo && commandContainsInlineInterpreter(command)) {
105
124
  return {
106
125
  decision: "deny",
107
126
  reason:
@@ -116,7 +135,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
116
135
  // form identifies the target repo; the bare relative form (`gh api issues/5/sub_issues`) resolves
117
136
  // against the cwd repo, so it is in scope only when running in the target repo (mirrors the
118
137
  // explicit-`--repo`/cwd-target posture).
119
- if (inTargetRepo && commandContainsSubIssueAdHocBypass(command)) {
138
+ if (inManagedRepo && commandContainsSubIssueAdHocBypass(command, managedSlug)) {
120
139
  return {
121
140
  decision: "deny",
122
141
  reason:
@@ -129,7 +148,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
129
148
  // to pulls/<n>/comments/<m>/replies, or a `gh api graphql` resolveReviewThread mutation (the Rest
130
149
  // path names the target repo; the graphql form has no path-host repo, so it is scoped to the cwd
131
150
  // repo). Actor-independent: reply through reply-resolve-review-thread(s).mjs.
132
- if (inTargetRepo && (commandContainsReplyResolveBypass(command) || commandContainsGraphqlResolveReviewThread(command))) {
151
+ if (inManagedRepo && (commandContainsReplyResolveBypass(command, managedSlug) || commandContainsGraphqlResolveReviewThread(command))) {
133
152
  return {
134
153
  decision: "deny",
135
154
  reason:
@@ -142,7 +161,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
142
161
  // COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY: ad-hoc Copilot review requests — raw `gh api` writes
143
162
  // to pulls/<n>/requested_reviewers, or a bare `/copilot` / `/copilot re-review` comment summon on the
144
163
  // target repo. Actor-independent: request Copilot via scripts/github/request-copilot-review.mjs.
145
- if (inTargetRepo && (commandContainsCopilotRequestBypass(command) || commandContainsCopilotSummonComment(command))) {
164
+ if (inManagedRepo && (commandContainsCopilotRequestBypass(command, managedSlug) || commandContainsCopilotSummonComment(command))) {
146
165
  return {
147
166
  decision: "deny",
148
167
  reason:
@@ -156,7 +175,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
156
175
  // `.git` directory — a stash from one worktree can pop into another's. Block it outright on the
157
176
  // target repo; see skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout for the
158
177
  // stash-free alternative (git diff / a patch file / a scratch checkout).
159
- if (commandContainsGitStash(command) && inTargetRepo) {
178
+ if (commandContainsGitStash(command) && inManagedRepo) {
160
179
  return {
161
180
  decision: "deny",
162
181
  reason:
@@ -169,14 +188,19 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
169
188
  // `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
170
189
  // sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected.
171
190
  if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
172
- const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
191
+ // The cwd repo IS the managed/target repo exactly when inManagedRepo.
192
+ const cwdTargets = inManagedRepo;
173
193
  // Scope PER segment, mirroring the `gh pr create` block: in scope when no explicit --repo and
174
- // cwd is the target, or an explicit --repo/-R equals the target. An explicit non-target --repo
175
- // passes through. DENY if ANY external-write segment is in scope.
194
+ // cwd is the target, or an explicit --repo/-R equals the managed slug. An explicit repo that is
195
+ // PROVEN foreign (managedSlug resolves and differs) passes through; otherwise (managedSlug
196
+ // unresolvable) we cannot prove the explicit repo is foreign, so a managed context fails closed
197
+ // (in scope). DENY if ANY external-write segment is in scope.
176
198
  const anyWriteInScope = extractRepoFlagsFromExternalWriteSegments(command).some((seg) =>
177
199
  seg.explicitRepo == null
178
200
  ? cwdTargets
179
- : seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
201
+ : managedSlug !== null
202
+ ? seg.explicitRepo.toLowerCase() === managedSlug
203
+ : inManagedContext,
180
204
  );
181
205
  if (anyWriteInScope) {
182
206
  return {
@@ -202,7 +226,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
202
226
  // refused actor-independently — the main agent is the actor that performs GitHub writes, so only an
203
227
  // actor-independent deny enforces the human-merge invariant (an agent-scoped deny would enforce
204
228
  // nothing on the main-agent write path).
205
- if (humanMergeOnly && isMerge && inTargetRepo) {
229
+ if (humanMergeOnly && isMerge && inManagedRepo) {
206
230
  return {
207
231
  decision: "deny",
208
232
  reason:
@@ -217,7 +241,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
217
241
  // rule is classified `agent` (behavioral guidance for the dev-loop driving agent); the main
218
242
  // agent/operator retains manual wait tooling. The main agent's own sanctioned wait path is still
219
243
  // the deterministic tools.
220
- if (typeof agentType === "string" && inTargetRepo && commandContainsDetachedWaitTool(command)) {
244
+ if (typeof agentType === "string" && inManagedRepo && commandContainsDetachedWaitTool(command)) {
221
245
  return {
222
246
  decision: "deny",
223
247
  reason:
@@ -234,16 +258,21 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
234
258
  // exists yet): PR creation must flow through the canonical wrapper, which always drafts and
235
259
  // self-assigns. This closes the draft-first hole where raw `gh pr create` opens a ready PR.
236
260
  if (isCreate) {
237
- const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
261
+ // The cwd repo IS the managed/target repo exactly when inManagedRepo.
262
+ const cwdTargets = inManagedRepo;
238
263
  // Evaluate scope PER create segment, not just the first: a create is in scope when it
239
264
  // explicitly targets the repo, or (with no explicit --repo) the cwd is the repo. An explicit
240
- // `--repo <target>` is denied regardless of cwd. DENY if ANY create segment is in
241
- // scope otherwise a leading out-of-scope create (`gh pr create --repo other/repo`) would
242
- // short-circuit and shield a later in-scope raw create (`&& gh pr create --fill`).
265
+ // `--repo <target>` is denied regardless of cwd unless it is PROVEN foreign (managedSlug
266
+ // resolves and differs); when managedSlug is unresolvable we cannot prove foreignness, so a
267
+ // managed context fails closed (in scope). DENY if ANY create segment is in scope — otherwise a
268
+ // leading out-of-scope create (`gh pr create --repo other/repo`) would short-circuit and shield
269
+ // a later in-scope raw create (`&& gh pr create --fill`).
243
270
  const anyCreateInScope = extractRepoFlagsFromGhPrCreateSegments(command).some((seg) =>
244
271
  seg.explicitRepo == null
245
272
  ? cwdTargets
246
- : seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
273
+ : managedSlug !== null
274
+ ? seg.explicitRepo.toLowerCase() === managedSlug
275
+ : inManagedContext,
247
276
  );
248
277
  if (anyCreateInScope) {
249
278
  return {
@@ -265,15 +294,28 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
265
294
  // When both verbs appear in a compound command, apply the stricter merge gate — if it passes,
266
295
  // the draft_gate (a subset of the pre-merge evidence check) is also satisfied.
267
296
  const verb = isMerge ? "gh pr merge" : "gh pr ready";
268
- // An explicit `--repo other/repo` that is not the target not our concern, pass through.
269
- const explicitRepo = isMerge
270
- ? extractRepoFlagFromGhPrMergeAnywhere(command)
271
- : extractRepoFlagFromGhPrReadyAnywhere(command);
272
- if (explicitRepo && explicitRepo.toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
297
+ // Pass through only when EVERY gated verb segment is PROVEN foreign (explicit repo, managed slug
298
+ // resolves, and demonstrably differs). A segment with no explicit repo, or an unresolvable managed
299
+ // slug, is NOT proven foreign — fail closed (preserves the fail-closed default). Mirrors the
300
+ // per-segment `.some()` scoping on the create/external-write paths: a proven-foreign FIRST segment
301
+ // must not shield a later managed segment (`gh pr merge --repo other/x 1 && gh pr merge 2`).
302
+ const gatedVerbSegments = [
303
+ ...(isMerge ? extractRepoFlagsFromGhPrMergeSegments(command) : []),
304
+ ...(isReady ? extractRepoFlagsFromGhPrReadySegments(command) : []),
305
+ ];
306
+ const allSegmentsProvenForeign =
307
+ gatedVerbSegments.length > 0 &&
308
+ gatedVerbSegments.every(
309
+ (seg) =>
310
+ seg.explicitRepo != null &&
311
+ managedSlug !== null &&
312
+ seg.explicitRepo.toLowerCase() !== managedSlug,
313
+ );
314
+ if (allSegmentsProvenForeign) {
273
315
  return ALLOW;
274
316
  }
275
- // Only gate within the target repo (case-insensitive — callers may pass an un-lowercased slug).
276
- if ((repoSlug ?? "").toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
317
+ // Only gate within the managed repo.
318
+ if (!inManagedRepo) {
277
319
  return ALLOW;
278
320
  }
279
321
 
@@ -287,31 +329,38 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
287
329
  };
288
330
  }
289
331
 
290
- if (gateError) {
291
- const which = isMerge ? "pre-merge gate" : "draft-gate";
332
+ // Raw `gh pr merge` is FORBIDDEN outright (RAW-GH-PR-MERGE-BYPASS): the only
333
+ // sanctioned merge path is the wrapper, which enforces the mandatory approver,
334
+ // merge class, and fresh-approval checks a bare evidence gate cannot. A
335
+ // gate-passed raw merge would bypass all of those, so this denies
336
+ // unconditionally (not only when evidence is missing) and points to the wrapper.
337
+ // The wrapper's own internal `gh pr merge` is a child_process spawn, never a
338
+ // Bash tool call, so it is never seen here.
339
+ if (isMerge) {
340
+ // This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
341
+ // writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
342
+ // is blocked here with the write never having executed. Hint the split when the command
343
+ // carries an evidence-writing invocation alongside the merge.
344
+ const alsoWritesEvidence = commandContainsEvidenceWrite(command);
292
345
  return {
293
346
  decision: "deny",
294
- reason: `${verb} blocked: ${which} evidence check failed (${gateError}).`,
347
+ reason:
348
+ `gh pr merge is forbidden: route the merge through the sanctioned wrapper \`node scripts/github/merge-pr.mjs --repo <owner/name> --pr ${prNumber} --human-approved-by <login>\`, which runs the full precondition set fail-closed (mandatory approver, merge class, fresh approval) — a raw \`gh pr merge\` bypasses those (RAW-GH-PR-MERGE-BYPASS).` +
349
+ (gateError ? ` (pre-merge gate evidence check also failed: ${gateError})` : "") +
350
+ (alsoWritesEvidence
351
+ ? " This command also writes gate evidence, but hooks evaluate before the command runs — never chain an evidence write with the merge."
352
+ : ""),
295
353
  };
296
354
  }
297
355
 
356
+ // `gh pr ready` keeps its gate-conditional behavior.
357
+ if (gateError) {
358
+ return {
359
+ decision: "deny",
360
+ reason: `${verb} blocked: draft-gate evidence check failed (${gateError}).`,
361
+ };
362
+ }
298
363
  if (!gatePassed) {
299
- if (isMerge) {
300
- // This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
301
- // writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
302
- // is blocked here with the write never having executed, which looks like the evidence
303
- // "vanished". Hint the split when the command carries an evidence-writing invocation
304
- // alongside the merge, so the failure is self-explaining instead of looking like data loss.
305
- const alsoWritesEvidence = commandContainsEvidenceWrite(command);
306
- return {
307
- decision: "deny",
308
- reason:
309
- `gh pr merge blocked: missing pre-merge gate evidence for PR #${prNumber} (need clean current-head draft_gate + pre_approval_gate; inline verdicts are not accepted). Run the dev-loop gates instead of merging directly.` +
310
- (alsoWritesEvidence
311
- ? " This command also writes gate evidence, but hooks evaluate before the command runs — write the evidence in a separate call, then merge alone."
312
- : ""),
313
- };
314
- }
315
364
  return {
316
365
  decision: "deny",
317
366
  reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,