@dev-loops/core 0.6.0 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -16,12 +16,9 @@
16
16
  "./claude/hook-decisions": "./src/claude/hook-decisions.mjs",
17
17
  "./config": "./src/config/config.mjs",
18
18
  "./debt/cluster": "./src/debt/cluster.mjs",
19
- "./debt/finding": "./src/debt/debt-finding.mjs",
20
19
  "./debt/remediation-to-issue": "./src/debt/remediation-to-issue.mjs",
21
- "./debt/score": "./src/debt/score.mjs",
22
20
  "./debt/shape": "./src/debt/shape.mjs",
23
21
  "./debt/signal": "./src/debt/debt-signal.mjs",
24
- "./debt/signals": "./src/debt/deep-persona-signals.mjs",
25
22
  "./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
26
23
  "./github/repo-slug": "./src/github/repo-slug.mjs",
27
24
  "./github/review-threads": "./src/github/review-threads.mjs",
@@ -43,6 +40,7 @@
43
40
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
44
41
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
45
42
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
43
+ "./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
46
44
  "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
47
45
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
48
46
  "./loop/queue-membership": "./src/loop/queue-membership.mjs",
@@ -57,7 +55,6 @@
57
55
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
58
56
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
59
57
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
60
- "./refinement/ac-dod-matrix": "./src/refinement/ac-dod-matrix.mjs",
61
58
  "./harness": "./src/harness/index.mjs",
62
59
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
63
60
  "./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
@@ -65,8 +62,7 @@
65
62
  "bin": {
66
63
  "dev-loops-log-bash-exit-1": "./bin/log-bash-exit-1.mjs",
67
64
  "dev-loops-ensure-phase-files": "./bin/ensure-phase-files.mjs",
68
- "dev-loops-parse-review-threads": "./bin/parse-review-threads.mjs",
69
- "dev-loops-capture-deep-persona-signals": "./bin/capture-deep-persona-signals.mjs"
65
+ "dev-loops-parse-review-threads": "./bin/parse-review-threads.mjs"
70
66
  },
