dev-loops 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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +8 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +2 -2
- package/.claude/agents/review.md +4 -3
- package/.claude/commands/{auto.md → loop-auto.md} +1 -1
- package/.claude/commands/loop-continue.md +15 -0
- package/.claude/commands/loop-enqueue.md +22 -0
- package/.claude/commands/loop-grill.md +17 -0
- package/.claude/commands/{info.md → loop-info.md} +2 -2
- package/.claude/commands/loop-queue-status.md +24 -0
- package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
- package/.claude/commands/{start.md → loop-start.md} +1 -1
- package/.claude/commands/{status.md → loop-status.md} +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +333 -29
- package/.claude/hooks/_hook-decisions.mjs +138 -15
- package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
- package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
- package/.claude/skills/dev-loop/SKILL.md +16 -12
- package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
- package/.claude/skills/docs/anti-patterns.md +5 -3
- package/.claude/skills/docs/artifact-authority-contract.md +30 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +19 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +26 -19
- package/.claude/skills/docs/local-planning-flow.md +1 -1
- package/.claude/skills/docs/local-planning-worked-example.md +1 -1
- package/.claude/skills/docs/merge-preconditions.md +30 -12
- package/.claude/skills/docs/plan-file-contract.md +1 -1
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
- package/.claude/skills/local-implementation/SKILL.md +62 -145
- package/.claude/skills/loop-grill/SKILL.md +163 -0
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +101 -0
- package/README.md +9 -9
- package/agents/dev-loop.agent.md +11 -10
- package/agents/developer.agent.md +1 -1
- package/agents/docs.agent.md +1 -1
- package/agents/fixer.agent.md +2 -1
- package/agents/quality.agent.md +1 -1
- package/agents/refiner.agent.md +3 -3
- package/agents/review.agent.md +5 -4
- package/cli/index.mjs +35 -42
- package/extension/README.md +4 -4
- package/extension/checks.ts +1 -5
- package/extension/harness-types.ts +1 -0
- package/extension/index.ts +6 -0
- package/extension/pi-extension-adapter.ts +2 -0
- package/extension/presentation.ts +7 -19
- package/package.json +12 -12
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -9
- package/scripts/github/comment-issue.mjs +2 -9
- package/scripts/github/create-label.mjs +133 -0
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
- package/scripts/github/detect-linked-issue-pr.mjs +22 -6
- package/scripts/github/edit-pr.mjs +259 -0
- package/scripts/github/fetch-ci-logs.mjs +2 -9
- package/scripts/github/list-issues.mjs +2 -9
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/manage-sub-issues.mjs +46 -10
- package/scripts/github/offer-human-handoff.mjs +8 -5
- package/scripts/github/post-gate-findings.mjs +20 -5
- package/scripts/github/probe-ci-status.mjs +8 -2
- package/scripts/github/probe-copilot-review.mjs +21 -14
- package/scripts/github/ready-for-review.mjs +26 -8
- package/scripts/github/reconcile-draft-gate.mjs +7 -3
- package/scripts/github/reply-resolve-review-thread.mjs +16 -5
- package/scripts/github/reply-resolve-review-threads.mjs +69 -7
- package/scripts/github/request-copilot-review.mjs +98 -26
- package/scripts/github/resolve-handoff-candidates.mjs +7 -3
- package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
- package/scripts/github/stage-reviewer-draft.mjs +10 -2
- package/scripts/github/tick-verified-checkboxes.mjs +202 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
- package/scripts/github/verify-briefing-prefixes.mjs +191 -0
- package/scripts/github/verify-fresh-review-context.mjs +226 -32
- package/scripts/github/view-pr.mjs +150 -0
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +458 -65
- package/scripts/github/write-gate-findings-log.mjs +128 -2
- package/scripts/lib/jq-output.mjs +18 -0
- package/scripts/loop/_post-convergence-change.mjs +211 -0
- package/scripts/loop/_pr-runner-coordination.mjs +70 -0
- package/scripts/loop/_repo-root-resolver.mjs +47 -0
- package/scripts/loop/build-handoff-envelope.mjs +14 -4
- package/scripts/loop/check-retro-tooling.mjs +14 -3
- package/scripts/loop/checkpoint-contract.mjs +7 -4
- package/scripts/loop/cleanup-worktree.mjs +12 -3
- package/scripts/loop/conductor-monitor.mjs +12 -18
- package/scripts/loop/copilot-pr-handoff.mjs +162 -14
- package/scripts/loop/debt-remediate.mjs +24 -12
- package/scripts/loop/detect-change-scope.mjs +38 -9
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
- package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
- package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
- package/scripts/loop/detect-internal-only-pr.mjs +8 -2
- package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
- package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
- package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
- package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
- package/scripts/loop/ensure-worktree.mjs +8 -3
- package/scripts/loop/info.mjs +12 -4
- package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
- package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
- package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
- package/scripts/loop/inspect-run-viewer.mjs +67 -4
- package/scripts/loop/inspect-run.mjs +8 -2
- package/scripts/loop/outer-loop.mjs +8 -4
- package/scripts/loop/pr-runner-coordination.mjs +2 -9
- package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
- package/scripts/loop/pre-flight-gate.mjs +9 -9
- package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
- package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
- package/scripts/loop/provision-worktree.mjs +74 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
- package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
- package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
- package/scripts/loop/run-conductor-cycle.mjs +8 -2
- package/scripts/loop/run-queue.mjs +18 -9
- package/scripts/loop/run-refinement-audit.mjs +8 -9
- package/scripts/loop/run-watch-cycle.mjs +2 -9
- package/scripts/loop/sanctioned-commands.mjs +106 -0
- package/scripts/loop/steer-loop.mjs +29 -16
- package/scripts/loop/validate-pr-body-spec.mjs +223 -0
- package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
- package/scripts/pages/build-site.mjs +59 -8
- package/scripts/pages/build-state-atlas.mjs +443 -0
- package/scripts/projects/_resolve-project.mjs +1 -0
- package/scripts/projects/add-queue-item.mjs +60 -54
- package/scripts/projects/archive-done-items.mjs +49 -84
- package/scripts/projects/ensure-queue-board.mjs +10 -36
- package/scripts/projects/list-queue-items.mjs +59 -382
- package/scripts/projects/move-queue-item.mjs +21 -449
- package/scripts/projects/reconcile-queue.mjs +253 -0
- package/scripts/projects/reorder-queue-item.mjs +43 -68
- package/scripts/projects/resolve-active-board-item.mjs +108 -46
- package/scripts/projects/sync-item-status.mjs +15 -10
- package/scripts/refine/_refine-helpers.mjs +21 -5
- package/scripts/refine/exit-spike.mjs +18 -8
- package/scripts/refine/promote-plan.mjs +22 -16
- package/scripts/refine/prose-linkage-detector.mjs +1 -1
- package/scripts/refine/refine-plan-file.mjs +13 -4
- package/scripts/refine/refinement-completeness-checker.mjs +1 -1
- package/scripts/refine/scaffold-spike-file.mjs +4 -8
- package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
- package/scripts/refine/tree-integrity-validator.mjs +1 -1
- package/scripts/refine/validate-plan-file.mjs +1 -1
- package/scripts/refine/validate-spike-file.mjs +1 -1
- package/scripts/refine/verify.mjs +11 -6
- package/scripts/release/assert-core-dependency-version.mjs +123 -0
- package/scripts/release/extract-changelog-section.mjs +16 -2
- package/skills/copilot-pr-followup/SKILL.md +60 -34
- package/skills/dev-loop/SKILL.md +11 -7
- package/skills/docs/acceptance-criteria-verification.md +8 -3
- package/skills/docs/anti-patterns.md +5 -3
- package/skills/docs/artifact-authority-contract.md +30 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +19 -15
- package/skills/docs/cross-harness-regression-contract.md +60 -0
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +26 -19
- package/skills/docs/local-planning-flow.md +1 -1
- package/skills/docs/local-planning-worked-example.md +1 -1
- package/skills/docs/merge-preconditions.md +30 -12
- package/skills/docs/plan-file-contract.md +1 -1
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +60 -44
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +106 -79
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/ui-e2e-scoping-step.md +32 -0
- package/skills/docs/validation-policy.md +4 -5
- package/skills/docs/workflow-handoff-contract.md +39 -0
- package/skills/local-implementation/SKILL.md +62 -145
- package/skills/loop-grill/SKILL.md +165 -0
- package/.claude/commands/continue.md +0 -15
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
- package/scripts/loop/conductor.mjs +0 -233
- package/scripts/loop/detect-stale-runner.mjs +0 -265
- package/scripts/loop/pre-push-main-guard.mjs +0 -117
|
@@ -15,6 +15,53 @@ export const TARGET_REPO_SLUG = "mfittko/dev-loops";
|
|
|
15
15
|
/** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
|
|
16
16
|
export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Shell command separators that terminate one segment and begin the next.
|
|
20
|
+
* Newline (`\n`) and carriage return (`\r`) are full command terminators in bash
|
|
21
|
+
* (equivalent to `;`), and the Claude Code Bash tool accepts multi-line command
|
|
22
|
+
* strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
|
|
23
|
+
* the gate. Used by all segment-splitting sites (DRY).
|
|
24
|
+
*/
|
|
25
|
+
const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
|
|
29
|
+
* A repo flag value may reach us quoted (`--repo 'owner/name'`); the scope check compares against
|
|
30
|
+
* the bare slug, so quotes must be normalized or a quoted on-target repo evades the guard (#1074).
|
|
31
|
+
* ponytail: single balanced pair only — no full shell tokenization (mismatched/partial quotes stay).
|
|
32
|
+
* @param {string|null} value @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
function stripSurroundingQuotes(value) {
|
|
35
|
+
if (value == null || value.length < 2) return value;
|
|
36
|
+
const first = value[0];
|
|
37
|
+
if ((first === "'" || first === '"') && value[value.length - 1] === first) {
|
|
38
|
+
return value.slice(1, -1);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read an inline `GH_REPO=<value>` env-assignment prefix on a single command segment.
|
|
45
|
+
* `gh` resolves its target repo from the `GH_REPO` env var, and a segment may set it inline
|
|
46
|
+
* (`GH_REPO=owner/name gh issue create …`) — same targeting intent as `--repo owner/name`, so the
|
|
47
|
+
* scope check must treat it the same or an off-cwd redirect evades the guard (#1074). Only the
|
|
48
|
+
* FIRST leading env assignment matching `GH_REPO=` is read (env assignments precede the executable);
|
|
49
|
+
* the value is quote-normalized. Ambient `process.env.GH_REPO` is out of scope — this is a static
|
|
50
|
+
* command-string classifier, so only the inline assignment in the string is considered.
|
|
51
|
+
* @param {string} segment @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
function extractGhRepoEnvAssignment(segment) {
|
|
54
|
+
if (!segment) return null;
|
|
55
|
+
for (const token of segment.trim().split(/\s+/)) {
|
|
56
|
+
const assign = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
57
|
+
if (!assign) break; // first non-assignment token ends the env-assignment prefix
|
|
58
|
+
if (assign[1] === "GH_REPO") {
|
|
59
|
+
return trimToNull(stripSurroundingQuotes(assign[2]));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
18
65
|
/** @param {string|null|undefined} value @returns {string|null} */
|
|
19
66
|
export function trimToNull(value) {
|
|
20
67
|
const trimmed = `${value ?? ""}`.trim();
|
|
@@ -51,7 +98,7 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
|
|
|
51
98
|
return null;
|
|
52
99
|
}
|
|
53
100
|
|
|
54
|
-
function
|
|
101
|
+
function segmentIsGhPrMerge(segment) {
|
|
55
102
|
if (!/^gh\s+pr\s+merge(?:\s|$)/i.test(segment)) {
|
|
56
103
|
return false;
|
|
57
104
|
}
|
|
@@ -82,36 +129,178 @@ export function isMergeCapableCommand(command) {
|
|
|
82
129
|
return false;
|
|
83
130
|
}
|
|
84
131
|
return normalized
|
|
85
|
-
.split(
|
|
86
|
-
.some((segment) =>
|
|
132
|
+
.split(SHELL_SEGMENT_SEPARATOR)
|
|
133
|
+
.some((segment) => segmentIsGhPrMerge(segment) || isGitMergeCompletionCommand(segment));
|
|
87
134
|
}
|
|
88
135
|
|
|
89
136
|
/** @param {string} command @returns {string} */
|
|
90
137
|
export function firstShellSegment(command) {
|
|
91
|
-
return command.trim().split(
|
|
138
|
+
return command.trim().split(SHELL_SEGMENT_SEPARATOR)[0]?.trim() ?? "";
|
|
92
139
|
}
|
|
93
140
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
141
|
+
/** Split a compound shell command into its individual segments. */
|
|
142
|
+
function shellSegments(command) {
|
|
143
|
+
return command.trim().split(SHELL_SEGMENT_SEPARATOR).map((s) => s.trim()).filter(Boolean);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Leading prefix a `gh pr <verb>` segment may carry before the `gh` executable:
|
|
148
|
+
* a run of `NAME=value` env assignments, optional `command`/`env`/`exec` wrapper
|
|
149
|
+
* words, and an absolute/relative path on the gh binary (`/usr/bin/gh`).
|
|
150
|
+
*
|
|
151
|
+
* Note: this is a pragmatic normalizer, not a full shell tokenizer. Subshell
|
|
152
|
+
* `(gh pr create)`, `{ …; }` group, `-R=value` short-flag, and backslash-escaped
|
|
153
|
+
* `\gh` forms are deliberately out of scope.
|
|
154
|
+
*/
|
|
155
|
+
const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
|
|
159
|
+
* Tolerates a leading env-assignment/wrapper/path prefix so `GH_TOKEN=x gh pr create`,
|
|
160
|
+
* `command gh issue create`, and `/usr/bin/gh pr create` are all matched. The same regex
|
|
161
|
+
* is reused to strip the matched prefix (`segment.replace(re, "")`), so remainder
|
|
162
|
+
* extraction stays consistent across all matcher/extractor call sites. The `gh` prefix
|
|
163
|
+
* requirement means node-wrapper commands (`node scripts/github/comment-issue.mjs …`) never
|
|
164
|
+
* match — their first token is `node`, not `gh`.
|
|
165
|
+
*/
|
|
166
|
+
function ghSubcmdVerbRegex(subcmd, verb) {
|
|
167
|
+
return new RegExp(`^${GH_PR_VERB_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Build the `gh pr <verb>` prefix matcher — delegates to the generic subcmd matcher (DRY). */
|
|
171
|
+
function ghPrVerbRegex(verb) {
|
|
172
|
+
return ghSubcmdVerbRegex("pr", verb);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Return the first segment in the command that is a `gh <subcmd> <verb>` call (ignoring
|
|
177
|
+
* --help/-h), or null. Scans ALL segments so compound commands are caught.
|
|
178
|
+
*/
|
|
179
|
+
function findGhSubcmdVerbSegment(command, subcmd, verb) {
|
|
180
|
+
const re = ghSubcmdVerbRegex(subcmd, verb);
|
|
181
|
+
for (const segment of shellSegments(command)) {
|
|
182
|
+
if (!re.test(segment)) continue;
|
|
183
|
+
const remainder = segment.replace(re, "").trim();
|
|
184
|
+
if (!remainder) return segment;
|
|
185
|
+
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
186
|
+
if (!args.includes("--help") && !args.includes("-h")) return segment;
|
|
99
187
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Extract the `--repo`/`-R` flag value from an already-isolated `gh <subcmd> <verb>` segment.
|
|
193
|
+
* @param {string} segment @param {string} subcmd @param {string} verb @returns {string|null}
|
|
194
|
+
*/
|
|
195
|
+
function extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) {
|
|
196
|
+
const re = ghSubcmdVerbRegex(subcmd, verb);
|
|
197
|
+
if (!segment || !re.test(segment)) return null;
|
|
198
|
+
const remainder = segment.replace(re, "").trim();
|
|
199
|
+
if (!remainder) return null;
|
|
200
|
+
const tokens = remainder.split(/\s+/);
|
|
201
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
202
|
+
const token = tokens[i];
|
|
203
|
+
const lower = token.toLowerCase();
|
|
204
|
+
if (lower === "-r" || lower === "--repo") {
|
|
205
|
+
if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) return stripSurroundingQuotes(tokens[i + 1]);
|
|
206
|
+
}
|
|
207
|
+
const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
|
|
208
|
+
if (repoEqMatch) return stripSurroundingQuotes(repoEqMatch[1]);
|
|
209
|
+
}
|
|
210
|
+
// No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
|
|
211
|
+
// mirroring gh's own precedence). This closes the GH_REPO repo-targeting bypass (#1074).
|
|
212
|
+
return extractGhRepoEnvAssignment(segment);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Return one `{ segment, explicitRepo }` entry for EVERY `gh <subcmd> <verb>` segment (ignoring
|
|
217
|
+
* --help/-h). Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
218
|
+
* @param {string} command @param {string} subcmd @param {string} verb
|
|
219
|
+
* @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
220
|
+
*/
|
|
221
|
+
function extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb) {
|
|
222
|
+
const re = ghSubcmdVerbRegex(subcmd, verb);
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const segment of shellSegments(command)) {
|
|
225
|
+
if (!re.test(segment)) continue;
|
|
226
|
+
const remainder = segment.replace(re, "").trim();
|
|
227
|
+
if (remainder) {
|
|
228
|
+
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
229
|
+
if (args.includes("--help") || args.includes("-h")) continue;
|
|
230
|
+
}
|
|
231
|
+
out.push({ segment, explicitRepo: extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) });
|
|
103
232
|
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The raw external-write verb forms that must be blocked when originating from a subagent:
|
|
238
|
+
* ad-hoc GitHub issue/PR creation and comments run directly via `gh` (not the sanctioned node
|
|
239
|
+
* wrappers). Each entry is `[subcmd, verb]`.
|
|
240
|
+
*/
|
|
241
|
+
const EXTERNAL_WRITE_VERB_FORMS = Object.freeze([
|
|
242
|
+
["issue", "create"],
|
|
243
|
+
["issue", "comment"],
|
|
244
|
+
["pr", "comment"],
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Whether `command` contains a raw `gh issue create`, `gh issue comment`, or `gh pr comment`
|
|
249
|
+
* invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
|
|
250
|
+
* blocks these when they originate from a subagent context. Node-wrapper commands
|
|
251
|
+
* (`node scripts/github/comment-issue.mjs …`) never match (first token is `node`, not `gh`).
|
|
252
|
+
* @param {string} command @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function commandContainsRawExternalWrite(command) {
|
|
255
|
+
return EXTERNAL_WRITE_VERB_FORMS.some(([subcmd, verb]) => findGhSubcmdVerbSegment(command, subcmd, verb) !== null);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Return `{ segment, explicitRepo }` for every raw external-write segment across all three verb
|
|
260
|
+
* forms (`gh issue create` / `gh issue comment` / `gh pr comment`). PreToolUse gate use only —
|
|
261
|
+
* lets the gate decide in-scope-ness per segment so a leading out-of-scope write can't shield a
|
|
262
|
+
* later in-scope one. `explicitRepo` is the segment's `--repo`/`-R` value or null.
|
|
263
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
264
|
+
*/
|
|
265
|
+
export function extractRepoFlagsFromExternalWriteSegments(command) {
|
|
266
|
+
return EXTERNAL_WRITE_VERB_FORMS.flatMap(([subcmd, verb]) =>
|
|
267
|
+
extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Return the first segment in the command that is a `gh pr <verb>` call (ignoring --help/-h),
|
|
273
|
+
* or null. Scans ALL segments so compound commands (`echo ok && gh pr merge 1`) are caught.
|
|
274
|
+
*/
|
|
275
|
+
function findGhPrVerbSegment(command, verb) {
|
|
276
|
+
return findGhSubcmdVerbSegment(command, "pr", verb);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Generic `gh pr <verb>` detector — checks the FIRST shell segment only.
|
|
281
|
+
*
|
|
282
|
+
* Used by the Pi extension's post-execute handler (`onUserBash`) to record that `gh pr ready`
|
|
283
|
+
* actually ran. First-segment-only is correct for that use: `false && gh pr ready 42` short-
|
|
284
|
+
* circuits so ready never executes, and the extension should not record a spurious invocation.
|
|
285
|
+
*
|
|
286
|
+
* For the Claude Code PreToolUse gate (block before execution), use
|
|
287
|
+
* `commandContainsGhPrReady`/`commandContainsGhPrMerge` instead — those scan ALL segments.
|
|
288
|
+
*/
|
|
289
|
+
function isGhPrVerbCommand(command, verb) {
|
|
290
|
+
const re = ghPrVerbRegex(verb);
|
|
291
|
+
const segment = firstShellSegment(command);
|
|
292
|
+
if (!segment || !re.test(segment)) return false;
|
|
293
|
+
const remainder = segment.replace(re, "").trim();
|
|
294
|
+
if (!remainder) return true;
|
|
104
295
|
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
105
296
|
return !args.includes("--help") && !args.includes("-h");
|
|
106
297
|
}
|
|
107
298
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
299
|
+
/** Extract PR number from a single already-isolated segment (shared by both first- and all-segment paths). */
|
|
300
|
+
function extractPrNumberFromSegment(segment, verb) {
|
|
301
|
+
const re = ghPrVerbRegex(verb);
|
|
302
|
+
if (!segment || !re.test(segment)) return null;
|
|
303
|
+
const remainder = segment.replace(re, "").trim();
|
|
115
304
|
if (!remainder) {
|
|
116
305
|
return null;
|
|
117
306
|
}
|
|
@@ -136,13 +325,11 @@ export function extractPrNumberFromGhPrReady(command) {
|
|
|
136
325
|
return null;
|
|
137
326
|
}
|
|
138
327
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
328
|
+
/** Extract repo flag from a single already-isolated segment. */
|
|
329
|
+
function extractRepoFlagFromSegment(segment, verb) {
|
|
330
|
+
const re = ghPrVerbRegex(verb);
|
|
331
|
+
if (!segment || !re.test(segment)) return null;
|
|
332
|
+
const remainder = segment.replace(re, "").trim();
|
|
146
333
|
if (!remainder) {
|
|
147
334
|
return null;
|
|
148
335
|
}
|
|
@@ -152,13 +339,130 @@ export function extractRepoFlagFromGhPrReady(command) {
|
|
|
152
339
|
const lower = token.toLowerCase();
|
|
153
340
|
if (lower === "-r" || lower === "--repo") {
|
|
154
341
|
if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) {
|
|
155
|
-
return tokens[i + 1];
|
|
342
|
+
return stripSurroundingQuotes(tokens[i + 1]);
|
|
156
343
|
}
|
|
157
344
|
}
|
|
158
345
|
const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
|
|
159
346
|
if (repoEqMatch) {
|
|
160
|
-
return repoEqMatch[1];
|
|
347
|
+
return stripSurroundingQuotes(repoEqMatch[1]);
|
|
161
348
|
}
|
|
162
349
|
}
|
|
163
|
-
|
|
350
|
+
// No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
|
|
351
|
+
// mirroring gh's own precedence). Applied here too so gh pr ready/merge/create scope checks get
|
|
352
|
+
// consistent GH_REPO handling — the root-cause fix, not just the external-write path (#1074).
|
|
353
|
+
return extractGhRepoEnvAssignment(segment);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** First-segment extractor for `gh pr <verb>` PR number — Pi extension public API. */
|
|
357
|
+
function extractPrNumberFromGhPrVerb(command, verb) {
|
|
358
|
+
return extractPrNumberFromSegment(firstShellSegment(command), verb);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** First-segment extractor for `gh pr <verb>` --repo flag — Pi extension public API. */
|
|
362
|
+
function extractRepoFlagFromGhPrVerb(command, verb) {
|
|
363
|
+
return extractRepoFlagFromSegment(firstShellSegment(command), verb);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** @param {string} command @returns {boolean} */
|
|
367
|
+
export function isGhPrReadyCommand(command) {
|
|
368
|
+
return isGhPrVerbCommand(command, "ready");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** @param {string} command @returns {number|null} */
|
|
372
|
+
export function extractPrNumberFromGhPrReady(command) {
|
|
373
|
+
return extractPrNumberFromGhPrVerb(command, "ready");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @param {string} command @returns {string|null} */
|
|
377
|
+
export function extractRepoFlagFromGhPrReady(command) {
|
|
378
|
+
return extractRepoFlagFromGhPrVerb(command, "ready");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Whether `command` contains a `gh pr merge` invocation in the FIRST shell segment,
|
|
383
|
+
* ignoring `--help`/`-h`. Used by the Pi extension's post-execute handler.
|
|
384
|
+
* For the Claude Code PreToolUse gate, use `commandContainsGhPrMerge` instead.
|
|
385
|
+
* @param {string} command @returns {boolean}
|
|
386
|
+
*/
|
|
387
|
+
export function isGhPrMergeCommand(command) {
|
|
388
|
+
return isGhPrVerbCommand(command, "merge");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Whether `command` contains a `gh pr ready` invocation in ANY shell segment.
|
|
393
|
+
* For use in the Claude Code PreToolUse gate only — blocks the whole command pre-emptively
|
|
394
|
+
* regardless of shell short-circuit semantics (`false && gh pr ready 42` is still blocked).
|
|
395
|
+
* @param {string} command @returns {boolean}
|
|
396
|
+
*/
|
|
397
|
+
export function commandContainsGhPrReady(command) {
|
|
398
|
+
return findGhPrVerbSegment(command, "ready") !== null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Whether `command` contains a `gh pr merge` invocation in ANY shell segment.
|
|
403
|
+
* For use in the Claude Code PreToolUse gate only — blocks the whole command pre-emptively.
|
|
404
|
+
* @param {string} command @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
export function commandContainsGhPrMerge(command) {
|
|
407
|
+
return findGhPrVerbSegment(command, "merge") !== null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Extract PR number from `gh pr ready` in any shell segment — PreToolUse gate use only. */
|
|
411
|
+
export function extractPrNumberFromGhPrReadyAnywhere(command) {
|
|
412
|
+
return extractPrNumberFromSegment(findGhPrVerbSegment(command, "ready"), "ready");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @param {string} command @returns {string|null} */
|
|
416
|
+
export function extractRepoFlagFromGhPrReadyAnywhere(command) {
|
|
417
|
+
return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "ready"), "ready");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Extract PR number from `gh pr merge` in any shell segment — PreToolUse gate use only. */
|
|
421
|
+
export function extractPrNumberFromGhPrMergeAnywhere(command) {
|
|
422
|
+
return extractPrNumberFromSegment(findGhPrVerbSegment(command, "merge"), "merge");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** @param {string} command @returns {string|null} */
|
|
426
|
+
export function extractRepoFlagFromGhPrMergeAnywhere(command) {
|
|
427
|
+
return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "merge"), "merge");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether `command` contains a raw `gh pr create` invocation in ANY shell segment.
|
|
432
|
+
* PreToolUse gate use only — blocks raw `gh pr create` so PR creation flows through the
|
|
433
|
+
* canonical wrapper (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always
|
|
434
|
+
* creates a draft and self-assigns. The wrapper runs `gh pr create` inside a node child
|
|
435
|
+
* process, so its Bash command string (`node …/create-pr.mjs …`) never matches this — only a
|
|
436
|
+
* literal `gh pr create` in the agent's shell command does.
|
|
437
|
+
* @param {string} command @returns {boolean}
|
|
438
|
+
*/
|
|
439
|
+
export function commandContainsGhPrCreate(command) {
|
|
440
|
+
return findGhPrVerbSegment(command, "create") !== null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Extract repo flag from `gh pr create` in any shell segment — PreToolUse gate use only. */
|
|
444
|
+
export function extractRepoFlagFromGhPrCreateAnywhere(command) {
|
|
445
|
+
return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "create"), "create");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Return one `{ segment, explicitRepo }` entry for EVERY `gh pr create` segment (ignoring
|
|
450
|
+
* --help/-h), not just the first. PreToolUse gate use only: the create-scope decision must
|
|
451
|
+
* consider every create segment, so a leading out-of-scope create can't shield a later
|
|
452
|
+
* in-scope raw create (`gh pr create --repo other/repo && gh pr create --fill`).
|
|
453
|
+
* `explicitRepo` is the segment's `--repo`/`-R` value or null when none is present.
|
|
454
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
455
|
+
*/
|
|
456
|
+
export function extractRepoFlagsFromGhPrCreateSegments(command) {
|
|
457
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** @param {string} command @returns {number|null} */
|
|
461
|
+
export function extractPrNumberFromGhPrMerge(command) {
|
|
462
|
+
return extractPrNumberFromGhPrVerb(command, "merge");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** @param {string} command @returns {string|null} */
|
|
466
|
+
export function extractRepoFlagFromGhPrMerge(command) {
|
|
467
|
+
return extractRepoFlagFromGhPrVerb(command, "merge");
|
|
164
468
|
}
|
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
|
|
13
13
|
import { resolveRunId } from "./_run-context.mjs";
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
commandContainsGhPrReady,
|
|
16
|
+
commandContainsGhPrMerge,
|
|
17
|
+
commandContainsGhPrCreate,
|
|
18
|
+
extractPrNumberFromGhPrReadyAnywhere,
|
|
19
|
+
extractRepoFlagFromGhPrReadyAnywhere,
|
|
20
|
+
extractPrNumberFromGhPrMergeAnywhere,
|
|
21
|
+
extractRepoFlagFromGhPrMergeAnywhere,
|
|
22
|
+
extractRepoFlagsFromGhPrCreateSegments,
|
|
23
|
+
commandContainsRawExternalWrite,
|
|
24
|
+
extractRepoFlagsFromExternalWriteSegments,
|
|
18
25
|
TARGET_REPO_SLUG,
|
|
19
26
|
} from "./_bash-command-classify.mjs";
|
|
20
27
|
|
|
@@ -26,6 +33,16 @@ import {
|
|
|
26
33
|
|
|
27
34
|
const ALLOW = Object.freeze({ decision: "allow" });
|
|
28
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Whether the command string also invokes an evidence-writing script (findings-log ledger or
|
|
38
|
+
* checkpoint-verdict upsert). Used only to enrich the merge-block message (#1172) — a compound
|
|
39
|
+
* command combining an evidence write with `gh pr merge` is blocked pre-execution, so the write
|
|
40
|
+
* never runs; this substring check has no false-negative cost (worst case: the plain message).
|
|
41
|
+
*/
|
|
42
|
+
function commandContainsEvidenceWrite(command) {
|
|
43
|
+
return command.includes("write-gate-findings-log") || command.includes("upsert-checkpoint-verdict");
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
/**
|
|
30
47
|
* The agent type (Claude `agent_type` / the canonical agent name) that owns repo mutations.
|
|
31
48
|
* Only this subagent — not arbitrary subagents (Explore, Plan, generic Task agents) — may
|
|
@@ -34,26 +51,114 @@ const ALLOW = Object.freeze({ decision: "allow" });
|
|
|
34
51
|
export const DEV_LOOP_AGENT_TYPE = "dev-loop";
|
|
35
52
|
|
|
36
53
|
/**
|
|
37
|
-
* Decide whether a PreToolUse Bash command must be blocked by
|
|
54
|
+
* Decide whether a PreToolUse Bash command must be blocked by a dev-loop gate boundary.
|
|
55
|
+
*
|
|
56
|
+
* Three gated commands on the target repo:
|
|
57
|
+
* - `gh pr create` — blocked outright; PR creation must flow through the canonical wrapper
|
|
58
|
+
* (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always drafts and
|
|
59
|
+
* self-assigns. Closes the hole where raw `gh pr create` opens a ready PR, bypassing draft-first.
|
|
60
|
+
* - `gh pr ready` — blocked without clean draft_gate evidence (`pre-pr-ready-gate`).
|
|
61
|
+
* - `gh pr merge` — blocked without the full pre-merge gate evidence (`detect-checkpoint-evidence`:
|
|
62
|
+
* clean current-head draft_gate + pre_approval_gate). The loop runs this check before merging;
|
|
63
|
+
* gating it here closes the hole where a hand-run `gh pr merge` skips the pre-approval gate
|
|
64
|
+
* entirely. Everything else passes through.
|
|
65
|
+
* - raw `gh issue create` / `gh issue comment` / `gh pr comment` — blocked ONLY when the call
|
|
66
|
+
* originates from a SUBAGENT context (`agentType` is a non-null string) and targets the repo.
|
|
67
|
+
* Sanctioned external writes flow through node wrappers (gate-verdict comments via
|
|
68
|
+
* `upsert-checkpoint-verdict.mjs`, review replies via `reply-resolve*.mjs`, board sync,
|
|
69
|
+
* `comment-issue.mjs`), whose Bash command string is `node scripts/…` and never matches these
|
|
70
|
+
* raw-`gh` matchers. The MAIN AGENT / operator (agentType null) retains direct `gh issue
|
|
71
|
+
* create` — that path is authorized (#1051).
|
|
38
72
|
*
|
|
39
|
-
*
|
|
40
|
-
* target repo without clean draft_gate evidence. Everything else (including merges, which
|
|
41
|
-
* trigger the post-merge step, not a block) is allowed through.
|
|
73
|
+
* The hook computes `gatePassed`/`gateError` from the gate script appropriate to the command kind.
|
|
42
74
|
*
|
|
43
75
|
* @param {Object} params
|
|
44
76
|
* @param {string} params.command - The Bash command string.
|
|
45
77
|
* @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo (null if unknown).
|
|
46
|
-
* @param {boolean} [params.gatePassed] - Whether
|
|
78
|
+
* @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
|
|
47
79
|
* @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
|
|
80
|
+
* @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
|
|
81
|
+
* string inside a subagent, null in the main agent. Scopes the external-write guard.
|
|
48
82
|
* @returns {HookDecision}
|
|
49
83
|
*/
|
|
50
|
-
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null }) {
|
|
51
|
-
if (typeof command !== "string"
|
|
84
|
+
export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null }) {
|
|
85
|
+
if (typeof command !== "string") {
|
|
86
|
+
return ALLOW;
|
|
87
|
+
}
|
|
88
|
+
// Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
|
|
89
|
+
// `gh pr comment` on the target repo from a subagent, so external writes flow through the
|
|
90
|
+
// sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
|
|
91
|
+
if (typeof agentType === "string" && commandContainsRawExternalWrite(command)) {
|
|
92
|
+
const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
|
|
93
|
+
// Scope PER segment, mirroring the `gh pr create` block: in scope when no explicit --repo and
|
|
94
|
+
// cwd is the target, or an explicit --repo/-R equals the target. An explicit non-target --repo
|
|
95
|
+
// passes through. DENY if ANY external-write segment is in scope.
|
|
96
|
+
const anyWriteInScope = extractRepoFlagsFromExternalWriteSegments(command).some((seg) =>
|
|
97
|
+
seg.explicitRepo == null
|
|
98
|
+
? cwdTargets
|
|
99
|
+
: seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
|
|
100
|
+
);
|
|
101
|
+
if (anyWriteInScope) {
|
|
102
|
+
return {
|
|
103
|
+
decision: "deny",
|
|
104
|
+
reason:
|
|
105
|
+
"Ad-hoc GitHub issue/PR creation and comments from a subagent are blocked. Use the sanctioned " +
|
|
106
|
+
"node wrappers instead — gate-verdict comments via scripts/github/upsert-checkpoint-verdict.mjs, " +
|
|
107
|
+
"review-thread replies via scripts/github/reply-resolve*.mjs, board sync, or scripts/github/comment-issue.mjs. " +
|
|
108
|
+
"Direct `gh issue create` is reserved for the main agent / operator.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Scan ALL shell segments — the PreToolUse gate blocks pre-emptively, so a gated verb in any
|
|
113
|
+
// segment (even after `&&` or `;`) must be caught. This differs from the Pi extension's
|
|
114
|
+
// post-execute `isGhPrReadyCommand`/`isGhPrMergeCommand` which scan only the first segment
|
|
115
|
+
// (correct there: `false && gh pr ready 42` short-circuits so ready never ran).
|
|
116
|
+
const isReady = commandContainsGhPrReady(command);
|
|
117
|
+
const isMerge = commandContainsGhPrMerge(command);
|
|
118
|
+
const isCreate = commandContainsGhPrCreate(command);
|
|
119
|
+
if (!isReady && !isMerge && !isCreate) {
|
|
52
120
|
return ALLOW;
|
|
53
121
|
}
|
|
54
122
|
|
|
123
|
+
// Raw `gh pr create` is blocked outright on the target repo (no PR number / gate evidence
|
|
124
|
+
// exists yet): PR creation must flow through the canonical wrapper, which always drafts and
|
|
125
|
+
// self-assigns. This closes the draft-first hole where raw `gh pr create` opens a ready PR.
|
|
126
|
+
if (isCreate) {
|
|
127
|
+
const cwdTargets = (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase();
|
|
128
|
+
// Evaluate scope PER create segment, not just the first: a create is in scope when it
|
|
129
|
+
// explicitly targets the repo, or (with no explicit --repo) the cwd is the repo. An explicit
|
|
130
|
+
// `--repo <target>` is denied regardless of cwd (#1047). DENY if ANY create segment is in
|
|
131
|
+
// scope — otherwise a leading out-of-scope create (`gh pr create --repo other/repo`) would
|
|
132
|
+
// short-circuit and shield a later in-scope raw create (`&& gh pr create --fill`).
|
|
133
|
+
const anyCreateInScope = extractRepoFlagsFromGhPrCreateSegments(command).some((seg) =>
|
|
134
|
+
seg.explicitRepo == null
|
|
135
|
+
? cwdTargets
|
|
136
|
+
: seg.explicitRepo.toLowerCase() === TARGET_REPO_SLUG.toLowerCase(),
|
|
137
|
+
);
|
|
138
|
+
if (anyCreateInScope) {
|
|
139
|
+
return {
|
|
140
|
+
decision: "deny",
|
|
141
|
+
reason:
|
|
142
|
+
"gh pr create blocked: open PRs via the canonical wrapper `node scripts/github/create-pr.mjs` " +
|
|
143
|
+
"(a.k.a. `dev-loops pr create`), which always creates a draft and self-assigns. Raw `gh pr create` " +
|
|
144
|
+
"defaults to ready-for-review and bypasses the draft-first contract (workflow.requireDraftFirst).",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// The create is out of scope. Only allow outright when there is no ready/merge segment to
|
|
148
|
+
// evaluate — otherwise fall through so a gated `gh pr ready`/`gh pr merge` in the same
|
|
149
|
+
// compound command (e.g. `gh pr create --repo other/repo && gh pr merge 5`) is still gated
|
|
150
|
+
// below rather than short-circuited.
|
|
151
|
+
if (!isReady && !isMerge) {
|
|
152
|
+
return ALLOW;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// When both verbs appear in a compound command, apply the stricter merge gate — if it passes,
|
|
156
|
+
// the draft_gate (a subset of the pre-merge evidence check) is also satisfied.
|
|
157
|
+
const verb = isMerge ? "gh pr merge" : "gh pr ready";
|
|
55
158
|
// An explicit `--repo other/repo` that is not the target → not our concern, pass through.
|
|
56
|
-
const explicitRepo =
|
|
159
|
+
const explicitRepo = isMerge
|
|
160
|
+
? extractRepoFlagFromGhPrMergeAnywhere(command)
|
|
161
|
+
: extractRepoFlagFromGhPrReadyAnywhere(command);
|
|
57
162
|
if (explicitRepo && explicitRepo.toLowerCase() !== TARGET_REPO_SLUG.toLowerCase()) {
|
|
58
163
|
return ALLOW;
|
|
59
164
|
}
|
|
@@ -62,23 +167,41 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
|
|
|
62
167
|
return ALLOW;
|
|
63
168
|
}
|
|
64
169
|
|
|
65
|
-
const prNumber =
|
|
170
|
+
const prNumber = isMerge
|
|
171
|
+
? extractPrNumberFromGhPrMergeAnywhere(command)
|
|
172
|
+
: extractPrNumberFromGhPrReadyAnywhere(command);
|
|
66
173
|
if (prNumber === null) {
|
|
67
174
|
return {
|
|
68
175
|
decision: "deny",
|
|
69
|
-
reason:
|
|
70
|
-
"gh pr ready blocked: could not determine the PR number from the command. Include the PR number explicitly.",
|
|
176
|
+
reason: `${verb} blocked: could not determine the PR number from the command. Include the PR number explicitly.`,
|
|
71
177
|
};
|
|
72
178
|
}
|
|
73
179
|
|
|
74
180
|
if (gateError) {
|
|
181
|
+
const which = isMerge ? "pre-merge gate" : "draft-gate";
|
|
75
182
|
return {
|
|
76
183
|
decision: "deny",
|
|
77
|
-
reason:
|
|
184
|
+
reason: `${verb} blocked: ${which} evidence check failed (${gateError}).`,
|
|
78
185
|
};
|
|
79
186
|
}
|
|
80
187
|
|
|
81
188
|
if (!gatePassed) {
|
|
189
|
+
if (isMerge) {
|
|
190
|
+
// This hook evaluates PreToolUse — BEFORE the Bash tool call runs. A compound command that
|
|
191
|
+
// writes gate evidence (findings-log ledger, checkpoint verdict) and merges in the same call
|
|
192
|
+
// is blocked here with the write never having executed, which looks like the evidence
|
|
193
|
+
// "vanished" (#1172). Hint the split when the command carries an evidence-writing invocation
|
|
194
|
+
// alongside the merge, so the failure is self-explaining instead of looking like data loss.
|
|
195
|
+
const alsoWritesEvidence = commandContainsEvidenceWrite(command);
|
|
196
|
+
return {
|
|
197
|
+
decision: "deny",
|
|
198
|
+
reason:
|
|
199
|
+
`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.` +
|
|
200
|
+
(alsoWritesEvidence
|
|
201
|
+
? " This command also writes gate evidence, but hooks evaluate before the command runs — write the evidence in a separate call, then merge alone."
|
|
202
|
+
: ""),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
82
205
|
return {
|
|
83
206
|
decision: "deny",
|
|
84
207
|
reason: `gh pr ready blocked: no visible clean draft_gate checkpoint verdict comment found for PR #${prNumber}.`,
|