@dev-loops/core 1.0.1 → 1.0.2-slim.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure decision logic for the Claude Code dev-loop hooks (#773).
2
+ * Pure decision logic for the Claude Code dev-loop hooks.
3
3
  *
4
4
  * The hook *scripts* are thin: they read the PreToolUse/PostToolUse stdin payload, gather facts
5
5
  * (git tracked/ignored status, gate-evidence result), and call these pure deciders. Keeping the
@@ -46,7 +46,7 @@ const ALLOW = Object.freeze({ decision: "allow" });
46
46
 
47
47
  /**
48
48
  * Whether the command string also invokes an evidence-writing script (findings-log ledger or
49
- * checkpoint-verdict upsert). Used only to enrich the merge-block message (#1172) — a compound
49
+ * checkpoint-verdict upsert). Used only to enrich the merge-block message — a compound
50
50
  * command combining an evidence write with `gh pr merge` is blocked pre-execution, so the write
51
51
  * never runs; this substring check has no false-negative cost (worst case: the plain message).
52
52
  */
@@ -64,22 +64,15 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
64
64
  /**
65
65
  * Decide whether a PreToolUse Bash command must be blocked by a dev-loop gate boundary.
66
66
  *
67
- * Three gated commands on the target repo:
67
+ * Gated commands on the target repo (each rationale sits inline at its check):
68
68
  * - `gh pr create` — blocked outright; PR creation must flow through the canonical wrapper
69
- * (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always drafts and
70
- * self-assigns. Closes the hole where raw `gh pr create` opens a ready PR, bypassing draft-first.
71
- * - `gh pr ready` — blocked without clean draft_gate evidence (`pre-pr-ready-gate`).
72
- * - `gh pr merge` — blocked without the full pre-merge gate evidence (`detect-checkpoint-evidence`:
73
- * clean current-head draft_gate + pre_approval_gate). The loop runs this check before merging;
74
- * gating it here closes the hole where a hand-run `gh pr merge` skips the pre-approval gate
75
- * entirely. Everything else passes through.
76
- * - raw `gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment` — blocked ONLY when the call
77
- * originates from a SUBAGENT context (`agentType` is a non-null string) and targets the repo.
78
- * Sanctioned external writes flow through node wrappers (gate-verdict comments via
79
- * `upsert-checkpoint-verdict.mjs`, review replies via `reply-resolve*.mjs`, board sync,
80
- * `comment-issue.mjs`), whose Bash command string is `node scripts/…` and never matches these
81
- * raw-`gh` matchers. The MAIN AGENT / operator (agentType null) retains direct `gh issue
82
- * create` — that path is authorized (#1051).
69
+ * (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always drafts and self-assigns.
70
+ * - `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).
73
+ * - raw `gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment` — blocked ONLY
74
+ * from a SUBAGENT context (`agentType` non-null) on the target repo. Sanctioned external writes
75
+ * flow through node wrappers; the MAIN AGENT / operator (agentType null) retains direct access.
83
76
  *
84
77
  * The hook computes `gatePassed`/`gateError` from the gate script appropriate to the command kind.
85
78
  *
@@ -101,10 +94,10 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
101
94
  return ALLOW;
102
95
  }
103
96
  // Normalize (trim + case-fold) so a divergent slug (surrounding whitespace, casing) does not
104
- // silently fail OPEN and disable every guard that depends on inTargetRepo (#1622).
97
+ // silently fail OPEN and disable every guard that depends on inTargetRepo.
105
98
  const inTargetRepo = (repoSlug ?? "").trim().toLowerCase() === TARGET_REPO_SLUG.trim().toLowerCase();
106
99
 
107
- // OPS-NO-INLINE-INTERPRETER (#1622): inline interpreters (`node -e`/`--eval`/`-p`, `python3 -c`,
100
+ // OPS-NO-INLINE-INTERPRETER: inline interpreters (`node -e`/`--eval`/`-p`, `python3 -c`,
108
101
  // heredocs fed to node/python) are barred actor-independently on the target repo — the rule bars
109
102
  // "Coordinator and agent flows"; sanctioned output parsing uses `--jq`/`--silent`, never an
110
103
  // inline interpreter.
@@ -118,10 +111,10 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
118
111
  };
119
112
  }
120
113
 
121
- // SUBISSUE-NO-ADHOC-BYPASS (#1622): ad-hoc `gh api` writes to the target repo's sub-issue endpoints.
114
+ // SUBISSUE-NO-ADHOC-BYPASS: ad-hoc `gh api` writes to the target repo's sub-issue endpoints.
122
115
  // Actor-independent (no reserved direct path). Gated on the target repo: the absolute slug-embedded
123
116
  // form identifies the target repo; the bare relative form (`gh api issues/5/sub_issues`) resolves
124
- // against the cwd repo, so it is in scope only when running in the target repo (mirrors the #1047
117
+ // against the cwd repo, so it is in scope only when running in the target repo (mirrors the
125
118
  // explicit-`--repo`/cwd-target posture).
126
119
  if (inTargetRepo && commandContainsSubIssueAdHocBypass(command)) {
127
120
  return {
@@ -132,7 +125,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
132
125
  };
133
126
  }
134
127
 
135
- // COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (#1622): ad-hoc thread-resolution writes — raw `gh api` POST
128
+ // COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER: ad-hoc thread-resolution writes — raw `gh api` POST
136
129
  // to pulls/<n>/comments/<m>/replies, or a `gh api graphql` resolveReviewThread mutation (the Rest
137
130
  // path names the target repo; the graphql form has no path-host repo, so it is scoped to the cwd
138
131
  // repo). Actor-independent: reply through reply-resolve-review-thread(s).mjs.
@@ -146,7 +139,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
146
139
  };
147
140
  }
148
141
 
149
- // COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (#1622): ad-hoc Copilot review requests — raw `gh api` writes
142
+ // COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY: ad-hoc Copilot review requests — raw `gh api` writes
150
143
  // to pulls/<n>/requested_reviewers, or a bare `/copilot` / `/copilot re-review` comment summon on the
151
144
  // target repo. Actor-independent: request Copilot via scripts/github/request-copilot-review.mjs.
152
145
  if (inTargetRepo && (commandContainsCopilotRequestBypass(command) || commandContainsCopilotSummonComment(command))) {
@@ -174,7 +167,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
174
167
  }
175
168
  // Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
176
169
  // `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
177
- // sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
170
+ // sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected.
178
171
  if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
179
172
  const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
180
173
  // Scope PER segment, mirroring the `gh pr create` block: in scope when no explicit --repo and
@@ -205,7 +198,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
205
198
  const isMerge = commandContainsGhPrMerge(command);
206
199
  const isCreate = commandContainsGhPrCreate(command);
207
200
 
208
- // STOP-HUMAN-MERGE-001 (#1622): when the repo resolves `autonomy.humanMergeOnly`, `gh pr merge` is
201
+ // STOP-HUMAN-MERGE-001: when the repo resolves `autonomy.humanMergeOnly`, `gh pr merge` is
209
202
  // refused actor-independently — the main agent is the actor that performs GitHub writes, so only an
210
203
  // actor-independent deny enforces the human-merge invariant (an agent-scoped deny would enforce
211
204
  // nothing on the main-agent write path).
@@ -220,7 +213,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
220
213
  }
221
214
 
222
215
  if (!isReady && !isMerge && !isCreate) {
223
- // COPILOT-FOLLOWUP-WAIT-TOOLS (#1622): banned detached/polling wait wrappers. Subagent-only — the
216
+ // COPILOT-FOLLOWUP-WAIT-TOOLS: banned detached/polling wait wrappers. Subagent-only — the
224
217
  // rule is classified `agent` (behavioral guidance for the dev-loop driving agent); the main
225
218
  // agent/operator retains manual wait tooling. The main agent's own sanctioned wait path is still
226
219
  // the deterministic tools.
@@ -244,7 +237,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
244
237
  const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
245
238
  // Evaluate scope PER create segment, not just the first: a create is in scope when it
246
239
  // explicitly targets the repo, or (with no explicit --repo) the cwd is the repo. An explicit
247
- // `--repo <target>` is denied regardless of cwd (#1047). DENY if ANY create segment is in
240
+ // `--repo <target>` is denied regardless of cwd. DENY if ANY create segment is in
248
241
  // scope — otherwise a leading out-of-scope create (`gh pr create --repo other/repo`) would
249
242
  // short-circuit and shield a later in-scope raw create (`&& gh pr create --fill`).
250
243
  const anyCreateInScope = extractRepoFlagsFromGhPrCreateSegments(command).some((seg) =>
@@ -307,7 +300,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
307
300
  // This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
308
301
  // writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
309
302
  // is blocked here with the write never having executed, which looks like the evidence
310
- // "vanished" (#1172). Hint the split when the command carries an evidence-writing invocation
303
+ // "vanished". Hint the split when the command carries an evidence-writing invocation
311
304
  // alongside the merge, so the failure is self-explaining instead of looking like data loss.
312
305
  const alsoWritesEvidence = commandContainsEvidenceWrite(command);
313
306
  return {
@@ -369,9 +362,102 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
369
362
  };
370
363
  }
371
364
 
365
+ /**
366
+ * Env var that authorizes a deliberate main-checkout mutation while a worktree
367
+ * cycle is active. Reuses the existing default-branch-guard override
368
+ * (`DEVLOOPS_ALLOW_MAIN`, GUARD_OVERRIDE_ENV) — both mean "I intend to operate on
369
+ * the primary checkout on purpose" — so the operator surface stays one flag.
370
+ */
371
+ export const WORKTREE_CHECKOUT_GUARD_OVERRIDE_ENV = "DEVLOOPS_ALLOW_MAIN";
372
+
373
+ /**
374
+ * Decide whether a PreToolUse Write/Edit is a WRONG-CHECKOUT mutation: the call
375
+ * context is operating inside a linked worktree (the active cycle worktree) but
376
+ * the target resolves to a TRACKED file in the MAIN checkout instead of that
377
+ * worktree.
378
+ *
379
+ * After `ensure-worktree.mjs` establishes an isolated worktree for a cycle, an
380
+ * absolute-path `Edit`/`Write` that names the main-checkout copy of a source
381
+ * file lands the change on the wrong checkout — silently, until a `git status`
382
+ * in the worktree turns up empty (observed in practice: six edits hit main
383
+ * before a self-caught restore). This guard catches that before it reaches a commit.
384
+ *
385
+ * The "active worktree" is anchored to the CALL CONTEXT's cwd, not to a durable
386
+ * marker: this repo accumulates many stale `tmp/worktrees/` worktrees, so "a
387
+ * worktree exists" cannot mean "a cycle is active". The worktree that CONTAINS
388
+ * cwd is the one this context is driving; a write escaping it into main is the
389
+ * wrong-checkout mistake.
390
+ *
391
+ * The hook resolves the facts (via the shared `worktree-guard.mjs` primitives)
392
+ * and passes booleans so this decider stays pure and unit-testable:
393
+ * - `activeWorktreeRoot`: the listed worktree root containing cwd, or null when
394
+ * cwd is not inside any worktree (no active cycle context — AC3).
395
+ * - `isTargetUnderActiveWorktree`: the target resolves inside that worktree (a
396
+ * legitimate in-worktree edit — AC2).
397
+ * - `isMainCheckoutTracked`: the target resolves under the main checkout AND is
398
+ * a tracked (non-gitignored) file. The hook sets this true when the status is
399
+ * UNRESOLVABLE (e.g. `git check-ignore` errored) so an ambiguous context fails
400
+ * safe rather than silently allowing a wrong-checkout write (AC4).
401
+ * - `allowMainCheckout`: the deliberate-override signal (`DEVLOOPS_ALLOW_MAIN=1`)
402
+ * for an intended main-checkout edit (AC3).
403
+ *
404
+ * @param {Object} params
405
+ * @param {string} params.filePath - Target file path (as supplied to the tool).
406
+ * @param {string|null} [params.activeWorktreeRoot] - Listed worktree root containing cwd, or null.
407
+ * @param {boolean} [params.isTargetUnderActiveWorktree] - Target is inside the active worktree.
408
+ * @param {boolean} [params.isMainCheckoutTracked] - Target is a tracked main-checkout file (or unresolvable).
409
+ * @param {boolean} [params.allowMainCheckout] - Deliberate override (DEVLOOPS_ALLOW_MAIN=1).
410
+ * @param {string|null} [params.suggestedWorktreePath] - The worktree-local path the write should target.
411
+ * @returns {HookDecision}
412
+ */
413
+ export function decideWorktreeCheckoutGuard({
414
+ filePath,
415
+ activeWorktreeRoot = null,
416
+ isTargetUnderActiveWorktree = false,
417
+ isMainCheckoutTracked = false,
418
+ allowMainCheckout = false,
419
+ suggestedWorktreePath = null,
420
+ }) {
421
+ // No active worktree context — this is the main checkout / orchestrator / a
422
+ // consumer repo's own interactive dev. A main-checkout edit is intended here
423
+ // (AC3). Also the only path that runs for repos never using dev-loop worktrees.
424
+ if (!activeWorktreeRoot) {
425
+ return ALLOW;
426
+ }
427
+ // Deliberate, operator-authorized main-checkout write during an active cycle (AC3).
428
+ if (allowMainCheckout) {
429
+ return ALLOW;
430
+ }
431
+ // Legitimate in-worktree edit — no false positive (AC2).
432
+ if (isTargetUnderActiveWorktree) {
433
+ return ALLOW;
434
+ }
435
+ // Outside the active worktree AND not a tracked main-checkout file: a scratch
436
+ // path (/tmp), a gitignored path, another worktree's file, or outside the repo
437
+ // entirely — none is the wrong-checkout mistake this guard exists to catch.
438
+ if (!isMainCheckoutTracked) {
439
+ return ALLOW;
440
+ }
441
+ // A worktree cycle is active and the target is a tracked main-checkout file
442
+ // (or an unresolvable/ambiguous context that fails safe — AC4). This is a
443
+ // wrong-checkout mutation (AC1).
444
+ const fix = suggestedWorktreePath
445
+ ? ` Edit the worktree copy instead: "${suggestedWorktreePath}".`
446
+ : "";
447
+ return {
448
+ decision: "deny",
449
+ reason:
450
+ `WORKTREE-WRONG-CHECKOUT-GUARD: wrong-checkout mutation blocked — a worktree cycle is active ` +
451
+ `("${activeWorktreeRoot}") but this Write/Edit targets the MAIN checkout's non-gitignored file ` +
452
+ `"${filePath}".${fix} A file mutation that lands on the main checkout while a worktree is ` +
453
+ "active is silently lost from the branch. Set DEVLOOPS_ALLOW_MAIN=1 only for a deliberate " +
454
+ "main-checkout edit. See skills/docs/worktree-guidance.md.",
455
+ };
456
+ }
457
+
372
458
  /**
373
459
  * Env var that exempts an interactive session awaiting commit authorization from the
374
- * SubagentStop uncommitted-work guard (#1619).
460
+ * SubagentStop uncommitted-work guard.
375
461
  *
376
462
  * An opt-in signal set by the operator or the interactive coordination path
377
463
  * (`DEVLOOPS_COMMIT_AUTH_PENDING=1`) when intentionally holding uncommitted work pending
@@ -384,34 +470,48 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
384
470
  export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
385
471
 
386
472
  /**
387
- * Env var that exempts an orchestrator-owned-commit dispatch from the SubagentStop
388
- * uncommitted-work guard (#1786).
473
+ * Subagent roles whose contract forbids committing to the repository.
389
474
  *
390
- * A "LOCAL EDITS ONLY: no commit" dispatch (e.g. the `developer`/`quality`/`docs` delegation
391
- * pattern in `skills/local-implementation/SKILL.md` "Delegation contract") tells the subagent to
392
- * make local edits and report changed files, leaving commit + push to the dispatching
393
- * orchestrator once it consolidates results. Without an exemption, that subagent's own
394
- * SubagentStop event still sees the dirty worktree it was told not to commit and deadlocks. The
395
- * dispatcher sets `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` for that dispatch to declare it owns the
396
- * commit — same opt-in `DEVLOOPS_*` signal shape as `DEVLOOPS_COMMIT_AUTH_PENDING`, but distinct:
397
- * this one exempts a non-interactive delegated dispatch whose commit responsibility sits with its
398
- * caller, not an interactive session awaiting operator authorization. Left unset, an ordinary
399
- * dispatch's commit-before-exit obligation stays enforced (fail closed by default).
475
+ * The `judge` and `review` agents are read-only over the repository: the judge writes only its
476
+ * own verdict artifact (under `tmp/`, gitignored) and the gate reviewer writes only its findings
477
+ * artifact (also under `tmp/`) — neither ever authors a tracked-file edit. So any uncommitted
478
+ * tracked change present in such a subagent's worktree is FOREIGN: it belongs to the orchestrator
479
+ * that dispatched it (a pending orchestrator edit that was in the shared worktree when the
480
+ * read-only pass ran), not to the read-only subagent. `LOCAL-COMMIT-BEFORE-EXIT` must not force
481
+ * one of these roles to author a commit of that foreign work — doing so violates the verdict-only
482
+ * contract (`agents/judge.agent.md`: "The only thing you write is your own verdict artifact").
483
+ * The data-loss protection is enforced against the OWNER of the edit (the orchestrator) on its own
484
+ * stop instead. This is intentionally scoped to read-only roles: editing roles (`developer`,
485
+ * `fixer`, `docs`, `quality`) and the orchestrator stay enforced.
400
486
  */
401
- export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT";
487
+ export const READONLY_SUBAGENT_ROLES = Object.freeze(["judge", "review"]);
488
+
489
+ /** Whether `agentType` (Claude `agent_type` from the SubagentStop payload) is a read-only role. */
490
+ export function isReadOnlySubagentRole(agentType) {
491
+ return typeof agentType === "string" && READONLY_SUBAGENT_ROLES.includes(agentType);
492
+ }
402
493
 
403
494
  /**
404
495
  * Decide whether a SubagentStop must be blocked because the subagent's worktree has
405
- * uncommitted changes (#1619).
496
+ * uncommitted changes.
406
497
  *
407
498
  * `scripts/loop/cleanup-worktree.mjs` runs `git worktree remove --force` after a merge, so
408
499
  * uncommitted changes in a worktree are destroyed with no warning. `LOCAL-COMMIT-BEFORE-EXIT`
409
500
  * existed only as prose. This decider makes it mechanical: refuse the subagent stop when the
410
501
  * cwd is under `tmp/worktrees/` and `git status --porcelain` is non-empty, unless the session
411
- * is an interactive one awaiting commit authorization, or the dispatch is an explicit
412
- * orchestrator-owned-commit exemption (#1786) (either exempt). A clean worktree, a cwd
502
+ * is an interactive one awaiting commit authorization (exempt). A clean worktree, a cwd
413
503
  * outside `tmp/worktrees/`, and a git-error/empty-porcelain case all allow the stop.
414
504
  *
505
+ * Editing roles (`developer`/`fixer`/`docs`/`quality`) stay fully enforced: an editing
506
+ * sub-delegate commits its own work before exit (`LOCAL-COMMIT-BEFORE-EXIT`), so a dirty exit is
507
+ * always a real defect, never a sanctioned "orchestrator owns the commit" split. The removed
508
+ * `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT` env-var exemption deadlocked such a role under a
509
+ * task-scoped no-commit instruction whenever the orchestrator could not set a per-dispatch env
510
+ * var (the Claude harness): the hook demanded a commit the session then denied, then re-blocked
511
+ * the exit. Disallowing the edit-here/commit-there split at the contract level makes the
512
+ * guard the enforcer and the deadlock structurally impossible while preserving data-loss
513
+ * protection. An orchestrator that wants one consolidated commit performs the edits itself.
514
+ *
415
515
  * Pure and side-effect free. The hook script gathers `cwd` and the `git status --porcelain`
416
516
  * output and calls this; the block decision is surfaced via exit code 2 + stderr JSON by the
417
517
  * hook (the SubagentStop contract differs from PreToolUse's `permissionDecision` form).
@@ -424,21 +524,38 @@ export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS
424
524
  * @param {boolean} [params.pendingCommitAuthorization] - True when the interactive session is
425
525
  * awaiting commit authorization (exempt) — derived by the hook script from the
426
526
  * `DEVLOOPS_COMMIT_AUTH_PENDING=1` opt-in env signal.
427
- * @param {boolean} [params.orchestratorOwnsCommit] - True when this dispatch is an explicit
428
- * orchestrator-owned-commit exemption (exempt) — derived by the hook script from the
429
- * `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` opt-in env signal.
527
+ * @param {string|null} [params.agentType] - Claude `agent_type` from the SubagentStop payload;
528
+ * a read-only role (`judge`/`review`, per `READONLY_SUBAGENT_ROLES`) is exempt — its
529
+ * contract forbids commits, so any dirty tracked edit in its worktree is foreign
530
+ * (orchestrator-owned) and must not be pinned on it.
430
531
  * @returns {HookDecision}
431
532
  */
432
- export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false, orchestratorOwnsCommit = false }) {
533
+ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false, agentType = null }) {
433
534
  if (typeof cwd !== "string" || !isUnderWorktreePath(cwd)) {
434
535
  return ALLOW;
435
536
  }
436
- if (pendingCommitAuthorization || orchestratorOwnsCommit) {
537
+ if (pendingCommitAuthorization) {
437
538
  return ALLOW;
438
539
  }
439
540
  if (typeof porcelain !== "string" || porcelain.trim() === "") {
440
541
  return ALLOW;
441
542
  }
543
+ // Read-only role exemption: the worktree is dirty, but a `judge`/`review` subagent's
544
+ // contract forbids commits, so this pending tracked edit is foreign — it belongs to the
545
+ // orchestrator that dispatched this pass. Do not force a verdict-only role to commit it; allow
546
+ // the stop with an advisory naming the orchestrator as the actor responsible for the edit. The
547
+ // data-loss guard still fires against the orchestrator on its own (editing) stop.
548
+ if (isReadOnlySubagentRole(agentType)) {
549
+ return {
550
+ decision: "allow",
551
+ advisory: true,
552
+ reason:
553
+ `LOCAL-COMMIT-BEFORE-EXIT exempt for read-only role "${agentType}": the worktree has ` +
554
+ "uncommitted changes, but a verdict-only role must not author a commit of work it did not " +
555
+ "create. This pending edit is foreign — the ORCHESTRATOR that dispatched this pass owns it " +
556
+ "and is responsible for committing it before its own stop.",
557
+ };
558
+ }
442
559
  const dirty = porcelain
443
560
  .split("\n")
444
561
  .map((l) => l.trim())