71
67
  "files": [
72
68
  "src/**/*.mjs",
@@ -51,9 +51,11 @@ const CATEGORY_ANGLE_MAP = {
51
51
  [ChangeCategory.COMMENT_ONLY]: [
52
52
  "dry",
53
53
  ],
54
+ // Core review subset for any non-trivial code change. Peripheral lenses
55
+ // (ci-guard, link-check, packaging-runtime, config-drift, etc.) are pulled in
56
+ // only when the diff's other categories implicate them, not by logic alone.
54
57
  [ChangeCategory.LOGIC_CHANGE]: [
55
- "correctness", "coverage", "kiss", "dry", "srp", "soc", "deep",
56
- "ocp", "lsp", "isp", "dip", "yagni", "scope", "no-op", "determinism",
58
+ "scope", "correctness", "coverage", "determinism", "contract-surface",
57
59
  ],
58
60
  };
59
61
 
@@ -70,28 +72,43 @@ const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr-descri
70
72
 
71
73
  /**
72
74
  * @typedef {object} DynamicAngleResult
73
- * @property {string[]} recommendedAngles — angles to run
75
+ * @property {string[]} recommendedAngles — angles to run, limited to the
76
+ * configured pool (subtractive result); in additive mode the caller
77
+ * merges addedAngles on top to form the full effective run set
74
78
  * @property {string[]} skippedAngles — angles skipped with reasons
75
79
  * @property {Record<string, string>} reasons — why each angle was skipped
76
80
  * @property {boolean} fallbackToAll — true when ambiguous → all angles recommended
81
+ * @property {string[]} addedAngles — catalog angles added (additive mode only, see #1048)
82
+ * @property {Record<string, string>} addedReasons — why each added angle was added
77
83
  */
78
84
 
79
85
  /**
80
86
  * Resolve which gate angles to run based on detected change categories.
81
87
  *
82
- * When the diff is ambiguous (contains LOGIC_CHANGE or multiple mixed categories),
83
- * all configured angles are recommended (fallback-to-all).
88
+ * When the diff is ambiguous (no detected categories / analysis failure),
89
+ * all configured angles are recommended (fallback-to-all). A LOGIC_CHANGE
90
+ * diff resolves to its core review subset, not fallback-to-all.
91
+ *
92
+ * When `anglePool` is provided (additive mode, see #1048), catalog angles in
93
+ * the pool that the change categories recommend but that are not already in
94
+ * `configuredAngles` are additively selected and reported as `addedAngles`.
95
+ * When `anglePool` is omitted, additive mode is off and `addedAngles` is
96
+ * always empty.
84
97
  *
85
98
  * @param {object} options
86
99
  * @param {string[]} options.configuredAngles — all angles configured for this gate
87
100
  * @param {string[]} options.changeCategories — from diff analysis
88
101
  * @param {boolean} [options.ambiguous] — from diff analysis
102
+ * @param {string[]} [options.anglePool] — catalog of angles eligible for additive
103
+ * selection (caller pre-filters this against excludeAngles); when undefined,
104
+ * additive selection is disabled
89
105
  * @returns {DynamicAngleResult}
90
106
  */
91
107
  export function resolveDynamicAngles({
92
108
  configuredAngles,
93
109
  changeCategories,
94
110
  ambiguous = false,
111
+ anglePool,
95
112
  }) {
96
113
  // Fallback: ambiguous diff → all angles
97
114
  if (ambiguous) {
@@ -100,6 +117,8 @@ export function resolveDynamicAngles({
100
117
  skippedAngles: [],
101
118
  reasons: {},
102
119
  fallbackToAll: true,
120
+ addedAngles: [],
121
+ addedReasons: {},
103
122
  };
104
123
  }
105
124
 
@@ -110,21 +129,30 @@ export function resolveDynamicAngles({
110
129
  skippedAngles: [],
111
130
  reasons: {},
112
131
  fallbackToAll: true,
132
+ addedAngles: [],
133
+ addedReasons: {},
113
134
  };
114
135
  }
115
136
 
116
- // Build recommended set from category union
137
+ // Build recommended set from category union, tracking the first trigger per angle
117
138
  const recommended = new Set();
139
+ const triggers = new Map();
118
140
  for (const cat of changeCategories) {
119
141
  const angles = CATEGORY_ANGLE_MAP[cat] ?? [];
120
142
  for (const angle of angles) {
121
143
  recommended.add(angle);
144
+ if (!triggers.has(angle)) {
145
+ triggers.set(angle, cat);
146
+ }
122
147
  }
123
148
  }
124
149
 
125
150
  // Always-include angles
126
151
  for (const angle of ALWAYS_INCLUDE) {
127
152
  recommended.add(angle);
153
+ if (!triggers.has(angle)) {
154
+ triggers.set(angle, "always-include");
155
+ }
128
156
  }
129
157
 
130
158
  // Filter to only angles that are configured
@@ -137,10 +165,26 @@ export function resolveDynamicAngles({
137
165
  reasons[angle] = `Skipped: detected categories (${changeCategories.join(", ") || "none"}) do not trigger this angle`;
138
166
  }
139
167
 
168
+ // Additive: pull in recommended catalog angles not already configured (#1048)
169
+ const configuredSet = new Set(configuredAngles);
170
+ const anglePoolSet = Array.isArray(anglePool) ? new Set(anglePool) : null;
171
+ const addedAngles = anglePoolSet
172
+ ? [...recommended].filter((a) => anglePoolSet.has(a) && !configuredSet.has(a))
173
+ : [];
174
+ const addedReasons = {};
175
+ for (const angle of addedAngles) {
176
+ const trigger = triggers.get(angle);
177
+ addedReasons[angle] = trigger === "always-include"
178
+ ? "Added: always-include lens not in the configured pool"
179
+ : `Added: triggered by change category ${trigger}`;
180
+ }
181
+
140
182
  return {
141
183
  recommendedAngles,
142
184
  skippedAngles,
143
185
  reasons,
144
186
  fallbackToAll: false,
187
+ addedAngles,
188
+ addedReasons,
145
189
  };
146
190
  }
@@ -199,13 +199,16 @@ export function analyzeT1(diffOutput, t0) {
199
199
  }
200
200
  }
201
201
 
202
- // Build categories from T0 + hunk analysis
203
- if (t0.renameOnly) categories.add("RENAME_ONLY");
204
- if (t0.allDocs) categories.add("DOCS_ONLY");
205
- if (t0.files.every((f) => classifyFile(f) === "config")) categories.add("CONFIG_ONLY");
206
- if (t0.files.every((f) => classifyFile(f) === "test")) categories.add("TEST_ONLY");
207
- if (t0.files.every((f) => classifyFile(f) === "ci")) categories.add("CI_ONLY");
202
+ // Build categories from T0 (shared with inferCategoriesFromT0) + hunk analysis.
203
+ for (const c of t0FileCategories(t0)) categories.add(c);
208
204
  if (hasLogicChange) categories.add("LOGIC_CHANGE");
205
+ // Mixed diffs never satisfy the exclusive `_ONLY` checks above (some files are
206
+ // code), so their peripheral surfaces would be dropped. In this hunk-level path
207
+ // (only reached for genuinely mixed diffs), also union each surface by PRESENCE
208
+ // so e.g. a code+workflow diff pulls ci-guard alongside the LOGIC_CHANGE core
209
+ // (AC: mixed logic+CI -> core union ci-guard). The pure single-surface path
210
+ // (inferCategoriesFromT0) keeps exclusive semantics.
211
+ for (const c of t0PresentSurfaceCategories(t0)) categories.add(c);
209
212
 
210
213
  // COMMENT_ONLY: hunkCount > 0 (real diff), has changed lines, all are non-logic,
211
214
  // and not a rename-only change
@@ -232,18 +235,64 @@ export function analyzeT1(diffOutput, t0) {
232
235
  */
233
236
 
234
237
  /**
235
- * Infer change categories from T0 analysis when T1 is not run.
238
+ * Categories derivable from T0 file classification alone (no hunk content).
239
+ * Shared by analyzeT1 (which adds hunk-derived LOGIC_CHANGE/COMMENT_ONLY on top)
240
+ * and inferCategoriesFromT0 (the no-T1 path). All checks are length-guarded so an
241
+ * empty file list yields no category.
236
242
  *
237
243
  * @param {T0Result} t0
238
244
  * @returns {string[]}
239
245
  */
240
- function inferCategoriesFromT0(t0) {
246
+ function t0FileCategories(t0) {
247
+ if (t0.files.length === 0) return [];
241
248
  const categories = [];
242
249
  if (t0.renameOnly) categories.push("RENAME_ONLY");
243
250
  if (t0.allDocs) categories.push("DOCS_ONLY");
244
- if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
245
- if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
246
- if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
251
+ if (t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
252
+ if (t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
253
+ if (t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
254
+ return categories;
255
+ }
256
+
257
+ /**
258
+ * Surface categories present in a MIXED diff (at least one file of the surface),
259
+ * used only by the hunk-level path to union a mixed diff's peripheral lenses on
260
+ * top of LOGIC_CHANGE. Reuses the same category names / angle mappings as the
261
+ * exclusive path; presence (not exclusivity) is the correct trigger for a mixed
262
+ * diff. Renames are handled by the exclusive path, so they are excluded here.
263
+ *
264
+ * @param {T0Result} t0
265
+ * @returns {string[]}
266
+ */
267
+ function t0PresentSurfaceCategories(t0) {
268
+ const categories = [];
269
+ const cats = new Set(t0.files.map(classifyFile));
270
+ if (cats.has("docs")) categories.push("DOCS_ONLY");
271
+ if (cats.has("config")) categories.push("CONFIG_ONLY");
272
+ if (cats.has("test")) categories.push("TEST_ONLY");
273
+ if (cats.has("ci")) categories.push("CI_ONLY");
274
+ return categories;
275
+ }
276
+
277
+ /**
278
+ * Infer change categories from T0 analysis when T1 (hunk-level) is not run.
279
+ * Reuses the shared T0 file-category derivation, then adds the pure-code
280
+ * LOGIC_CHANGE inference that the hunk-level path would otherwise supply.
281
+ *
282
+ * @param {T0Result} t0
283
+ * @returns {string[]}
284
+ */
285
+ function inferCategoriesFromT0(t0) {
286
+ const categories = t0FileCategories(t0);
287
+ // Pure code-only change: a diff whose files all classify as code (and is not a
288
+ // rename) is a LOGIC_CHANGE. Without this, an all-code diff has a single file
289
+ // category (so analyzeDiff never runs hunk-level T1) and produces no category,
290
+ // which resolveDynamicAngles treats as "unclassifiable" → fallback-to-all. That
291
+ // regressed the primary case: a code-only PR must resolve to the LOGIC_CHANGE
292
+ // core review subset, not all angles.
293
+ if (!t0.renameOnly && t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "code")) {
294
+ categories.push("LOGIC_CHANGE");
295
+ }
247
296
  return categories;
248
297
  }
249
298
 
@@ -279,7 +328,14 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
279
328
  };
280
329
  }
281
330
 
282
- const ambiguous = t0Ambiguous && (t1.changeCategories.length === 0 || t1.changeCategories.includes("LOGIC_CHANGE"));
331
+ // `ambiguous` flags one specific case: a diff T0 could not classify (mixed file
332
+ // categories, so t0Ambiguous) AND whose hunk analysis still produced no
333
+ // category. It is NOT the only fallback trigger — resolveDynamicAngles also
334
+ // falls back whenever changeCategories is empty (e.g. a single lone unknown/
335
+ // asset file yields no category yet is not t0Ambiguous). A mixed diff that
336
+ // yields a category (e.g. LOGIC_CHANGE) is classified and not ambiguous, so
337
+ // LOGIC_CHANGE never forces fallback-to-all via this flag.
338
+ const ambiguous = t0Ambiguous && t1.changeCategories.length === 0;
283
339
 
284
340
  return { t0, t1, ambiguous };
285
341
  }
@@ -11,9 +11,16 @@
11
11
 
12
12
  import { resolveRunId } from "../loop/run-context.mjs";
13
13
  import {
14
- isGhPrReadyCommand,
15
- extractPrNumberFromGhPrReady,
16
- extractRepoFlagFromGhPrReady,
14
+ commandContainsGhPrReady,
15
+ commandContainsGhPrMerge,
16
+ commandContainsGhPrCreate,
17
+ extractPrNumberFromGhPrReadyAnywhere,
18
+ extractRepoFlagFromGhPrReadyAnywhere,
19
+ extractPrNumberFromGhPrMergeAnywhere,
20
+ extractRepoFlagFromGhPrMergeAnywhere,
21
+ extractRepoFlagsFromGhPrCreateSegments,
22
+ commandContainsRawExternalWrite,
23
+ extractRepoFlagsFromExternalWriteSegments,
17
24
  TARGET_REPO_SLUG,
18
25
  } from "../loop/bash-command-classify.mjs";
19
26
 
@@ -25,6 +32,16 @@ import {
25
32
 
26
33
  const ALLOW = Object.freeze({ decision: "allow" });
27
34
 
35
+ /**
36
+ * Whether the command string also invokes an evidence-writing script (findings-log ledger or
37
+ * checkpoint-verdict upsert). Used only to enrich the merge-block message (#1172) — a compound
38
+ * command combining an evidence write with `gh pr merge` is blocked pre-execution, so the write
39
+ * never runs; this substring check has no false-negative cost (worst case: the plain message).
40
+ */
41
+ function commandContainsEvidenceWrite(command) {
42
+ return command.includes("write-gate-findings-log") || command.includes("upsert-checkpoint-verdict");
43
+ }
44
+
28
45
  /**
29
46
  * The agent type (Claude `agent_type` / the canonical agent name) that owns repo mutations.
30
47
  * Only this subagent — not arbitrary subagents (Explore, Plan, generic Task agents) — may
@@ -33,26 +50,114 @@ const ALLOW = Object.freeze({ decision: "allow" });
33
50
  export const DEV_LOOP_AGENT_TYPE = "dev-loop";
34
51
 
35
52
  /**
36
- * Decide whether a PreToolUse Bash command must be blocked by the draft-gate boundary.
53
+ * Decide whether a PreToolUse Bash command must be blocked by a dev-loop gate boundary.
54
+ *
55
+ * Three gated commands on the target repo:
56
+ * - `gh pr create` — blocked outright; PR creation must flow through the canonical wrapper
57
+ * (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always drafts and
58
+ * self-assigns. Closes the hole where raw `gh pr create` opens a ready PR, bypassing draft-first.
59
+ * - `gh pr ready` — blocked without clean draft_gate evidence (`pre-pr-ready-gate`).
60
+ * - `gh pr merge` — blocked without the full pre-merge gate evidence (`detect-checkpoint-evidence`:
61
+ * clean current-head draft_gate + pre_approval_gate). The loop runs this check before merging;
62
+ * gating it here closes the hole where a hand-run `gh pr merge` skips the pre-approval gate
63
+ * entirely. Everything else passes through.
64
+ * - raw `gh issue create` / `gh issue comment` / `gh pr comment` — blocked ONLY when the call
65
+ * originates from a SUBAGENT context (`agentType` is a non-null string) and targets the repo.
66
+ * Sanctioned external writes flow through node wrappers (gate-verdict comments via
67
+ * `upsert-checkpoint-verdict.mjs`, review replies via `reply-resolve*.mjs`, board sync,
68
+ * `comment-issue.mjs`), whose Bash command string is `node scripts/…` and never matches these
69
+ * raw-`gh` matchers. The MAIN AGENT / operator (agentType null) retains direct `gh issue
70
+ * create` — that path is authorized (#1051).
37
71
  *
38
- * Mirrors the Pi extension's `onUserBash`: the only blocked case is `gh pr ready` for the
39
- * target repo without clean draft_gate evidence. Everything else (including merges, which
40
- * trigger the post-merge step, not a block) is allowed through.
72
+ * The hook computes `gatePassed`/`gateError` from the gate script appropriate to the command kind.
41
73
  *
42
74
  * @param {Object} params
43
75
  * @param {string} params.command - The Bash command string.
44
76
  * @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
45
- * @param {boolean} [params.gatePassed] - Whether `pre-pr-ready-gate` evidence exists for the PR.
77
+ * @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
46
78
  * @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
79
+ * @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
80
+ * string inside a subagent, null in the main agent. Scopes the external-write guard.
47
81
  * @returns {HookDecision}
48
82
  */
49
- export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null }) {
50
- if (typeof command !== "string" || !isGhPrReadyCommand(command)) {
83
+ export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null }) {
84
+ if (typeof command !== "string") {
85
+ return ALLOW;
86
+ }
87
+ // Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
88
+ // `gh pr comment` on the target repo from a subagent, so external writes flow through the
89
+ // sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
90
+ if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
91
+ const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
92
+ // Scope PER segment, mirroring the `gh pr create` block: in scope when no explicit --repo and
93
+ // cwd is the target, or an explicit --repo/-R equals the target. An explicit non-target --repo
94
+ // passes through. DENY if ANY external-write segment is in scope.
95
+ const anyWriteInScope = extractRepoFlagsFromExternalWriteSegments(command).some((seg) =>
96
+ seg.explicitRepo == null
97
+ ? cwdTargets
98
+ : seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
99
+ );
100
+ if (anyWriteInScope) {
101
+ return {
102
+ decision: "deny",
103
+ reason:
104
+ "Ad-hoc GitHub issue/PR creation and comments from a subagent are blocked. Use the sanctioned " +
105
+ "node wrappers instead — gate-verdict comments via scripts/github/upsert-checkpoint-verdict.mjs, " +
106
+ "review-thread replies via scripts/github/reply-resolve*.mjs, board sync, or scripts/github/comment-issue.mjs. " +
107
+ "Direct `gh issue create` is reserved for the main agent / operator.",
108
+ };
109
+ }
110
+ }
111
+ // Scan ALL shell segments — the PreToolUse gate blocks pre-emptively, so a gated verb in any
112
+ // segment (even after `&&` or `;`) must be caught. This differs from the Pi extension's
113
+ // post-execute `isGhPrReadyCommand`/`isGhPrMergeCommand` which scan only the first segment
114
+ // (correct there: `false && gh pr ready 42` short-circuits so ready never ran).
115
+ const isReady = commandContainsGhPrReady(command);
116
+ const isMerge = commandContainsGhPrMerge(command);
117
+ const isCreate = commandContainsGhPrCreate(command);
118
+ if (!isReady && !isMerge && !isCreate) {
51
119
  return ALLOW;
52
120
  }
53
121
 
122
+ // Raw `gh pr create` is blocked outright on the target repo (no PR number / gate evidence
123
+ // exists yet): PR creation must flow through the canonical wrapper, which always drafts and
124
+ // self-assigns. This closes the draft-first hole where raw `gh pr create` opens a ready PR.
125
+ if (isCreate) {
126
+ const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
127
+ // Evaluate scope PER create segment, not just the first: a create is in scope when it
128
+ // explicitly targets the repo, or (with no explicit --repo) the cwd is the repo. An explicit
129
+ // `--repo <target>` is denied regardless of cwd (#1047). DENY if ANY create segment is in
130
+ // scope — otherwise a leading out-of-scope create (`gh pr create --repo other/repo`) would
131
+ // short-circuit and shield a later in-scope raw create (`&& gh pr create --fill`).
132
+ const anyCreateInScope = extractRepoFlagsFromGhPrCreateSegments(command).some((seg) =>
133
+ seg.explicitRepo == null
134
+ ? cwdTargets
135
+ : seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
136
+ );
137
+ if (anyCreateInScope) {
138
+ return {
139
+ decision: "deny",
140
+ reason:
141
+ "gh pr create blocked: open PRs via the canonical wrapper `node scripts/github/create-pr.mjs` " +
142
+ "(a.k.a. `dev-loops pr create`), which always creates a draft and self-assigns. Raw `gh pr create` " +
143
+ "defaults to ready-for-review and bypasses the draft-first contract (workflow.requireDraftFirst).",
144
+ };
145
+ }
146
+ // The create is out of scope. Only allow outright when there is no ready/merge segment to
147
+ // evaluate — otherwise fall through so a gated `gh pr ready`/`gh pr merge` in the same
148
+ // compound command (e.g. `gh pr create --repo other/repo && gh pr merge 5`) is still gated
149
+ // below rather than short-circuited.
150
+ if (!isReady && !isMerge) {
151
+ return ALLOW;
152
+ }
153
+ }
154
+ // When both verbs appear in a compound command, apply the stricter merge gate — if it passes,
155
+ // the draft_gate (a subset of the pre-merge evidence check) is also satisfied.
156
+ const verb = isMerge ? "gh pr merge" : "gh pr ready";
54
157
  // An explicit `--repo other/repo` that is not the target → not our concern, pass through.
55
- const explicitRepo = extractRepoFlagFromGhPrReady(command);
158
+ const explicitRepo = isMerge
159
+ ? extractRepoFlagFromGhPrMergeAnywhere(command)
160
+ : extractRepoFlagFromGhPrReadyAnywhere(command);
56
161
  if (explicitRepo && explicitRepo.toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
57
162
  return ALLOW;
58
163
  }
@@ -61,23 +166,41 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
61
166
  return ALLOW;
62
167
  }
63
168
 
64
- const prNumber = extractPrNumberFromGhPrReady(command);
169
+ const prNumber = isMerge
170
+ ? extractPrNumberFromGhPrMergeAnywhere(command)
171
+ : extractPrNumberFromGhPrReadyAnywhere(command);
65
172
  if (prNumber === null) {
66
173
  return {
67
174
  decision: "deny",
68
- reason:
69
- "gh pr ready blocked: could not determine the PR number from the command. Include the PR number explicitly.",
175
+ reason: `${verb} blocked: could not determine the PR number from the command. Include the PR number explicitly.`,
70
176
  };
71
177
  }
72
178
 
73
179
  if (gateError) {
180
+ const which = isMerge ? "pre-merge gate" : "draft-gate";
74
181
  return {
75
182
  decision: "deny",
76
- reason: `gh pr ready blocked: draft-gate evidence check failed (${gateError}).`,
183
+ reason: `${verb} blocked: ${which} evidence check failed (${gateError}).`,
77
184
  };
78
185
  }
79
186
 
80
187
  if (!gatePassed) {
188
+ if (isMerge) {
189
+ // This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
190
+ // writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
191
+ // is blocked here with the write never having executed, which looks like the evidence
192
+ // "vanished" (#1172). Hint the split when the command carries an evidence-writing invocation
193
+ // alongside the merge, so the failure is self-explaining instead of looking like data loss.
194
+ const alsoWritesEvidence = commandContainsEvidenceWrite(command);
195
+ return {
196
+ decision: "deny",
197
+ reason:
198
+ `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.` +
199
+ (alsoWritesEvidence
200
+ ? " This command also writes gate evidence, but hooks evaluate before the command runs — write the evidence in a separate call, then merge alone."
201
+ : ""),
202
+ };
203
+ }
81
204
  return {
82
205
  decision: "deny",
83
206
  reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,