@dev-loops/core 1.0.2-slim.0 → 1.0.3
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 +11 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +439 -37
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/github/copilot-helpers.mjs +28 -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 +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +113 -6
- package/src/loop/issue-refinement-artifact.mjs +30 -11
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/loop/worktree-guard.mjs +80 -13
- package/src/security/secret-scan.mjs +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -22,24 +22,31 @@
|
|
|
22
22
|
"./debt/signal": "./src/debt/debt-signal.mjs",
|
|
23
23
|
"./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
|
|
24
24
|
"./github/comment-id-guard": "./src/github/comment-id-guard.mjs",
|
|
25
|
+
"./github/closing-ref-guard": "./src/github/closing-ref-guard.mjs",
|
|
25
26
|
"./github/gh": "./src/github/gh.mjs",
|
|
26
27
|
"./github/issue-ops": "./src/github/issue-ops.mjs",
|
|
27
28
|
"./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
|
|
28
29
|
"./github/repo-slug": "./src/github/repo-slug.mjs",
|
|
29
30
|
"./github/review-threads": "./src/github/review-threads.mjs",
|
|
31
|
+
"./github/test-mode-write-guard": "./src/github/test-mode-write-guard.mjs",
|
|
30
32
|
"./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
|
|
31
33
|
"./loop/agent-stall": "./src/loop/agent-stall.mjs",
|
|
32
34
|
"./loop/bash-command-classify": "./src/loop/bash-command-classify.mjs",
|
|
35
|
+
"./loop/child-launch-bound": "./src/loop/child-launch-bound.mjs",
|
|
36
|
+
"./loop/reviewer-unit-bound": "./src/loop/reviewer-unit-bound.mjs",
|
|
33
37
|
"./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
|
|
34
38
|
"./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
|
|
35
39
|
"./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
|
|
36
40
|
"./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
|
|
41
|
+
"./loop/finding-cluster": "./src/loop/finding-cluster.mjs",
|
|
42
|
+
"./loop/fixer-disposition": "./src/loop/fixer-disposition.mjs",
|
|
37
43
|
"./loop/gate-carry-forward": "./src/loop/gate-carry-forward.mjs",
|
|
38
44
|
"./loop/gate-evidence-reconcile": "./src/loop/gate-evidence-reconcile.mjs",
|
|
39
45
|
"./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
|
|
40
46
|
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
41
47
|
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
42
48
|
"./loop/markdown-sections": "./src/loop/markdown-sections.mjs",
|
|
49
|
+
"./loop/merge-approval": "./src/loop/merge-approval.mjs",
|
|
43
50
|
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
44
51
|
"./loop/phase-files": "./src/loop/phase-files.mjs",
|
|
45
52
|
"./loop/policy-constants": "./src/loop/policy-constants.mjs",
|
|
@@ -62,9 +69,11 @@
|
|
|
62
69
|
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
63
70
|
"./loop/size-budget-merge-gate": "./src/loop/size-budget-merge-gate.mjs",
|
|
64
71
|
"./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
|
|
72
|
+
"./loop/execution-record": "./src/loop/execution-record.mjs",
|
|
65
73
|
"./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
|
|
66
74
|
"./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
|
|
67
75
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
|
76
|
+
"./loop/role-budget-bound": "./src/loop/role-budget-bound.mjs",
|
|
68
77
|
"./loop/run-context": "./src/loop/run-context.mjs",
|
|
69
78
|
"./loop/run-inspection": "./src/loop/run-inspection.mjs",
|
|
70
79
|
"./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
|
|
@@ -74,6 +83,7 @@
|
|
|
74
83
|
"./loop/steering": "./src/loop/steering.mjs",
|
|
75
84
|
"./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
|
|
76
85
|
"./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
|
|
86
|
+
"./loop/watcher-exclusivity": "./src/loop/watcher-exclusivity.mjs",
|
|
77
87
|
"./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
|
|
78
88
|
"./loop/ui-review-provision": "./src/loop/ui-review-provision.mjs",
|
|
79
89
|
"./loop/ui-review-drive": "./src/loop/ui-review-drive.mjs",
|
|
@@ -114,6 +114,67 @@ export function rewriteGeneratedRepoDocLinks(body) {
|
|
|
114
114
|
return String(body).replace(/(\]\(<?)(\.\.\/docs\/)/g, "$1../$2");
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Name of the resolver launcher committed at `.claude/bin/dev-loops-run`. It self-locates a
|
|
119
|
+
* source checkout by walking up from `process.cwd()` (unconditional priority), else by walking
|
|
120
|
+
* up from the launcher's own real binary location, else falls back to the plugin's auto-installed
|
|
121
|
+
* `dev-loops` package, and hard-stops (never falls back to raw `gh`) when none resolve.
|
|
122
|
+
*/
|
|
123
|
+
export const WRAPPER_LAUNCHER = "dev-loops-run";
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* CLI namespaces the routed `dev-loops <ns> <sub>` form recognizes — the real top-level keys of
|
|
127
|
+
* `SUBCOMMAND_ROUTES` in `cli/index.mjs` (locked by
|
|
128
|
+
* `test/contracts/claude-no-bare-invocation.test.mjs`'s recurrence guard, which reads those keys
|
|
129
|
+
* directly). `release` and `security` are NOT CLI namespaces (no such routes exist); `issue` and
|
|
130
|
+
* `inspect` are and were previously missing here, which left e.g. `dev-loops issue edit …` bare
|
|
131
|
+
* and unrouted in the generated tree.
|
|
132
|
+
*/
|
|
133
|
+
export const WRAPPER_NS = "gate|loop|pr|issue|queue|project|inspect|refine";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Regex source (no flags) matching a bare `node scripts/<dir>/…/<file>.mjs` invocation, at ANY
|
|
137
|
+
* subdirectory depth (one-or-more `dir/` segments), not just one subdirectory level, so a
|
|
138
|
+
* nested wrapper is caught too. The `node`→`scripts/` gap matches ANY whitespace (`\s+`),
|
|
139
|
+
* including a source line-wrap (`node` at a line end, `scripts/…mjs` beginning the next), so a
|
|
140
|
+
* prose-wrapped invocation is routed and the guard test that shares this source catches it too —
|
|
141
|
+
* a single-space-only pattern silently left wrapped invocations bare and passed the guard falsely.
|
|
142
|
+
* The `.mjs` path anchor keeps false positives impossible. Capture group 1 is the matched
|
|
143
|
+
* `scripts/…mjs` path. Exported so the no-bare-invocation guard test can build the identical regex
|
|
144
|
+
* rather than re-deriving it (single source of truth).
|
|
145
|
+
*/
|
|
146
|
+
export const BARE_NODE_SCRIPTS_SOURCE = String.raw`\bnode\s+(scripts\/(?:[a-z0-9-]+\/)+[A-Za-z0-9._-]+\.mjs)`;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Regex source (no flags) matching a bare, unrouted `dev-loops <namespace> <sub>` invocation.
|
|
150
|
+
* Requires an immediate lowercase-starting subcommand (lookahead) so prose (`dev-loops gate.`,
|
|
151
|
+
* `dev-loops gate — …`, `` `dev-loops queue` ``) and the already-pinned `npx dev-loops@<version>`
|
|
152
|
+
* CLI form never match. Exported so the no-bare-invocation guard test can build the identical
|
|
153
|
+
* regex from the same `WRAPPER_NS` rather than re-deriving it (single source of truth).
|
|
154
|
+
*/
|
|
155
|
+
export const BARE_DEV_LOOPS_NS_SOURCE = String.raw`\bdev-loops (${WRAPPER_NS}) (?=[a-z])`;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Route real wrapper invocations in a generated body through the resolver launcher so a
|
|
159
|
+
* plugin-only install (no `scripts/`, no `node_modules`) still resolves every wrapper.
|
|
160
|
+
* Two disjoint forms; args are preserved byte-for-byte:
|
|
161
|
+
* node scripts/<dir>/<file>.mjs … → <launcher> scripts/<dir>/<file>.mjs …
|
|
162
|
+
* dev-loops <namespace> <sub> … → <launcher> cli/index.mjs <namespace> <sub> …
|
|
163
|
+
* The namespace form requires an immediate lowercase-starting subcommand (lookahead), so prose
|
|
164
|
+
* (`dev-loops gate.`, `dev-loops gate — …`, `` `dev-loops queue` ``) and the already-pinned
|
|
165
|
+
* `npx dev-loops@<version>` CLI form are never touched. Idempotent: the rewritten output never
|
|
166
|
+
* re-matches (the launcher name is followed by a path/`cli/index.mjs`, not `scripts/` or a bare
|
|
167
|
+
* namespace token).
|
|
168
|
+
* @param {string} body
|
|
169
|
+
* @param {string} [launcher]
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function rewriteWrapperInvocation(body, launcher = WRAPPER_LAUNCHER) {
|
|
173
|
+
return String(body)
|
|
174
|
+
.replace(new RegExp(BARE_NODE_SCRIPTS_SOURCE, "g"), `${launcher} $1`)
|
|
175
|
+
.replace(new RegExp(BARE_DEV_LOOPS_NS_SOURCE, "g"), `${launcher} cli/index.mjs $1 `);
|
|
176
|
+
}
|
|
177
|
+
|
|
117
178
|
/**
|
|
118
179
|
* Map a single Pi tool name to its Claude tool name(s).
|
|
119
180
|
* @param {string} name
|
|
@@ -184,7 +245,7 @@ function normalizeToolList(value) {
|
|
|
184
245
|
*/
|
|
185
246
|
export function transformAgent({ source, raw, version = "latest", config = {} }) {
|
|
186
247
|
const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
|
|
187
|
-
const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
|
|
248
|
+
const body = rewriteWrapperInvocation(rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version)));
|
|
188
249
|
const tools = mapTools(normalizeToolList(frontmatter.tools));
|
|
189
250
|
const model = resolveRoleModel(config, { role: String(frontmatter.name ?? ""), harness: "claude" });
|
|
190
251
|
|
|
@@ -219,7 +280,7 @@ export function transformAgent({ source, raw, version = "latest", config = {} })
|
|
|
219
280
|
*/
|
|
220
281
|
export function transformCommand({ source, raw, version = "latest" }) {
|
|
221
282
|
const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
|
|
222
|
-
const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
|
|
283
|
+
const body = rewriteWrapperInvocation(rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version)));
|
|
223
284
|
|
|
224
285
|
const lines = ["---"];
|
|
225
286
|
if (frontmatter.description != null) {
|
|
@@ -241,7 +302,7 @@ export function transformCommand({ source, raw, version = "latest" }) {
|
|
|
241
302
|
*/
|
|
242
303
|
export function transformSkill({ source, raw, version = "latest" }) {
|
|
243
304
|
const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
|
|
244
|
-
const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
|
|
305
|
+
const body = rewriteWrapperInvocation(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
|
|
245
306
|
const tools = mapTools(normalizeToolList(frontmatter["allowed-tools"]));
|
|
246
307
|
|
|
247
308
|
const lines = ["---"];
|
|
@@ -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,6 @@ import {
|
|
|
31
32
|
commandContainsCopilotSummonComment,
|
|
32
33
|
commandContainsDetachedWaitTool,
|
|
33
34
|
commandContainsInlineInterpreter,
|
|
34
|
-
TARGET_REPO_SLUG,
|
|
35
35
|
} from "../loop/bash-command-classify.mjs";
|
|
36
36
|
|
|
37
37
|
/**
|
|
@@ -79,6 +79,11 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
|
79
79
|
* @param {Object} params
|
|
80
80
|
* @param {string} params.command - The Bash command string.
|
|
81
81
|
* @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
|
|
82
|
+
* @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the dev-loops-managed
|
|
83
|
+
* repo (the repo `inManagedContext` refers to), or null when the identity can't be resolved.
|
|
84
|
+
* @param {boolean} [params.inManagedContext] - Whether the current repo is dev-loops-managed (a
|
|
85
|
+
* `.devloops` config exists at its root). Replaces the old hardcoded-slug `TARGET_REPO_SLUG`
|
|
86
|
+
* comparison so the guard suite applies in any managed consumer repo, not only mfittko/dev-loops.
|
|
82
87
|
* @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
|
|
83
88
|
* @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
|
|
84
89
|
* @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
|
|
@@ -89,19 +94,33 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
|
89
94
|
* subagent-only deny would enforce nothing.
|
|
90
95
|
* @returns {HookDecision}
|
|
91
96
|
*/
|
|
92
|
-
export function decideBashGate({
|
|
97
|
+
export function decideBashGate({
|
|
98
|
+
command,
|
|
99
|
+
repoSlug = null,
|
|
100
|
+
managedRepoSlug = null,
|
|
101
|
+
inManagedContext = false,
|
|
102
|
+
gatePassed = false,
|
|
103
|
+
gateError = null,
|
|
104
|
+
agentType = null,
|
|
105
|
+
humanMergeOnly = false,
|
|
106
|
+
}) {
|
|
93
107
|
if (typeof command !== "string") {
|
|
94
108
|
return ALLOW;
|
|
95
109
|
}
|
|
96
110
|
// Normalize (trim + case-fold) so a divergent slug (surrounding whitespace, casing) does not
|
|
97
|
-
// silently fail OPEN
|
|
98
|
-
|
|
111
|
+
// silently fail OPEN. A repo is dev-loops-managed when inManagedContext is true (a .devloops
|
|
112
|
+
// config exists at its root); the managed slug is that repo's resolved identity, which may be
|
|
113
|
+
// unresolvable (null). FAIL CLOSED: inside a managed context whose identity can't be resolved,
|
|
114
|
+
// every guard below still applies (inManagedRepo stays true) rather than silently allowing
|
|
115
|
+
// everything — an unresolvable identity must never disable the guard suite.
|
|
116
|
+
const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
|
|
117
|
+
const inManagedRepo = deriveInManagedRepo({ inManagedContext, managedRepoSlug, repoSlug });
|
|
99
118
|
|
|
100
119
|
// OPS-NO-INLINE-INTERPRETER: inline interpreters (`node -e`/`--eval`/`-p`, `python3 -c`,
|
|
101
120
|
// heredocs fed to node/python) are barred actor-independently on the target repo — the rule bars
|
|
102
121
|
// "Coordinator and agent flows"; sanctioned output parsing uses `--jq`/`--silent`, never an
|
|
103
122
|
// inline interpreter.
|
|
104
|
-
if (
|
|
123
|
+
if (inManagedRepo && commandContainsInlineInterpreter(command)) {
|
|
105
124
|
return {
|
|
106
125
|
decision: "deny",
|
|
107
126
|
reason:
|
|
@@ -116,7 +135,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
116
135
|
// form identifies the target repo; the bare relative form (`gh api issues/5/sub_issues`) resolves
|
|
117
136
|
// against the cwd repo, so it is in scope only when running in the target repo (mirrors the
|
|
118
137
|
// explicit-`--repo`/cwd-target posture).
|
|
119
|
-
if (
|
|
138
|
+
if (inManagedRepo && commandContainsSubIssueAdHocBypass(command, managedSlug)) {
|
|
120
139
|
return {
|
|
121
140
|
decision: "deny",
|
|
122
141
|
reason:
|
|
@@ -129,7 +148,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
129
148
|
// to pulls/<n>/comments/<m>/replies, or a `gh api graphql` resolveReviewThread mutation (the Rest
|
|
130
149
|
// path names the target repo; the graphql form has no path-host repo, so it is scoped to the cwd
|
|
131
150
|
// repo). Actor-independent: reply through reply-resolve-review-thread(s).mjs.
|
|
132
|
-
if (
|
|
151
|
+
if (inManagedRepo && (commandContainsReplyResolveBypass(command, managedSlug) || commandContainsGraphqlResolveReviewThread(command))) {
|
|
133
152
|
return {
|
|
134
153
|
decision: "deny",
|
|
135
154
|
reason:
|
|
@@ -142,7 +161,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
142
161
|
// COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY: ad-hoc Copilot review requests — raw `gh api` writes
|
|
143
162
|
// to pulls/<n>/requested_reviewers, or a bare `/copilot` / `/copilot re-review` comment summon on the
|
|
144
163
|
// target repo. Actor-independent: request Copilot via scripts/github/request-copilot-review.mjs.
|
|
145
|
-
if (
|
|
164
|
+
if (inManagedRepo && (commandContainsCopilotRequestBypass(command, managedSlug) || commandContainsCopilotSummonComment(command))) {
|
|
146
165
|
return {
|
|
147
166
|
decision: "deny",
|
|
148
167
|
reason:
|
|
@@ -156,7 +175,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
156
175
|
// `.git` directory — a stash from one worktree can pop into another's. Block it outright on the
|
|
157
176
|
// target repo; see skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout for the
|
|
158
177
|
// stash-free alternative (git diff / a patch file / a scratch checkout).
|
|
159
|
-
if (commandContainsGitStash(command) &&
|
|
178
|
+
if (commandContainsGitStash(command) && inManagedRepo) {
|
|
160
179
|
return {
|
|
161
180
|
decision: "deny",
|
|
162
181
|
reason:
|
|
@@ -169,14 +188,19 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
169
188
|
// `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
|
|
170
189
|
// sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected.
|
|
171
190
|
if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
|
|
172
|
-
|
|
191
|
+
// The cwd repo IS the managed/target repo exactly when inManagedRepo.
|
|
192
|
+
const cwdTargets = inManagedRepo;
|
|
173
193
|
// 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
|
-
//
|
|
194
|
+
// cwd is the target, or an explicit --repo/-R equals the managed slug. An explicit repo that is
|
|
195
|
+
// PROVEN foreign (managedSlug resolves and differs) passes through; otherwise (managedSlug
|
|
196
|
+
// unresolvable) we cannot prove the explicit repo is foreign, so a managed context fails closed
|
|
197
|
+
// (in scope). DENY if ANY external-write segment is in scope.
|
|
176
198
|
const anyWriteInScope = extractRepoFlagsFromExternalWriteSegments(command).some((seg) =>
|
|
177
199
|
seg.explicitRepo == null
|
|
178
200
|
? cwdTargets
|
|
179
|
-
:
|
|
201
|
+
: managedSlug !== null
|
|
202
|
+
? seg.explicitRepo.toLowerCase() === managedSlug
|
|
203
|
+
: inManagedContext,
|
|
180
204
|
);
|
|
181
205
|
if (anyWriteInScope) {
|
|
182
206
|
return {
|
|
@@ -202,7 +226,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
202
226
|
// refused actor-independently — the main agent is the actor that performs GitHub writes, so only an
|
|
203
227
|
// actor-independent deny enforces the human-merge invariant (an agent-scoped deny would enforce
|
|
204
228
|
// nothing on the main-agent write path).
|
|
205
|
-
if (humanMergeOnly && isMerge &&
|
|
229
|
+
if (humanMergeOnly && isMerge && inManagedRepo) {
|
|
206
230
|
return {
|
|
207
231
|
decision: "deny",
|
|
208
232
|
reason:
|
|
@@ -217,7 +241,7 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
217
241
|
// rule is classified `agent` (behavioral guidance for the dev-loop driving agent); the main
|
|
218
242
|
// agent/operator retains manual wait tooling. The main agent's own sanctioned wait path is still
|
|
219
243
|
// the deterministic tools.
|
|
220
|
-
if (typeof agentType === "string" &&
|
|
244
|
+
if (typeof agentType === "string" && inManagedRepo && commandContainsDetachedWaitTool(command)) {
|
|
221
245
|
return {
|
|
222
246
|
decision: "deny",
|
|
223
247
|
reason:
|
|
@@ -234,16 +258,21 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
234
258
|
// exists yet): PR creation must flow through the canonical wrapper, which always drafts and
|
|
235
259
|
// self-assigns. This closes the draft-first hole where raw `gh pr create` opens a ready PR.
|
|
236
260
|
if (isCreate) {
|
|
237
|
-
|
|
261
|
+
// The cwd repo IS the managed/target repo exactly when inManagedRepo.
|
|
262
|
+
const cwdTargets = inManagedRepo;
|
|
238
263
|
// Evaluate scope PER create segment, not just the first: a create is in scope when it
|
|
239
264
|
// 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
|
-
//
|
|
265
|
+
// `--repo <target>` is denied regardless of cwd — unless it is PROVEN foreign (managedSlug
|
|
266
|
+
// resolves and differs); when managedSlug is unresolvable we cannot prove foreignness, so a
|
|
267
|
+
// managed context fails closed (in scope). DENY if ANY create segment is in scope — otherwise a
|
|
268
|
+
// leading out-of-scope create (`gh pr create --repo other/repo`) would short-circuit and shield
|
|
269
|
+
// a later in-scope raw create (`&& gh pr create --fill`).
|
|
243
270
|
const anyCreateInScope = extractRepoFlagsFromGhPrCreateSegments(command).some((seg) =>
|
|
244
271
|
seg.explicitRepo == null
|
|
245
272
|
? cwdTargets
|
|
246
|
-
:
|
|
273
|
+
: managedSlug !== null
|
|
274
|
+
? seg.explicitRepo.toLowerCase() === managedSlug
|
|
275
|
+
: inManagedContext,
|
|
247
276
|
);
|
|
248
277
|
if (anyCreateInScope) {
|
|
249
278
|
return {
|
|
@@ -265,15 +294,28 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
265
294
|
// When both verbs appear in a compound command, apply the stricter merge gate — if it passes,
|
|
266
295
|
// the draft_gate (a subset of the pre-merge evidence check) is also satisfied.
|
|
267
296
|
const verb = isMerge ? "gh pr merge" : "gh pr ready";
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
297
|
+
// Pass through only when EVERY gated verb segment is PROVEN foreign (explicit repo, managed slug
|
|
298
|
+
// resolves, and demonstrably differs). A segment with no explicit repo, or an unresolvable managed
|
|
299
|
+
// slug, is NOT proven foreign — fail closed (preserves the fail-closed default). Mirrors the
|
|
300
|
+
// per-segment `.some()` scoping on the create/external-write paths: a proven-foreign FIRST segment
|
|
301
|
+
// must not shield a later managed segment (`gh pr merge --repo other/x 1 && gh pr merge 2`).
|
|
302
|
+
const gatedVerbSegments = [
|
|
303
|
+
...(isMerge ? extractRepoFlagsFromGhPrMergeSegments(command) : []),
|
|
304
|
+
...(isReady ? extractRepoFlagsFromGhPrReadySegments(command) : []),
|
|
305
|
+
];
|
|
306
|
+
const allSegmentsProvenForeign =
|
|
307
|
+
gatedVerbSegments.length > 0 &&
|
|
308
|
+
gatedVerbSegments.every(
|
|
309
|
+
(seg) =>
|
|
310
|
+
seg.explicitRepo != null &&
|
|
311
|
+
managedSlug !== null &&
|
|
312
|
+
seg.explicitRepo.toLowerCase() !== managedSlug,
|
|
313
|
+
);
|
|
314
|
+
if (allSegmentsProvenForeign) {
|
|
273
315
|
return ALLOW;
|
|
274
316
|
}
|
|
275
|
-
// Only gate within the
|
|
276
|
-
if (
|
|
317
|
+
// Only gate within the managed repo.
|
|
318
|
+
if (!inManagedRepo) {
|
|
277
319
|
return ALLOW;
|
|
278
320
|
}
|
|
279
321
|
|
|
@@ -287,31 +329,38 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
287
329
|
};
|
|
288
330
|
}
|
|
289
331
|
|
|
290
|
-
|
|
291
|
-
|
|
332
|
+
// Raw `gh pr merge` is FORBIDDEN outright (RAW-GH-PR-MERGE-BYPASS): the only
|
|
333
|
+
// sanctioned merge path is the wrapper, which enforces the mandatory approver,
|
|
334
|
+
// merge class, and fresh-approval checks a bare evidence gate cannot. A
|
|
335
|
+
// gate-passed raw merge would bypass all of those, so this denies
|
|
336
|
+
// unconditionally (not only when evidence is missing) and points to the wrapper.
|
|
337
|
+
// The wrapper's own internal `gh pr merge` is a child_process spawn, never a
|
|
338
|
+
// Bash tool call, so it is never seen here.
|
|
339
|
+
if (isMerge) {
|
|
340
|
+
// This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
|
|
341
|
+
// writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
|
|
342
|
+
// is blocked here with the write never having executed. Hint the split when the command
|
|
343
|
+
// carries an evidence-writing invocation alongside the merge.
|
|
344
|
+
const alsoWritesEvidence = commandContainsEvidenceWrite(command);
|
|
292
345
|
return {
|
|
293
346
|
decision: "deny",
|
|
294
|
-
reason:
|
|
347
|
+
reason:
|
|
348
|
+
`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).` +
|
|
349
|
+
(gateError ? ` (pre-merge gate evidence check also failed: ${gateError})` : "") +
|
|
350
|
+
(alsoWritesEvidence
|
|
351
|
+
? " This command also writes gate evidence, but hooks evaluate before the command runs — never chain an evidence write with the merge."
|
|
352
|
+
: ""),
|
|
295
353
|
};
|
|
296
354
|
}
|
|
297
355
|
|
|
356
|
+
// `gh pr ready` keeps its gate-conditional behavior.
|
|
357
|
+
if (gateError) {
|
|
358
|
+
return {
|
|
359
|
+
decision: "deny",
|
|
360
|
+
reason: `${verb} blocked: draft-gate evidence check failed (${gateError}).`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
298
363
|
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
364
|
return {
|
|
316
365
|
decision: "deny",
|
|
317
366
|
reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,
|