@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.
- package/package.json +10 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +213 -65
- package/src/config/config.mjs +473 -43
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +79 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +396 -42
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +296 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +82 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +399 -0
- package/src/loop/pr-gate-coordination.mjs +123 -12
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
|
@@ -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
|
-
|
|
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
|
|
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({
|
|
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
|
|
98
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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) &&
|
|
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
|
-
|
|
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
|
|
175
|
-
//
|
|
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
|
-
:
|
|
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 &&
|
|
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
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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
|
-
|
|
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
|
|
241
|
-
//
|
|
242
|
-
//
|
|
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
|
-
:
|
|
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,
|
|
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
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
276
|
-
if (
|
|
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
|
-
|
|
291
|
-
|
|
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:
|
|
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
|