@dev-loops/core 1.0.2 → 1.0.4-pre.0

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.
@@ -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,7 @@ import {
31
32
  commandContainsCopilotSummonComment,
32
33
  commandContainsDetachedWaitTool,
33
34
  commandContainsInlineInterpreter,
34
- TARGET_REPO_SLUG,
35
+ commandContainsCodeVerificationEntrypoint,
35
36
  } from "../loop/bash-command-classify.mjs";
36
37
 
37
38
  /**
@@ -61,6 +62,24 @@ function commandContainsEvidenceWrite(command) {
61
62
  */
62
63
  export const DEV_LOOP_AGENT_TYPE = "dev-loop";
63
64
 
65
+ /**
66
+ * Normalize a Claude `agent_type` hook-payload value that may be PLUGIN-NAMESPACED
67
+ * (`<plugin-name>:<agent-name>`, e.g. `dev-loops:dev-loop`) to the bare agent name the coordinator
68
+ * deciders compare against `DEV_LOOP_AGENT_TYPE`. Returns the substring after the last `:` when
69
+ * present, else `agentType` unchanged (including `null`/non-string, passed through as-is).
70
+ *
71
+ * Applied in the coordinator-scoped deciders (`decideBashGate`, `decideCoordinatorWriteGuard`).
72
+ * Deliberately NOT applied in `decideWriteGuard` — its main-agent allow-set boundary is covered by
73
+ * the `DEVLOOPS_RUN_ID` run-id check first, and broadening that decider's comparison is out of
74
+ * scope for the coordinator→worker delegation boundary.
75
+ * @param {string|null|undefined} agentType @returns {string|null|undefined}
76
+ */
77
+ export function normalizeAgentType(agentType) {
78
+ if (typeof agentType !== "string") return agentType;
79
+ const idx = agentType.lastIndexOf(":");
80
+ return idx === -1 ? agentType : agentType.slice(idx + 1);
81
+ }
82
+
64
83
  /**
65
84
  * Decide whether a PreToolUse Bash command must be blocked by a dev-loop gate boundary.
66
85
  *
@@ -68,8 +87,9 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
68
87
  * - `gh pr create` — blocked outright; PR creation must flow through the canonical wrapper
69
88
  * (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always drafts and self-assigns.
70
89
  * - `gh pr ready` — blocked without clean draft_gate evidence.
71
- * - `gh pr merge` — blocked without full pre-merge gate evidence (clean current-head draft_gate +
72
- * pre_approval_gate).
90
+ * - `gh pr merge` — blocked outright; use scripts/github/merge-pr.mjs. Its gate evidence check
91
+ * requires a clean draft_gate transition record + current-head pre_approval_gate
92
+ * (GATE-COMMENT-DRAFT-REQUIREMENTS in skills/docs/gate-review-comment-contract.md).
73
93
  * - raw `gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment` — blocked ONLY
74
94
  * from a SUBAGENT context (`agentType` non-null) on the target repo. Sanctioned external writes
75
95
  * flow through node wrappers; the MAIN AGENT / operator (agentType null) retains direct access.
@@ -79,6 +99,11 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
79
99
  * @param {Object} params
80
100
  * @param {string} params.command - The Bash command string.
81
101
  * @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
102
+ * @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the dev-loops-managed
103
+ * repo (the repo `inManagedContext` refers to), or null when the identity can't be resolved.
104
+ * @param {boolean} [params.inManagedContext] - Whether the current repo is dev-loops-managed (a
105
+ * `.devloops` config exists at its root). Replaces the old hardcoded-slug `TARGET_REPO_SLUG`
106
+ * comparison so the guard suite applies in any managed consumer repo, not only mfittko/dev-loops.
82
107
  * @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
83
108
  * @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
84
109
  * @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
@@ -87,21 +112,58 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
87
112
  * (`resolveHumanMergeOnly`); when true, `gh pr merge` is refused actor-independently
88
113
  * (STOP-HUMAN-MERGE-001), because the main agent is the actor that performs GitHub writes and a
89
114
  * subagent-only deny would enforce nothing.
115
+ * @param {boolean} [params.enforceCoordinator] - Strict mode for the COORDINATOR-VERIFY-DELEGATION
116
+ * boundary, derived by the hook from `DEVLOOPS_COORDINATOR_READONLY=1` — the SAME flag that
117
+ * gates `decideCoordinatorWriteGuard`. Default fail-open (mirrors that boundary).
90
118
  * @returns {HookDecision}
91
119
  */
