@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.
- package/package.json +5 -2
- package/src/analysis/diff-analyzer.mjs +85 -137
- package/src/claude/asset-generation.mjs +7 -7
- package/src/claude/hook-decisions.mjs +167 -50
- package/src/config/config.mjs +388 -787
- package/src/config/extension-defaults.yaml +14 -9
- package/src/github/comment-id-guard.mjs +39 -1
- package/src/github/copilot-helpers.mjs +90 -158
- package/src/github/gh.mjs +49 -0
- package/src/loop/bash-command-classify.mjs +34 -49
- package/src/loop/commit-msg-guard.mjs +1 -1
- package/src/loop/conductor-routing.mjs +15 -23
- package/src/loop/copilot-loop-state.mjs +46 -94
- package/src/loop/gate-carry-forward.mjs +46 -22
- package/src/loop/gate-evidence-reconcile.mjs +75 -0
- package/src/loop/gate-fanin.mjs +266 -435
- package/src/loop/handoff-envelope.mjs +21 -21
- package/src/loop/issue-refinement-artifact.mjs +449 -284
- package/src/loop/lifecycle-state.mjs +10 -21
- package/src/loop/pr-gate-coordination.mjs +49 -49
- package/src/loop/queue-board-sync.mjs +16 -82
- package/src/loop/review-dispatch-plan.mjs +61 -122
- package/src/loop/review-lineage.mjs +19 -44
- package/src/loop/spec-authority.mjs +729 -0
- package/src/loop/steering.mjs +16 -68
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/worktree-guard.mjs +55 -0
- package/src/projects/list-queue-items.mjs +16 -175
- package/src/projects/move-queue-item.mjs +16 -171
- package/src/projects/projects-access.mjs +202 -0
- package/src/security/secret-scan.mjs +13 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure decision logic for the Claude Code dev-loop hooks
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
71
|
-
* - `gh pr
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
*
|
|
388
|
-
* uncommitted-work guard (#1786).
|
|
473
|
+
* Subagent roles whose contract forbids committing to the repository.
|
|
389
474
|
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* commit
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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,
|
|
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
|
|
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())
|