@dev-loops/core 0.6.0 → 0.7.2
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 +7 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +247 -98
- package/src/config/extension-defaults.yaml +6 -11
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +137 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +259 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +158 -238
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +52 -8
- package/src/loop/queue-board-sync.mjs +62 -3
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -0
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -16,12 +16,9 @@
|
|
|
16
16
|
"./claude/hook-decisions": "./src/claude/hook-decisions.mjs",
|
|
17
17
|
"./config": "./src/config/config.mjs",
|
|
18
18
|
"./debt/cluster": "./src/debt/cluster.mjs",
|
|
19
|
-
"./debt/finding": "./src/debt/debt-finding.mjs",
|
|
20
19
|
"./debt/remediation-to-issue": "./src/debt/remediation-to-issue.mjs",
|
|
21
|
-
"./debt/score": "./src/debt/score.mjs",
|
|
22
20
|
"./debt/shape": "./src/debt/shape.mjs",
|
|
23
21
|
"./debt/signal": "./src/debt/debt-signal.mjs",
|
|
24
|
-
"./debt/signals": "./src/debt/deep-persona-signals.mjs",
|
|
25
22
|
"./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
|
|
26
23
|
"./github/repo-slug": "./src/github/repo-slug.mjs",
|
|
27
24
|
"./github/review-threads": "./src/github/review-threads.mjs",
|
|
@@ -41,8 +38,10 @@
|
|
|
41
38
|
"./loop/plan-file-promote-contract": "./src/loop/plan-file-promote-contract.mjs",
|
|
42
39
|
"./loop/plan-file-refine-contract": "./src/loop/plan-file-refine-contract.mjs",
|
|
43
40
|
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
41
|
+
"./loop/pr-lifecycle": "./src/loop/pr-lifecycle.mjs",
|
|
44
42
|
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
45
43
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
44
|
+
"./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
|
|
46
45
|
"./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
|
|
47
46
|
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
48
47
|
"./loop/queue-membership": "./src/loop/queue-membership.mjs",
|
|
@@ -57,7 +56,9 @@
|
|
|
57
56
|
"./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
|
|
58
57
|
"./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
|
|
59
58
|
"./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
|
|
60
|
-
"./
|
|
59
|
+
"./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
|
|
60
|
+
"./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
|
|
61
|
+
"./projects/resolve-project": "./src/projects/resolve-project.mjs",
|
|
61
62
|
"./harness": "./src/harness/index.mjs",
|
|
62
63
|
"./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
|
|
63
64
|
"./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
|
|
@@ -65,8 +66,7 @@
|
|
|
65
66
|
"bin": {
|
|
66
67
|
"dev-loops-log-bash-exit-1": "./bin/log-bash-exit-1.mjs",
|
|
67
68
|
"dev-loops-ensure-phase-files": "./bin/ensure-phase-files.mjs",
|
|
68
|
-
"dev-loops-parse-review-threads": "./bin/parse-review-threads.mjs"
|
|
69
|
-
"dev-loops-capture-deep-persona-signals": "./bin/capture-deep-persona-signals.mjs"
|
|
69
|
+
"dev-loops-parse-review-threads": "./bin/parse-review-threads.mjs"
|
|
70
70
|
},
|
|
71
71
|
"files": [
|
|
72
72
|
"src/**/*.mjs",
|
|
@@ -51,9 +51,11 @@ const CATEGORY_ANGLE_MAP = {
|
|
|
51
51
|
[ChangeCategory.COMMENT_ONLY]: [
|
|
52
52
|
"dry",
|
|
53
53
|
],
|
|
54
|
+
// Core review subset for any non-trivial code change. Peripheral lenses
|
|
55
|
+
// (ci-guard, link-check, packaging-runtime, config-drift, etc.) are pulled in
|
|
56
|
+
// only when the diff's other categories implicate them, not by logic alone.
|
|
54
57
|
[ChangeCategory.LOGIC_CHANGE]: [
|
|
55
|
-
"
|
|
56
|
-
"ocp", "lsp", "isp", "dip", "yagni", "scope", "no-op", "determinism",
|
|
58
|
+
"scope", "correctness", "coverage", "determinism", "contract-surface",
|
|
57
59
|
],
|
|
58
60
|
};
|
|
59
61
|
|
|
@@ -70,28 +72,43 @@ const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr-descri
|
|
|
70
72
|
|
|
71
73
|
/**
|
|
72
74
|
* @typedef {object} DynamicAngleResult
|
|
73
|
-
* @property {string[]} recommendedAngles — angles to run
|
|
75
|
+
* @property {string[]} recommendedAngles — angles to run, limited to the
|
|
76
|
+
* configured pool (subtractive result); in additive mode the caller
|
|
77
|
+
* merges addedAngles on top to form the full effective run set
|
|
74
78
|
* @property {string[]} skippedAngles — angles skipped with reasons
|
|
75
79
|
* @property {Record<string, string>} reasons — why each angle was skipped
|
|
76
80
|
* @property {boolean} fallbackToAll — true when ambiguous → all angles recommended
|
|
81
|
+
* @property {string[]} addedAngles — catalog angles added (additive mode only, see #1048)
|
|
82
|
+
* @property {Record<string, string>} addedReasons — why each added angle was added
|
|
77
83
|
*/
|
|
78
84
|
|
|
79
85
|
/**
|
|
80
86
|
* Resolve which gate angles to run based on detected change categories.
|
|
81
87
|
*
|
|
82
|
-
* When the diff is ambiguous (
|
|
83
|
-
* all configured angles are recommended (fallback-to-all).
|
|
88
|
+
* When the diff is ambiguous (no detected categories / analysis failure),
|
|
89
|
+
* all configured angles are recommended (fallback-to-all). A LOGIC_CHANGE
|
|
90
|
+
* diff resolves to its core review subset, not fallback-to-all.
|
|
91
|
+
*
|
|
92
|
+
* When `anglePool` is provided (additive mode, see #1048), catalog angles in
|
|
93
|
+
* the pool that the change categories recommend but that are not already in
|
|
94
|
+
* `configuredAngles` are additively selected and reported as `addedAngles`.
|
|
95
|
+
* When `anglePool` is omitted, additive mode is off and `addedAngles` is
|
|
96
|
+
* always empty.
|
|
84
97
|
*
|
|
85
98
|
* @param {object} options
|
|
86
99
|
* @param {string[]} options.configuredAngles — all angles configured for this gate
|
|
87
100
|
* @param {string[]} options.changeCategories — from diff analysis
|
|
88
101
|
* @param {boolean} [options.ambiguous] — from diff analysis
|
|
102
|
+
* @param {string[]} [options.anglePool] — catalog of angles eligible for additive
|
|
103
|
+
* selection (caller pre-filters this against excludeAngles); when undefined,
|
|
104
|
+
* additive selection is disabled
|
|
89
105
|
* @returns {DynamicAngleResult}
|
|
90
106
|
*/
|
|
91
107
|
export function resolveDynamicAngles({
|
|
92
108
|
configuredAngles,
|
|
93
109
|
changeCategories,
|
|
94
110
|
ambiguous = false,
|
|
111
|
+
anglePool,
|
|
95
112
|
}) {
|
|
96
113
|
// Fallback: ambiguous diff → all angles
|
|
97
114
|
if (ambiguous) {
|
|
@@ -100,6 +117,8 @@ export function resolveDynamicAngles({
|
|
|
100
117
|
skippedAngles: [],
|
|
101
118
|
reasons: {},
|
|
102
119
|
fallbackToAll: true,
|
|
120
|
+
addedAngles: [],
|
|
121
|
+
addedReasons: {},
|
|
103
122
|
};
|
|
104
123
|
}
|
|
105
124
|
|
|
@@ -110,21 +129,30 @@ export function resolveDynamicAngles({
|
|
|
110
129
|
skippedAngles: [],
|
|
111
130
|
reasons: {},
|
|
112
131
|
fallbackToAll: true,
|
|
132
|
+
addedAngles: [],
|
|
133
|
+
addedReasons: {},
|
|
113
134
|
};
|
|
114
135
|
}
|
|
115
136
|
|
|
116
|
-
// Build recommended set from category union
|
|
137
|
+
// Build recommended set from category union, tracking the first trigger per angle
|
|
117
138
|
const recommended = new Set();
|
|
139
|
+
const triggers = new Map();
|
|
118
140
|
for (const cat of changeCategories) {
|
|
119
141
|
const angles = CATEGORY_ANGLE_MAP[cat] ?? [];
|
|
120
142
|
for (const angle of angles) {
|
|
121
143
|
recommended.add(angle);
|
|
144
|
+
if (!triggers.has(angle)) {
|
|
145
|
+
triggers.set(angle, cat);
|
|
146
|
+
}
|
|
122
147
|
}
|
|
123
148
|
}
|
|
124
149
|
|
|
125
150
|
// Always-include angles
|
|
126
151
|
for (const angle of ALWAYS_INCLUDE) {
|
|
127
152
|
recommended.add(angle);
|
|
153
|
+
if (!triggers.has(angle)) {
|
|
154
|
+
triggers.set(angle, "always-include");
|
|
155
|
+
}
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
// Filter to only angles that are configured
|
|
@@ -137,10 +165,26 @@ export function resolveDynamicAngles({
|
|
|
137
165
|
reasons[angle] = `Skipped: detected categories (${changeCategories.join(", ") || "none"}) do not trigger this angle`;
|
|
138
166
|
}
|
|
139
167
|
|
|
168
|
+
// Additive: pull in recommended catalog angles not already configured (#1048)
|
|
169
|
+
const configuredSet = new Set(configuredAngles);
|
|
170
|
+
const anglePoolSet = Array.isArray(anglePool) ? new Set(anglePool) : null;
|
|
171
|
+
const addedAngles = anglePoolSet
|
|
172
|
+
? [...recommended].filter((a) => anglePoolSet.has(a) && !configuredSet.has(a))
|
|
173
|
+
: [];
|
|
174
|
+
const addedReasons = {};
|
|
175
|
+
for (const angle of addedAngles) {
|
|
176
|
+
const trigger = triggers.get(angle);
|
|
177
|
+
addedReasons[angle] = trigger === "always-include"
|
|
178
|
+
? "Added: always-include lens not in the configured pool"
|
|
179
|
+
: `Added: triggered by change category ${trigger}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
140
182
|
return {
|
|
141
183
|
recommendedAngles,
|
|
142
184
|
skippedAngles,
|
|
143
185
|
reasons,
|
|
144
186
|
fallbackToAll: false,
|
|
187
|
+
addedAngles,
|
|
188
|
+
addedReasons,
|
|
145
189
|
};
|
|
146
190
|
}
|
|
@@ -199,13 +199,16 @@ export function analyzeT1(diffOutput, t0) {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// Build categories from T0 + hunk analysis
|
|
203
|
-
|
|
204
|
-
if (t0.allDocs) categories.add("DOCS_ONLY");
|
|
205
|
-
if (t0.files.every((f) => classifyFile(f) === "config")) categories.add("CONFIG_ONLY");
|
|
206
|
-
if (t0.files.every((f) => classifyFile(f) === "test")) categories.add("TEST_ONLY");
|
|
207
|
-
if (t0.files.every((f) => classifyFile(f) === "ci")) categories.add("CI_ONLY");
|
|
202
|
+
// Build categories from T0 (shared with inferCategoriesFromT0) + hunk analysis.
|
|
203
|
+
for (const c of t0FileCategories(t0)) categories.add(c);
|
|
208
204
|
if (hasLogicChange) categories.add("LOGIC_CHANGE");
|
|
205
|
+
// Mixed diffs never satisfy the exclusive `_ONLY` checks above (some files are
|
|
206
|
+
// code), so their peripheral surfaces would be dropped. In this hunk-level path
|
|
207
|
+
// (only reached for genuinely mixed diffs), also union each surface by PRESENCE
|
|
208
|
+
// so e.g. a code+workflow diff pulls ci-guard alongside the LOGIC_CHANGE core
|
|
209
|
+
// (AC: mixed logic+CI -> core union ci-guard). The pure single-surface path
|
|
210
|
+
// (inferCategoriesFromT0) keeps exclusive semantics.
|
|
211
|
+
for (const c of t0PresentSurfaceCategories(t0)) categories.add(c);
|
|
209
212
|
|
|
210
213
|
// COMMENT_ONLY: hunkCount > 0 (real diff), has changed lines, all are non-logic,
|
|
211
214
|
// and not a rename-only change
|
|
@@ -232,18 +235,64 @@ export function analyzeT1(diffOutput, t0) {
|
|
|
232
235
|
*/
|
|
233
236
|
|
|
234
237
|
/**
|
|
235
|
-
*
|
|
238
|
+
* Categories derivable from T0 file classification alone (no hunk content).
|
|
239
|
+
* Shared by analyzeT1 (which adds hunk-derived LOGIC_CHANGE/COMMENT_ONLY on top)
|
|
240
|
+
* and inferCategoriesFromT0 (the no-T1 path). All checks are length-guarded so an
|
|
241
|
+
* empty file list yields no category.
|
|
236
242
|
*
|
|
237
243
|
* @param {T0Result} t0
|
|
238
244
|
* @returns {string[]}
|
|
239
245
|
*/
|
|
240
|
-
function
|
|
246
|
+
function t0FileCategories(t0) {
|
|
247
|
+
if (t0.files.length === 0) return [];
|
|
241
248
|
const categories = [];
|
|
242
249
|
if (t0.renameOnly) categories.push("RENAME_ONLY");
|
|
243
250
|
if (t0.allDocs) categories.push("DOCS_ONLY");
|
|
244
|
-
if (t0.files.
|
|
245
|
-
if (t0.files.
|
|
246
|
-
if (t0.files.
|
|
251
|
+
if (t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
|
|
252
|
+
if (t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
|
|
253
|
+
if (t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
|
|
254
|
+
return categories;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Surface categories present in a MIXED diff (at least one file of the surface),
|
|
259
|
+
* used only by the hunk-level path to union a mixed diff's peripheral lenses on
|
|
260
|
+
* top of LOGIC_CHANGE. Reuses the same category names / angle mappings as the
|
|
261
|
+
* exclusive path; presence (not exclusivity) is the correct trigger for a mixed
|
|
262
|
+
* diff. Renames are handled by the exclusive path, so they are excluded here.
|
|
263
|
+
*
|
|
264
|
+
* @param {T0Result} t0
|
|
265
|
+
* @returns {string[]}
|
|
266
|
+
*/
|
|
267
|
+
function t0PresentSurfaceCategories(t0) {
|
|
268
|
+
const categories = [];
|
|
269
|
+
const cats = new Set(t0.files.map(classifyFile));
|
|
270
|
+
if (cats.has("docs")) categories.push("DOCS_ONLY");
|
|
271
|
+
if (cats.has("config")) categories.push("CONFIG_ONLY");
|
|
272
|
+
if (cats.has("test")) categories.push("TEST_ONLY");
|
|
273
|
+
if (cats.has("ci")) categories.push("CI_ONLY");
|
|
274
|
+
return categories;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Infer change categories from T0 analysis when T1 (hunk-level) is not run.
|
|
279
|
+
* Reuses the shared T0 file-category derivation, then adds the pure-code
|
|
280
|
+
* LOGIC_CHANGE inference that the hunk-level path would otherwise supply.
|
|
281
|
+
*
|
|
282
|
+
* @param {T0Result} t0
|
|
283
|
+
* @returns {string[]}
|
|
284
|
+
*/
|
|
285
|
+
function inferCategoriesFromT0(t0) {
|
|
286
|
+
const categories = t0FileCategories(t0);
|
|
287
|
+
// Pure code-only change: a diff whose files all classify as code (and is not a
|
|
288
|
+
// rename) is a LOGIC_CHANGE. Without this, an all-code diff has a single file
|
|
289
|
+
// category (so analyzeDiff never runs hunk-level T1) and produces no category,
|
|
290
|
+
// which resolveDynamicAngles treats as "unclassifiable" → fallback-to-all. That
|
|
291
|
+
// regressed the primary case: a code-only PR must resolve to the LOGIC_CHANGE
|
|
292
|
+
// core review subset, not all angles.
|
|
293
|
+
if (!t0.renameOnly && t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "code")) {
|
|
294
|
+
categories.push("LOGIC_CHANGE");
|
|
295
|
+
}
|
|
247
296
|
return categories;
|
|
248
297
|
}
|
|
249
298
|
|
|
@@ -279,7 +328,14 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
|
|
|
279
328
|
};
|
|
280
329
|
}
|
|
281
330
|
|
|
282
|
-
|
|
331
|
+
// `ambiguous` flags one specific case: a diff T0 could not classify (mixed file
|
|
332
|
+
// categories, so t0Ambiguous) AND whose hunk analysis still produced no
|
|
333
|
+
// category. It is NOT the only fallback trigger — resolveDynamicAngles also
|
|
334
|
+
// falls back whenever changeCategories is empty (e.g. a single lone unknown/
|
|
335
|
+
// asset file yields no category yet is not t0Ambiguous). A mixed diff that
|
|
336
|
+
// yields a category (e.g. LOGIC_CHANGE) is classified and not ambiguous, so
|
|
337
|
+
// LOGIC_CHANGE never forces fallback-to-all via this flag.
|
|
338
|
+
const ambiguous = t0Ambiguous && t1.changeCategories.length === 0;
|
|
283
339
|
|
|
284
340
|
return { t0, t1, ambiguous };
|
|
285
341
|
}
|
|
@@ -11,9 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
import { resolveRunId } from "../loop/run-context.mjs";
|
|
13
13
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
commandContainsGhPrReady,
|
|
15
|
+
commandContainsGhPrMerge,
|
|
16
|
+
commandContainsGhPrCreate,
|
|
17
|
+
extractPrNumberFromGhPrReadyAnywhere,
|
|
18
|
+
extractRepoFlagFromGhPrReadyAnywhere,
|
|
19
|
+
extractPrNumberFromGhPrMergeAnywhere,
|
|
20
|
+
extractRepoFlagFromGhPrMergeAnywhere,
|
|
21
|
+
extractRepoFlagsFromGhPrCreateSegments,
|
|
22
|
+
commandContainsRawExternalWrite,
|
|
23
|
+
extractRepoFlagsFromExternalWriteSegments,
|
|
17
24
|
TARGET_REPO_SLUG,
|
|
18
25
|
} from "../loop/bash-command-classify.mjs";
|
|
19
26
|
|
|
@@ -25,6 +32,16 @@ import {
|
|
|
25
32
|
|
|
26
33
|
const ALLOW = Object.freeze({ decision: "allow" });
|
|
27
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Whether the command string also invokes an evidence-writing script (findings-log ledger or
|
|
37
|
+
* checkpoint-verdict upsert). Used only to enrich the merge-block message (#1172) — a compound
|
|
38
|
+
* command combining an evidence write with `gh pr merge` is blocked pre-execution, so the write
|
|
39
|
+
* never runs; this substring check has no false-negative cost (worst case: the plain message).
|
|
40
|
+
*/
|
|
41
|
+
function commandContainsEvidenceWrite(command) {
|
|
42
|
+
return command.includes("write-gate-findings-log") || command.includes("upsert-checkpoint-verdict");
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
/**
|
|
29
46
|
* The agent type (Claude `agent_type` / the canonical agent name) that owns repo mutations.
|
|
30
47
|
* Only this subagent — not arbitrary subagents (Explore, Plan, generic Task agents) — may
|
|
@@ -33,26 +50,114 @@ const ALLOW = Object.freeze({ decision: "allow" });
|
|
|
33
50
|
export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
34
51
|
|
|
35
52
|
/**
|
|
36
|
-
* Decide whether a PreToolUse Bash command must be blocked by
|
|
53
|
+
* Decide whether a PreToolUse Bash command must be blocked by a dev-loop gate boundary.
|
|
54
|
+
*
|
|
55
|
+
* Three gated commands on the target repo:
|
|
56
|
+
* - `gh pr create` — blocked outright; PR creation must flow through the canonical wrapper
|
|
57
|
+
* (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always drafts and
|
|
58
|
+
* self-assigns. Closes the hole where raw `gh pr create` opens a ready PR, bypassing draft-first.
|
|
59
|
+
* - `gh pr ready` — blocked without clean draft_gate evidence (`pre-pr-ready-gate`).
|
|
60
|
+
* - `gh pr merge` — blocked without the full pre-merge gate evidence (`detect-checkpoint-evidence`:
|
|
61
|
+
* clean current-head draft_gate + pre_approval_gate). The loop runs this check before merging;
|
|
62
|
+
* gating it here closes the hole where a hand-run `gh pr merge` skips the pre-approval gate
|
|
63
|
+
* entirely. Everything else passes through.
|
|
64
|
+
* - raw `gh issue create` / `gh issue comment` / `gh pr comment` — blocked ONLY when the call
|
|
65
|
+
* originates from a SUBAGENT context (`agentType` is a non-null string) and targets the repo.
|
|
66
|
+
* Sanctioned external writes flow through node wrappers (gate-verdict comments via
|
|
67
|
+
* `upsert-checkpoint-verdict.mjs`, review replies via `reply-resolve*.mjs`, board sync,
|
|
68
|
+
* `comment-issue.mjs`), whose Bash command string is `node scripts/…` and never matches these
|
|
69
|
+
* raw-`gh` matchers. The MAIN AGENT / operator (agentType null) retains direct `gh issue
|
|
70
|
+
* create` — that path is authorized (#1051).
|
|
37
71
|
*
|
|
38
|
-
*
|
|
39
|
-
* target repo without clean draft_gate evidence. Everything else (including merges, which
|
|
40
|
-
* trigger the post-merge step, not a block) is allowed through.
|
|
72
|
+
* The hook computes `gatePassed`/`gateError` from the gate script appropriate to the command kind.
|
|
41
73
|
*
|
|
42
74
|
* @param {Object} params
|
|
43
75
|
* @param {string} params.command - The Bash command string.
|
|
44
76
|
* @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
|
|
45
|
-
* @param {boolean} [params.gatePassed] - Whether
|
|
77
|
+
* @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
|
|
46
78
|
* @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
|
|
79
|
+
* @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
|
|
80
|
+
* string inside a subagent, null in the main agent. Scopes the external-write guard.
|
|
47
81
|
* @returns {HookDecision}
|
|
48
82
|
*/
|
|
49
|
-
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null }) {
|
|
50
|
-
if (typeof command !== "string"
|
|
83
|
+
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null }) {
|
|
84
|
+
if (typeof command !== "string") {
|
|
85
|
+
return ALLOW;
|
|
86
|
+
}
|
|
87
|
+
// Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
|
|
88
|
+
// `gh pr comment` on the target repo from a subagent, so external writes flow through the
|
|
89
|
+
// sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
|
|
90
|
+
if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
|
|
91
|
+
const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
|
|
92
|
+
// Scope PER segment, mirroring the `gh pr create` block: in scope when no explicit --repo and
|
|
93
|
+
// cwd is the target, or an explicit --repo/-R equals the target. An explicit non-target --repo
|
|
94
|
+
// passes through. DENY if ANY external-write segment is in scope.
|
|
95
|
+
const anyWriteInScope = extractRepoFlagsFromExternalWriteSegments(command).some((seg) =>
|
|
96
|
+
seg.explicitRepo == null
|
|
97
|
+
? cwdTargets
|
|
98
|
+
: seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
|
|
99
|
+
);
|
|
100
|
+
if (anyWriteInScope) {
|
|
101
|
+
return {
|
|
102
|
+
decision: "deny",
|
|
103
|
+
reason:
|
|
104
|
+
"Ad-hoc GitHub issue/PR creation and comments from a subagent are blocked. Use the sanctioned " +
|
|
105
|
+
"node wrappers instead — gate-verdict comments via scripts/github/upsert-checkpoint-verdict.mjs, " +
|
|
106
|
+
"review-thread replies via scripts/github/reply-resolve*.mjs, board sync, or scripts/github/comment-issue.mjs. " +
|
|
107
|
+
"Direct `gh issue create` is reserved for the main agent / operator.",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Scan ALL shell segments — the PreToolUse gate blocks pre-emptively, so a gated verb in any
|
|
112
|
+
// segment (even after `&&` or `;`) must be caught. This differs from the Pi extension's
|
|
113
|
+
// post-execute `isGhPrReadyCommand`/`isGhPrMergeCommand` which scan only the first segment
|
|
114
|
+
// (correct there: `false && gh pr ready 42` short-circuits so ready never ran).
|
|
115
|
+
const isReady = commandContainsGhPrReady(command);
|
|
116
|
+
const isMerge = commandContainsGhPrMerge(command);
|
|
117
|
+
const isCreate = commandContainsGhPrCreate(command);
|
|
118
|
+
if (!isReady && !isMerge && !isCreate) {
|
|
51
119
|
return ALLOW;
|
|
52
120
|
}
|
|
53
121
|
|
|
122
|
+
// Raw `gh pr create` is blocked outright on the target repo (no PR number / gate evidence
|
|
123
|
+
// exists yet): PR creation must flow through the canonical wrapper, which always drafts and
|
|
124
|
+
// self-assigns. This closes the draft-first hole where raw `gh pr create` opens a ready PR.
|
|
125
|
+
if (isCreate) {
|
|
126
|
+
const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
|
|
127
|
+
// Evaluate scope PER create segment, not just the first: a create is in scope when it
|
|
128
|
+
// explicitly targets the repo, or (with no explicit --repo) the cwd is the repo. An explicit
|
|
129
|
+
// `--repo <target>` is denied regardless of cwd (#1047). DENY if ANY create segment is in
|
|
130
|
+
// scope — otherwise a leading out-of-scope create (`gh pr create --repo other/repo`) would
|
|
131
|
+
// short-circuit and shield a later in-scope raw create (`&& gh pr create --fill`).
|
|
132
|
+
const anyCreateInScope = extractRepoFlagsFromGhPrCreateSegments(command).some((seg) =>
|
|
133
|
+
seg.explicitRepo == null
|
|
134
|
+
? cwdTargets
|
|
135
|
+
: seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
|
|
136
|
+
);
|
|
137
|
+
if (anyCreateInScope) {
|
|
138
|
+
return {
|
|
139
|
+
decision: "deny",
|
|
140
|
+
reason:
|
|
141
|
+
"gh pr create blocked: open PRs via the canonical wrapper `node scripts/github/create-pr.mjs` " +
|
|
142
|
+
"(a.k.a. `dev-loops pr create`), which always creates a draft and self-assigns. Raw `gh pr create` " +
|
|
143
|
+
"defaults to ready-for-review and bypasses the draft-first contract (workflow.requireDraftFirst).",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// The create is out of scope. Only allow outright when there is no ready/merge segment to
|
|
147
|
+
// evaluate — otherwise fall through so a gated `gh pr ready`/`gh pr merge` in the same
|
|
148
|
+
// compound command (e.g. `gh pr create --repo other/repo && gh pr merge 5`) is still gated
|
|
149
|
+
// below rather than short-circuited.
|
|
150
|
+
if (!isReady && !isMerge) {
|
|
151
|
+
return ALLOW;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// When both verbs appear in a compound command, apply the stricter merge gate — if it passes,
|
|
155
|
+
// the draft_gate (a subset of the pre-merge evidence check) is also satisfied.
|
|
156
|
+
const verb = isMerge ? "gh pr merge" : "gh pr ready";
|
|
54
157
|
// An explicit `--repo other/repo` that is not the target → not our concern, pass through.
|
|
55
|
-
const explicitRepo =
|
|
158
|
+
const explicitRepo = isMerge
|
|
159
|
+
? extractRepoFlagFromGhPrMergeAnywhere(command)
|
|
160
|
+
: extractRepoFlagFromGhPrReadyAnywhere(command);
|
|
56
161
|
if (explicitRepo && explicitRepo.toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
|
|
57
162
|
return ALLOW;
|
|
58
163
|
}
|
|
@@ -61,23 +166,41 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
61
166
|
return ALLOW;
|
|
62
167
|
}
|
|
63
168
|
|
|
64
|
-
const prNumber =
|
|
169
|
+
const prNumber = isMerge
|
|
170
|
+
? extractPrNumberFromGhPrMergeAnywhere(command)
|
|
171
|
+
: extractPrNumberFromGhPrReadyAnywhere(command);
|
|
65
172
|
if (prNumber === null) {
|
|
66
173
|
return {
|
|
67
174
|
decision: "deny",
|
|
68
|
-
reason:
|
|
69
|
-
"gh pr ready blocked: could not determine the PR number from the command. Include the PR number explicitly.",
|
|
175
|
+
reason: `${verb} blocked: could not determine the PR number from the command. Include the PR number explicitly.`,
|
|
70
176
|
};
|
|
71
177
|
}
|
|
72
178
|
|
|
73
179
|
if (gateError) {
|
|
180
|
+
const which = isMerge ? "pre-merge gate" : "draft-gate";
|
|
74
181
|
return {
|
|
75
182
|
decision: "deny",
|
|
76
|
-
reason:
|
|
183
|
+
reason: `${verb} blocked: ${which} evidence check failed (${gateError}).`,
|
|
77
184
|
};
|
|
78
185
|
}
|
|
79
186
|
|
|
80
187
|
if (!gatePassed) {
|
|
188
|
+
if (isMerge) {
|
|
189
|
+
// This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
|
|
190
|
+
// writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
|
|
191
|
+
// is blocked here with the write never having executed, which looks like the evidence
|
|
192
|
+
// "vanished" (#1172). Hint the split when the command carries an evidence-writing invocation
|
|
193
|
+
// alongside the merge, so the failure is self-explaining instead of looking like data loss.
|
|
194
|
+
const alsoWritesEvidence = commandContainsEvidenceWrite(command);
|
|
195
|
+
return {
|
|
196
|
+
decision: "deny",
|
|
197
|
+
reason:
|
|
198
|
+
`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.` +
|
|
199
|
+
(alsoWritesEvidence
|
|
200
|
+
? " This command also writes gate evidence, but hooks evaluate before the command runs — write the evidence in a separate call, then merge alone."
|
|
201
|
+
: ""),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
81
204
|
return {
|
|
82
205
|
decision: "deny",
|
|
83
206
|
reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,
|