92
- export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null, humanMergeOnly = false }) {
120
+ export function decideBashGate({
121
+ command,
122
+ repoSlug = null,
123
+ managedRepoSlug = null,
124
+ inManagedContext = false,
125
+ gatePassed = false,
126
+ gateError = null,
127
+ agentType = null,
128
+ humanMergeOnly = false,
129
+ enforceCoordinator = false,
130
+ }) {
93
131
  if (typeof command !== "string") {
94
132
  return ALLOW;
95
133
  }
134
+
135
+ // COORDINATOR-VERIFY-DELEGATION: a known code-verification/build entrypoint (bun run
136
+ // verify/test, vitest, npm test/run test/run build, ...) run inline by the dev-loop COORDINATOR
137
+ // itself (agent_type "dev-loop"). WORKER subagents (developer/fixer/quality/review) may run these
138
+ // freely — only the coordinator is scoped out, mirroring `decideCoordinatorWriteGuard`'s
139
+ // agent_type discriminator. Opt-in via the same `DEVLOOPS_COORDINATOR_READONLY=1` flag as the
140
+ // write-guard boundary; default fail-open. Not scoped to `inManagedRepo` — this is a local
141
+ // command-invocation boundary (which binary ran), not a GitHub-repo-targeting one.
142
+ if (enforceCoordinator && normalizeAgentType(agentType) === DEV_LOOP_AGENT_TYPE && commandContainsCodeVerificationEntrypoint(command)) {
143
+ return {
144
+ decision: "deny",
145
+ reason:
146
+ "COORDINATOR-VERIFY-DELEGATION: the dev-loop coordinator must not run code-verification/build " +
147
+ "commands inline. Delegate the verification run to a fresh worker subagent (developer/fixer/" +
148
+ "quality/review), which reports back a compact pass/fail plus any failing-test names — or, when " +
149
+ "checking a pushed commit, prefer CI's structured conclusion (`gh pr checks` / " +
150
+ "scripts/github/detect-checkpoint-evidence.mjs) over a local run. See skills/docs/main-agent-contract.md.",
151
+ };
152
+ }
96
153
  // 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();
154
+ // silently fail OPEN. A repo is dev-loops-managed when inManagedContext is true (a .devloops
155
+ // config exists at its root); the managed slug is that repo's resolved identity, which may be
156
+ // unresolvable (null). FAIL CLOSED: inside a managed context whose identity can't be resolved,
157
+ // every guard below still applies (inManagedRepo stays true) rather than silently allowing
158
+ // everything — an unresolvable identity must never disable the guard suite.
159
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
160
+ const inManagedRepo = deriveInManagedRepo({ inManagedContext, managedRepoSlug, repoSlug });
99
161
 
100
162
  // OPS-NO-INLINE-INTERPRETER: inline interpreters (`node -e`/`--eval`/`-p`, `python3 -c`,
101
163
  // heredocs fed to node/python) are barred actor-independently on the target repo — the rule bars
102
164
  // "Coordinator and agent flows"; sanctioned output parsing uses `--jq`/`--silent`, never an
103
165
  // inline interpreter.
104
- if (inTargetRepo && commandContainsInlineInterpreter(command)) {
166
+ if (inManagedRepo && commandContainsInlineInterpreter(command)) {
105
167
  return {
106
168
  decision: "deny",
107
169
  reason:
@@ -111,12 +173,35 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
111
173
  };
112
174
  }
113
175
 
176
+ // COPILOT-FOLLOWUP-WAIT-TOOLS: banned detached/polling wait wrappers. Actor-independent: the
177
+ // coordinator/main agent — not subagents only — is the actor that leaves backgrounded
178
+ // `until`/`while … sleep … done` poll loops and bare-`&` backgrounded probe shells orphaned
179
+ // under the Claude Code harness (no async wake to join them), so the gate must deny its
180
+ // backgrounding too. Evaluated HERE, before the `gh pr ready`/`merge`/`create` classification,
181
+ // so a compound command that pairs a lifecycle verb with a backgrounded wait
182
+ // (`gh pr create --repo other/x && node …/probe-copilot-review.mjs … &`) cannot short-circuit
183
+ // past it via the create/ready ALLOW paths. The sanctioned wait is always a bounded FOREGROUND
184
+ // inline probe (`probe-copilot-review.mjs` / `wait-pr-checks.mjs` with an explicit
185
+ // --timeout/--timeout-ms; `gh run watch`; the watch-cycle CLIs).
186
+ if (inManagedRepo && commandContainsDetachedWaitTool(command)) {
187
+ return {
188
+ decision: "deny",
189
+ reason:
190
+ "COPILOT-FOLLOWUP-WAIT-TOOLS: wait only through a bounded FOREGROUND probe (scripts/github/" +
191
+ "probe-copilot-review.mjs or scripts/github/wait-pr-checks.mjs with an explicit --timeout/" +
192
+ "--timeout-ms; scripts/loop/detect-copilot-loop-state.mjs one-shot; dev-loops loop watch-cycle; " +
193
+ "gh run watch) — nohup/disown/tmux/screen detach, while-sleep-poll loops, and bare-`&` " +
194
+ "backgrounding of a probe/wait script are barred for the coordinator and every subagent (a " +
195
+ "backgrounded wait orphans under Claude Code, which has no async wake to join it).",
196
+ };
197
+ }
198
+
114
199
  // SUBISSUE-NO-ADHOC-BYPASS: ad-hoc `gh api` writes to the target repo's sub-issue endpoints.
115
200
  // Actor-independent (no reserved direct path). Gated on the target repo: the absolute slug-embedded
116
201
  // form identifies the target repo; the bare relative form (`gh api issues/5/sub_issues`) resolves
117
202
  // against the cwd repo, so it is in scope only when running in the target repo (mirrors the
118
203
  // explicit-`--repo`/cwd-target posture).
119
- if (inTargetRepo && commandContainsSubIssueAdHocBypass(command)) {
204
+ if (inManagedRepo && commandContainsSubIssueAdHocBypass(command, managedSlug)) {
120
205
  return {
121
206
  decision: "deny",
122
207
  reason:
@@ -129,7 +214,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
129
214
  // to pulls/<n>/comments/<m>/replies, or a `gh api graphql` resolveReviewThread mutation (the Rest
130
215
  // path names the target repo; the graphql form has no path-host repo, so it is scoped to the cwd
131
216
  // repo). Actor-independent: reply through reply-resolve-review-thread(s).mjs.
132
- if (inTargetRepo && (commandContainsReplyResolveBypass(command) || commandContainsGraphqlResolveReviewThread(command))) {
217
+ if (inManagedRepo && (commandContainsReplyResolveBypass(command, managedSlug) || commandContainsGraphqlResolveReviewThread(command))) {
133
218
  return {
134
219
  decision: "deny",
135
220
  reason:
@@ -142,7 +227,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
142
227
  // COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY: ad-hoc Copilot review requests — raw `gh api` writes
143
228
  // to pulls/<n>/requested_reviewers, or a bare `/copilot` / `/copilot re-review` comment summon on the
144
229
  // target repo. Actor-independent: request Copilot via scripts/github/request-copilot-review.mjs.
145
- if (inTargetRepo && (commandContainsCopilotRequestBypass(command) || commandContainsCopilotSummonComment(command))) {
230
+ if (inManagedRepo && (commandContainsCopilotRequestBypass(command, managedSlug) || commandContainsCopilotSummonComment(command))) {
146
231
  return {
147
232
  decision: "deny",
148
233
  reason:
@@ -156,7 +241,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
156
241
  // `.git` directory — a stash from one worktree can pop into another's. Block it outright on the
157
242
  // target repo; see skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout for the
158
243
  // stash-free alternative (git diff / a patch file / a scratch checkout).
159
- if (commandContainsGitStash(command) && inTargetRepo) {
244
+ if (commandContainsGitStash(command) && inManagedRepo) {
160
245
  return {
161
246
  decision: "deny",
162
247
  reason:
@@ -169,14 +254,19 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
169
254
  // `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
170
255
  // sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected.
171
256
  if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
172
- const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
257
+ // The cwd repo IS the managed/target repo exactly when inManagedRepo.
258
+ const cwdTargets = inManagedRepo;
173
259
  // 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.
260
+ // cwd is the target, or an explicit --repo/-R equals the managed slug. An explicit repo that is
261
+ // PROVEN foreign (managedSlug resolves and differs) passes through; otherwise (managedSlug
262
+ // unresolvable) we cannot prove the explicit repo is foreign, so a managed context fails closed
263
+ // (in scope). DENY if ANY external-write segment is in scope.
176
264
  const anyWriteInScope = extractRepoFlagsFromExternalWriteSegments(command).some((seg) =>
177
265
  seg.explicitRepo == null
178
266
  ? cwdTargets
179
- : seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
267
+ : managedSlug !== null
268
+ ? seg.explicitRepo.toLowerCase() === managedSlug
269
+ : inManagedContext,
180
270
  );
181
271
  if (anyWriteInScope) {
182
272
  return {
@@ -202,7 +292,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
202
292
  // refused actor-independently — the main agent is the actor that performs GitHub writes, so only an
203
293
  // actor-independent deny enforces the human-merge invariant (an agent-scoped deny would enforce
204
294
  // nothing on the main-agent write path).
205
- if (humanMergeOnly && isMerge && inTargetRepo) {
295
+ if (humanMergeOnly && isMerge && inManagedRepo) {
206
296
  return {
207
297
  decision: "deny",
208
298
  reason:
@@ -213,20 +303,9 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
213
303
  }
214
304
 
215
305
  if (!isReady && !isMerge && !isCreate) {
216
- // COPILOT-FOLLOWUP-WAIT-TOOLS: banned detached/polling wait wrappers. Subagent-only — the
217
- // rule is classified `agent` (behavioral guidance for the dev-loop driving agent); the main
218
- // agent/operator retains manual wait tooling. The main agent's own sanctioned wait path is still
219
- // the deterministic tools.
220
- if (typeof agentType === "string" && inTargetRepo && commandContainsDetachedWaitTool(command)) {
221
- return {
222
- decision: "deny",
223
- reason:
224
- "COPILOT-FOLLOWUP-WAIT-TOOLS: wait only through deterministic tools (scripts/loop/detect-copilot-" +
225
- "loop-state.mjs one-shot, dev-loops loop watch-cycle persistent, scripts/github/wait-pr-checks.mjs, " +
226
- "gh run watch) — nohup/disown/tmux/screen detach and while-sleep-poll loops are barred for the " +
227
- "dev-loop driving agent.",
228
- };
229
- }
306
+ // The detached-wait deny (COPILOT-FOLLOWUP-WAIT-TOOLS) is evaluated earlier — actor-independently
307
+ // and BEFORE this lifecycle-verb classification — so a compound command pairing a lifecycle verb
308
+ // with a backgrounded wait cannot short-circuit past it through the create/ready ALLOW paths.
230
309
  return ALLOW;
231
310
  }
232
311
 
@@ -234,16 +313,21 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
234
313
  // exists yet): PR creation must flow through the canonical wrapper, which always drafts and
235
314
  // self-assigns. This closes the draft-first hole where raw `gh pr create` opens a ready PR.
236
315
  if (isCreate) {
237
- const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
316
+ // The cwd repo IS the managed/target repo exactly when inManagedRepo.
317
+ const cwdTargets = inManagedRepo;
238
318
  // Evaluate scope PER create segment, not just the first: a create is in scope when it
239
319
  // 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`).
320
+ // `--repo <target>` is denied regardless of cwd — unless it is PROVEN foreign (managedSlug
321
+ // resolves and differs); when managedSlug is unresolvable we cannot prove foreignness, so a
322
+ // managed context fails closed (in scope). DENY if ANY create segment is in scope — otherwise a
323
+ // leading out-of-scope create (`gh pr create --repo other/repo`) would short-circuit and shield
324
+ // a later in-scope raw create (`&& gh pr create --fill`).
243
325
  const anyCreateInScope = extractRepoFlagsFromGhPrCreateSegments(command).some((seg) =>
244
326
  seg.explicitRepo == null
245
327
  ? cwdTargets
246
- : seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
328
+ : managedSlug !== null
329
+ ? seg.explicitRepo.toLowerCase() === managedSlug
330
+ : inManagedContext,
247
331
  );
248
332
  if (anyCreateInScope) {
249
333
  return {
@@ -262,18 +346,30 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
262
346
  return ALLOW;
263
347
  }
264
348
  }
265
- // When both verbs appear in a compound command, apply the stricter merge gate — if it passes,
266
- // the draft_gate (a subset of the pre-merge evidence check) is also satisfied.
349
+ // When both verbs appear in a compound command, the unconditional raw-merge refusal wins.
267
350
  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()) {
351
+ // Pass through only when EVERY gated verb segment is PROVEN foreign (explicit repo, managed slug
352
+ // resolves, and demonstrably differs). A segment with no explicit repo, or an unresolvable managed
353
+ // slug, is NOT proven foreign — fail closed (preserves the fail-closed default). Mirrors the
354
+ // per-segment `.some()` scoping on the create/external-write paths: a proven-foreign FIRST segment
355
+ // must not shield a later managed segment (`gh pr merge --repo other/x 1 && gh pr merge 2`).
356
+ const gatedVerbSegments = [
357
+ ...(isMerge ? extractRepoFlagsFromGhPrMergeSegments(command) : []),
358
+ ...(isReady ? extractRepoFlagsFromGhPrReadySegments(command) : []),
359
+ ];
360
+ const allSegmentsProvenForeign =
361
+ gatedVerbSegments.length > 0 &&
362
+ gatedVerbSegments.every(
363
+ (seg) =>
364
+ seg.explicitRepo != null &&
365
+ managedSlug !== null &&
366
+ seg.explicitRepo.toLowerCase() !== managedSlug,
367
+ );
368
+ if (allSegmentsProvenForeign) {
273
369
  return ALLOW;
274
370
  }
275
- // Only gate within the target repo (case-insensitive — callers may pass an un-lowercased slug).
276
- if ((repoSlug ?? "").toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
371
+ // Only gate within the managed repo.
372
+ if (!inManagedRepo) {
277
373
  return ALLOW;
278
374
  }
279
375
 
@@ -287,31 +383,38 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
287
383
  };
288
384
  }
289
385
 
290
- if (gateError) {
291
- const which = isMerge ? "pre-merge gate" : "draft-gate";
386
+ // Raw `gh pr merge` is FORBIDDEN outright (RAW-GH-PR-MERGE-BYPASS): the only
387
+ // sanctioned merge path is the wrapper, which enforces the mandatory approver,
388
+ // merge class, and fresh-approval checks a bare evidence gate cannot. A
389
+ // gate-passed raw merge would bypass all of those, so this denies
390
+ // unconditionally (not only when evidence is missing) and points to the wrapper.
391
+ // The wrapper's own internal `gh pr merge` is a child_process spawn, never a
392
+ // Bash tool call, so it is never seen here.
393
+ if (isMerge) {
394
+ // This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
395
+ // writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
396
+ // is blocked here with the write never having executed. Hint the split when the command
397
+ // carries an evidence-writing invocation alongside the merge.
398
+ const alsoWritesEvidence = commandContainsEvidenceWrite(command);
292
399
  return {
293
400
  decision: "deny",
294
- reason: `${verb} blocked: ${which} evidence check failed (${gateError}).`,
401
+ reason:
402
+ `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).` +
403
+ (gateError ? ` (pre-merge gate evidence check also failed: ${gateError})` : "") +
404
+ (alsoWritesEvidence
405
+ ? " This command also writes gate evidence, but hooks evaluate before the command runs — never chain an evidence write with the merge."
406
+ : ""),
295
407
  };
296
408
  }
297
409
 
410
+ // `gh pr ready` keeps its gate-conditional behavior.
411
+ if (gateError) {
412
+ return {
413
+ decision: "deny",
414
+ reason: `${verb} blocked: draft-gate evidence check failed (${gateError}).`,
415
+ };
416
+ }
298
417
  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
418
  return {
316
419
  decision: "deny",
317
420
  reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,
@@ -362,6 +465,51 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
362
465
  };
363
466
  }
364
467
 
468
+ /**
469
+ * Decide whether a PreToolUse Write/Edit must be blocked by the coordinator→worker delegation
470
+ * boundary — the INVERSE of `decideWriteGuard`, one level down. Under the Claude Code
471
+ * harness the dev-loop agent itself (Claude `agent_type === "dev-loop"`) acts as a delegating
472
+ * COORDINATOR: it MUST NOT mutate TRACKED repo files directly — that work is delegated to a fresh
473
+ * WORKER subagent (`developer`/`fixer`/`quality`/`docs`). `agent_type` is the only discriminator:
474
+ * `DEVLOOPS_RUN_ID` does not distinguish coordinator from worker (the coordinator mints it and
475
+ * propagates it to the workers it dispatches), so — unlike `decideWriteGuard` — this decider does
476
+ * not key on run id at all.
477
+ *
478
+ * Denies only when ALL of: strict enforcement is on, the target is a tracked repo mutation, AND
479
+ * the caller's `agent_type` is the coordinator's (`"dev-loop"`). Every other `agent_type` —
480
+ * including `null` (the Pi main agent / an interactive Claude session with no subagent context,
481
+ * which is `decideWriteGuard`'s boundary, not this one) and any worker role — is allowed here.
482
+ * Strict enforcement is opt-in via `enforce` (the hook derives it from
483
+ * `DEVLOOPS_COORDINATOR_READONLY=1`); default is fail-open, mirroring `decideWriteGuard`'s
484
+ * adopt-safe precedent so enabling this boundary does not retroactively break a repo's own
485
+ * interactive Claude Code dev.
486
+ *
487
+ * @param {Object} params
488
+ * @param {string} params.filePath - Target file path.
489
+ * @param {boolean} params.isRepoMutation - True if inside the repo working tree AND not gitignored.
490
+ * @param {boolean} [params.enforce] - Strict mode (DEVLOOPS_COORDINATOR_READONLY=1).
491
+ * @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload, if any.
492
+ * @returns {HookDecision}
493
+ */
494
+ export function decideCoordinatorWriteGuard({ filePath, isRepoMutation, enforce = false, agentType = null }) {
495
+ if (!enforce) {
496
+ return ALLOW; // strict enforcement not enabled — fail open
497
+ }
498
+ if (!isRepoMutation) {
499
+ return ALLOW; // non-repo or gitignored path (tmp/, the scratchpad, sanctioned ledger paths)
500
+ }
501
+ if (normalizeAgentType(agentType) !== DEV_LOOP_AGENT_TYPE) {
502
+ return ALLOW; // not the coordinator — a worker subagent, or the main agent (the other boundary)
503
+ }
504
+ return {
505
+ decision: "deny",
506
+ reason:
507
+ `Coordinator→worker delegation boundary: refusing to mutate repository path "${filePath}" as the ` +
508
+ "dev-loop coordinator. Delegate this tracked-file edit to a fresh worker subagent (developer/fixer/" +
509
+ "quality/docs) instead of writing it directly. See skills/docs/main-agent-contract.md.",
510
+ };
511
+ }
512
+
365
513
  /**
366
514
  * Env var that authorizes a deliberate main-checkout mutation while a worktree
367
515
  * cycle is active. Reuses the existing default-branch-guard override