@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
|
@@ -15,6 +15,149 @@ export function isCopilotLogin(login) {
|
|
|
15
15
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
|
|
19
|
+
// the write-side sanitizer and the read-side guard scan key off this shape so a
|
|
20
|
+
// gate-evidence comment can quote the rule (inside a code span/fenced block)
|
|
21
|
+
// without arming the request-copilot-review.mjs anti-summon guard. The token
|
|
22
|
+
// regex carries the same left word-boundary as the guard regex so the sanitizer
|
|
23
|
+
// never mangles text the guard would not arm on (e.g. user@copilot.example).
|
|
24
|
+
const COPILOT_SUMMON_TOKEN_RE = /(?<=^|\W)(@copilot|\/copilot[a-z0-9_-]*)/gi;
|
|
25
|
+
const COPILOT_SUMMON_WORD_BOUNDARY_RE = /(?:^|\W)(@copilot|\/copilot)(?:$|\W)/i;
|
|
26
|
+
// GFM inline code span: an N-backtick run, lazy content, closed by a same-length
|
|
27
|
+
// run. Covers single-backtick spans as well as double-backtick spans wrapping a
|
|
28
|
+
// literal backtick.
|
|
29
|
+
const INLINE_CODE_SPAN_RE = /(`+)[\s\S]*?\1(?!`)/g;
|
|
30
|
+
const ZERO_WIDTH_JOINER = "\u200D";
|
|
31
|
+
|
|
32
|
+
// Apply `transformLine` to every markdown line OUTSIDE a fenced code block
|
|
33
|
+
// (```/~~~), leaving fence-delimiter lines and fenced content untouched.
|
|
34
|
+
// Mirrors the fenced-block tracking scripts/docs/validate-rule-ownership.mjs
|
|
35
|
+
// uses for its own lexical scan.
|
|
36
|
+
function transformNonFencedLines(text, transformLine) {
|
|
37
|
+
const lines = String(text).split(/\r?\n/);
|
|
38
|
+
let inFencedBlock = false;
|
|
39
|
+
let fencedDelimiter = "";
|
|
40
|
+
const transformed = lines.map((line) => {
|
|
41
|
+
const rawTrimmed = line.trim();
|
|
42
|
+
const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
|
|
43
|
+
if (fenceMatch) {
|
|
44
|
+
if (!inFencedBlock) {
|
|
45
|
+
inFencedBlock = true;
|
|
46
|
+
fencedDelimiter = fenceMatch[1];
|
|
47
|
+
return line;
|
|
48
|
+
}
|
|
49
|
+
if (rawTrimmed.startsWith(fencedDelimiter)) {
|
|
50
|
+
inFencedBlock = false;
|
|
51
|
+
fencedDelimiter = "";
|
|
52
|
+
return line;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (inFencedBlock) {
|
|
56
|
+
return line;
|
|
57
|
+
}
|
|
58
|
+
return transformLine(line);
|
|
59
|
+
});
|
|
60
|
+
return transformed.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Apply `replaceSegment` to every part of a line that lies OUTSIDE an inline
|
|
64
|
+
// code span (any N-backtick GFM span), leaving span content untouched.
|
|
65
|
+
function transformOutsideSpans(line, replaceSegment) {
|
|
66
|
+
let result = "";
|
|
67
|
+
let last = 0;
|
|
68
|
+
for (const span of line.matchAll(INLINE_CODE_SPAN_RE)) {
|
|
69
|
+
result += replaceSegment(line.slice(last, span.index));
|
|
70
|
+
result += span[0];
|
|
71
|
+
last = span.index + span[0].length;
|
|
72
|
+
}
|
|
73
|
+
return result + replaceSegment(line.slice(last));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Wrap bare `@copilot`/`/copilot*` tokens in backticks so a comment can quote the
|
|
77
|
+
// anti-summon rule without arming it. Tokens already inside an inline code span
|
|
78
|
+
// are left untouched.
|
|
79
|
+
function wrapBareSummonTokensInLine(line) {
|
|
80
|
+
return transformOutsideSpans(line, (segment) => segment.replace(COPILOT_SUMMON_TOKEN_RE, "`$1`"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Does this single (non-fenced) line still arm the guard scan after inline code
|
|
84
|
+
// spans are dropped? Mirrors stripMarkdownCodeForScan's per-line step. Spans are
|
|
85
|
+
// replaced with a SPACE, not the empty string: the fragments flanking a span
|
|
86
|
+
// must never be rejoined into a token that was not present ("@copi`x`lot" is not
|
|
87
|
+
// a summon), while a token directly abutting a span ("text`x`@copilot", which
|
|
88
|
+
// GitHub renders as a real mention) still arms.
|
|
89
|
+
function lineArmsSummonGuard(line) {
|
|
90
|
+
return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(line.replace(INLINE_CODE_SPAN_RE, " "));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ZWJ_FALLBACK_RE = /(?<=^|\W)([@/])(copilot)/gi;
|
|
94
|
+
|
|
95
|
+
// Sanitize one line, verifying against the guard scan. Backtick-wrapping is the
|
|
96
|
+
// primary neutralization (visible, greppable), but pre-existing backticks on the
|
|
97
|
+
// line can destabilize it two ways: an UNBALANCED stray backtick pairs with an
|
|
98
|
+
// inserted one and re-exposes the token to the guard's span-stripping, and
|
|
99
|
+
// adjacent spans (e.g. a span ending right before the token's new wrap) can make
|
|
100
|
+
// the wrapped line re-tokenize differently on the next pass, re-wrapping the
|
|
101
|
+
// token and growing the comment by one backtick per rewrite. The wrapped result
|
|
102
|
+
// is therefore accepted only when it is BOTH guard-inert AND a fixed point of
|
|
103
|
+
// the wrapper (re-wrapping it changes nothing); otherwise fall back to inserting
|
|
104
|
+
// a zero-width joiner into the residual tokens still outside the wrapped line's
|
|
105
|
+
// spans — invisible, guard-inert, and idempotent (the joined token no longer
|
|
106
|
+
// matches the summon shape). Working on the wrapped line (not the original)
|
|
107
|
+
// preserves every stable backtick wrap and keeps the joiner out of legitimate
|
|
108
|
+
// pre-existing code spans.
|
|
109
|
+
function sanitizeSummonLine(line) {
|
|
110
|
+
const wrapped = wrapBareSummonTokensInLine(line);
|
|
111
|
+
if (!lineArmsSummonGuard(wrapped) && wrapBareSummonTokensInLine(wrapped) === wrapped) {
|
|
112
|
+
return wrapped;
|
|
113
|
+
}
|
|
114
|
+
return transformOutsideSpans(wrapped, (segment) => segment.replace(ZWJ_FALLBACK_RE, `$1${ZERO_WIDTH_JOINER}$2`));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function sanitizeCopilotSummonTokens(text) {
|
|
118
|
+
return transformNonFencedLines(String(text), sanitizeSummonLine);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Drop all markdown code content (fenced blocks entirely, inline code spans
|
|
122
|
+
// per line) from `text`, leaving only the bare-text markdown to scan. Unlike
|
|
123
|
+
// transformNonFencedLines (which leaves fenced lines verbatim — correct for
|
|
124
|
+
// sanitizing, where code content must not be rewritten), fenced content here
|
|
125
|
+
// must be REMOVED rather than kept: leaving it in place would let bare text
|
|
126
|
+
// inside a fence still match the anti-summon scan.
|
|
127
|
+
function stripMarkdownCodeForScan(text) {
|
|
128
|
+
const lines = String(text).split(/\r?\n/);
|
|
129
|
+
let inFencedBlock = false;
|
|
130
|
+
let fencedDelimiter = "";
|
|
131
|
+
const kept = [];
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const rawTrimmed = line.trim();
|
|
134
|
+
const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
|
|
135
|
+
if (fenceMatch) {
|
|
136
|
+
if (!inFencedBlock) {
|
|
137
|
+
inFencedBlock = true;
|
|
138
|
+
fencedDelimiter = fenceMatch[1];
|
|
139
|
+
} else if (rawTrimmed.startsWith(fencedDelimiter)) {
|
|
140
|
+
inFencedBlock = false;
|
|
141
|
+
fencedDelimiter = "";
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (inFencedBlock) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Space (not empty-string) replacement: see lineArmsSummonGuard.
|
|
149
|
+
kept.push(line.replace(INLINE_CODE_SPAN_RE, " "));
|
|
150
|
+
}
|
|
151
|
+
return kept.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The request-copilot-review.mjs anti-summon guard scan: true when `text`
|
|
155
|
+
// contains a bare-text (not code-spanned/fenced) `@copilot` or `/copilot`
|
|
156
|
+
// occurrence. Quoting the rule inside backticks or a fenced block is exempt.
|
|
157
|
+
export function containsBareCopilotSummon(text) {
|
|
158
|
+
return COPILOT_SUMMON_WORD_BOUNDARY_RE.test(stripMarkdownCodeForScan(text));
|
|
159
|
+
}
|
|
160
|
+
|
|
18
161
|
export function normalizeTimestamp(value) {
|
|
19
162
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
20
163
|
return null;
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* @property {string} cwd - Working directory for the current invocation.
|
|
31
31
|
* @property {boolean} hasUI - Whether an interactive UI surface is attached.
|
|
32
32
|
* @property {HarnessUi} ui - UI operations for this invocation.
|
|
33
|
+
* @property {((message: string, options?: Record<string, unknown>) => unknown) | undefined} sendUserMessage - Optional: send a user-turn message into the harness (Pi extension only; absent in other harnesses).
|
|
33
34
|
*
|
|
34
35
|
* @typedef {'session_start'|'tool_result'|'user_bash'|'agent_end'} HarnessLifecycleEvent
|
|
35
36
|
*
|
package/src/harness/index.mjs
CHANGED
|
@@ -2,4 +2,3 @@ export { createHarnessAdapter, isHarnessAdapter } from "./adapter.mjs";
|
|
|
2
2
|
export { createPiAdapter } from "./pi-adapter.mjs";
|
|
3
3
|
export { createNoopAdapter } from "./noop-adapter.mjs";
|
|
4
4
|
export { createExtensionHarnessAdapter } from "./extension-adapter.mjs";
|
|
5
|
-
export { createClaudeExtensionAdapter } from "./claude-extension-adapter.mjs";
|
|
@@ -14,6 +14,53 @@ export const TARGET_REPO_SLUG = "mfittko/dev-loops";
|
|
|
14
14
|
/** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
|
|
15
15
|
export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Shell command separators that terminate one segment and begin the next.
|
|
19
|
+
* Newline (`\n`) and carriage return (`\r`) are full command terminators in bash
|
|
20
|
+
* (equivalent to `;`), and the Claude Code Bash tool accepts multi-line command
|
|
21
|
+
* strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
|
|
22
|
+
* the gate. Used by all segment-splitting sites (DRY).
|
|
23
|
+
*/
|
|
24
|
+
const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
|
|
28
|
+
* A repo flag value may reach us quoted (`--repo 'owner/name'`); the scope check compares against
|
|
29
|
+
* the bare slug, so quotes must be normalized or a quoted on-target repo evades the guard (#1074).
|
|
30
|
+
* ponytail: single balanced pair only — no full shell tokenization (mismatched/partial quotes stay).
|
|
31
|
+
* @param {string|null} value @returns {string|null}
|
|
32
|
+
*/
|
|
33
|
+
function stripSurroundingQuotes(value) {
|
|
34
|
+
if (value == null || value.length < 2) return value;
|
|
35
|
+
const first = value[0];
|
|
36
|
+
if ((first === "'" || first === '"') && value[value.length - 1] === first) {
|
|
37
|
+
return value.slice(1, -1);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read an inline `GH_REPO=<value>` env-assignment prefix on a single command segment.
|
|
44
|
+
* `gh` resolves its target repo from the `GH_REPO` env var, and a segment may set it inline
|
|
45
|
+
* (`GH_REPO=owner/name gh issue create …`) — same targeting intent as `--repo owner/name`, so the
|
|
46
|
+
* scope check must treat it the same or an off-cwd redirect evades the guard (#1074). Only the
|
|
47
|
+
* FIRST leading env assignment matching `GH_REPO=` is read (env assignments precede the executable);
|
|
48
|
+
* the value is quote-normalized. Ambient `process.env.GH_REPO` is out of scope — this is a static
|
|
49
|
+
* command-string classifier, so only the inline assignment in the string is considered.
|
|
50
|
+
* @param {string} segment @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
function extractGhRepoEnvAssignment(segment) {
|
|
53
|
+
if (!segment) return null;
|
|
54
|
+
for (const token of segment.trim().split(/\s+/)) {
|
|
55
|
+
const assign = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
56
|
+
if (!assign) break; // first non-assignment token ends the env-assignment prefix
|
|
57
|
+
if (assign[1] === "GH_REPO") {
|
|
58
|
+
return trimToNull(stripSurroundingQuotes(assign[2]));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
17
64
|
/** @param {string|null|undefined} value @returns {string|null} */
|
|
18
65
|
export function trimToNull(value) {
|
|
19
66
|
const trimmed = `${value ?? ""}`.trim();
|
|
@@ -50,7 +97,7 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
|
|
|
50
97
|
return null;
|
|
51
98
|
}
|
|
52
99
|
|
|
53
|
-
function
|
|
100
|
+
function segmentIsGhPrMerge(segment) {
|
|
54
101
|
if (!/^gh\s+pr\s+merge(?:\s|$)/i.test(segment)) {
|
|
55
102
|
return false;
|
|
56
103
|
}
|
|
@@ -81,36 +128,178 @@ export function isMergeCapableCommand(command) {
|
|
|
81
128
|
return false;
|
|
82
129
|
}
|
|
83
130
|
return normalized
|
|
84
|
-
.split(
|
|
85
|
-
.some((segment) =>
|
|
131
|
+
.split(SHELL_SEGMENT_SEPARATOR)
|
|
132
|
+
.some((segment) => segmentIsGhPrMerge(segment) || isGitMergeCompletionCommand(segment));
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
/** @param {string} command @returns {string} */
|
|
89
136
|
export function firstShellSegment(command) {
|
|
90
|
-
return command.trim().split(
|
|
137
|
+
return command.trim().split(SHELL_SEGMENT_SEPARATOR)[0]?.trim() ?? "";
|
|
91
138
|
}
|
|
92
139
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
140
|
+
/** Split a compound shell command into its individual segments. */
|
|
141
|
+
function shellSegments(command) {
|
|
142
|
+
return command.trim().split(SHELL_SEGMENT_SEPARATOR).map((s) => s.trim()).filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Leading prefix a `gh pr <verb>` segment may carry before the `gh` executable:
|
|
147
|
+
* a run of `NAME=value` env assignments, optional `command`/`env`/`exec` wrapper
|
|
148
|
+
* words, and an absolute/relative path on the gh binary (`/usr/bin/gh`).
|
|
149
|
+
*
|
|
150
|
+
* Note: this is a pragmatic normalizer, not a full shell tokenizer. Subshell
|
|
151
|
+
* `(gh pr create)`, `{ …; }` group, `-R=value` short-flag, and backslash-escaped
|
|
152
|
+
* `\gh` forms are deliberately out of scope.
|
|
153
|
+
*/
|
|
154
|
+
const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
|
|
158
|
+
* Tolerates a leading env-assignment/wrapper/path prefix so `GH_TOKEN=x gh pr create`,
|
|
159
|
+
* `command gh issue create`, and `/usr/bin/gh pr create` are all matched. The same regex
|
|
160
|
+
* is reused to strip the matched prefix (`segment.replace(re, "")`), so remainder
|
|
161
|
+
* extraction stays consistent across all matcher/extractor call sites. The `gh` prefix
|
|
162
|
+
* requirement means node-wrapper commands (`node scripts/github/comment-issue.mjs …`) never
|
|
163
|
+
* match — their first token is `node`, not `gh`.
|
|
164
|
+
*/
|
|
165
|
+
function ghSubcmdVerbRegex(subcmd, verb) {
|
|
166
|
+
return new RegExp(`^${GH_PR_VERB_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Build the `gh pr <verb>` prefix matcher — delegates to the generic subcmd matcher (DRY). */
|
|
170
|
+
function ghPrVerbRegex(verb) {
|
|
171
|
+
return ghSubcmdVerbRegex("pr", verb);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Return the first segment in the command that is a `gh <subcmd> <verb>` call (ignoring
|
|
176
|
+
* --help/-h), or null. Scans ALL segments so compound commands are caught.
|
|
177
|
+
*/
|
|
178
|
+
function findGhSubcmdVerbSegment(command, subcmd, verb) {
|
|
179
|
+
const re = ghSubcmdVerbRegex(subcmd, verb);
|
|
180
|
+
for (const segment of shellSegments(command)) {
|
|
181
|
+
if (!re.test(segment)) continue;
|
|
182
|
+
const remainder = segment.replace(re, "").trim();
|
|
183
|
+
if (!remainder) return segment;
|
|
184
|
+
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
185
|
+
if (!args.includes("--help") && !args.includes("-h")) return segment;
|
|
98
186
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Extract the `--repo`/`-R` flag value from an already-isolated `gh <subcmd> <verb>` segment.
|
|
192
|
+
* @param {string} segment @param {string} subcmd @param {string} verb @returns {string|null}
|
|
193
|
+
*/
|
|
194
|
+
function extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) {
|
|
195
|
+
const re = ghSubcmdVerbRegex(subcmd, verb);
|
|
196
|
+
if (!segment || !re.test(segment)) return null;
|
|
197
|
+
const remainder = segment.replace(re, "").trim();
|
|
198
|
+
if (!remainder) return null;
|
|
199
|
+
const tokens = remainder.split(/\s+/);
|
|
200
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
201
|
+
const token = tokens[i];
|
|
202
|
+
const lower = token.toLowerCase();
|
|
203
|
+
if (lower === "-r" || lower === "--repo") {
|
|
204
|
+
if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) return stripSurroundingQuotes(tokens[i + 1]);
|
|
205
|
+
}
|
|
206
|
+
const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
|
|
207
|
+
if (repoEqMatch) return stripSurroundingQuotes(repoEqMatch[1]);
|
|
208
|
+
}
|
|
209
|
+
// No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
|
|
210
|
+
// mirroring gh's own precedence). This closes the GH_REPO repo-targeting bypass (#1074).
|
|
211
|
+
return extractGhRepoEnvAssignment(segment);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Return one `{ segment, explicitRepo }` entry for EVERY `gh <subcmd> <verb>` segment (ignoring
|
|
216
|
+
* --help/-h). Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
|
|
217
|
+
* @param {string} command @param {string} subcmd @param {string} verb
|
|
218
|
+
* @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
219
|
+
*/
|
|
220
|
+
function extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb) {
|
|
221
|
+
const re = ghSubcmdVerbRegex(subcmd, verb);
|
|
222
|
+
const out = [];
|
|
223
|
+
for (const segment of shellSegments(command)) {
|
|
224
|
+
if (!re.test(segment)) continue;
|
|
225
|
+
const remainder = segment.replace(re, "").trim();
|
|
226
|
+
if (remainder) {
|
|
227
|
+
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
228
|
+
if (args.includes("--help") || args.includes("-h")) continue;
|
|
229
|
+
}
|
|
230
|
+
out.push({ segment, explicitRepo: extractRepoFlagFromSubcmdSegment(segment, subcmd, verb) });
|
|
102
231
|
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The raw external-write verb forms that must be blocked when originating from a subagent:
|
|
237
|
+
* ad-hoc GitHub issue/PR creation and comments run directly via `gh` (not the sanctioned node
|
|
238
|
+
* wrappers). Each entry is `[subcmd, verb]`.
|
|
239
|
+
*/
|
|
240
|
+
const EXTERNAL_WRITE_VERB_FORMS = Object.freeze([
|
|
241
|
+
["issue", "create"],
|
|
242
|
+
["issue", "comment"],
|
|
243
|
+
["pr", "comment"],
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether `command` contains a raw `gh issue create`, `gh issue comment`, or `gh pr comment`
|
|
248
|
+
* invocation in ANY shell segment (ignoring --help/-h). PreToolUse gate use only — the gate
|
|
249
|
+
* blocks these when they originate from a subagent context. Node-wrapper commands
|
|
250
|
+
* (`node scripts/github/comment-issue.mjs …`) never match (first token is `node`, not `gh`).
|
|
251
|
+
* @param {string} command @returns {boolean}
|
|
252
|
+
*/
|
|
253
|
+
export function commandContainsRawExternalWrite(command) {
|
|
254
|
+
return EXTERNAL_WRITE_VERB_FORMS.some(([subcmd, verb]) => findGhSubcmdVerbSegment(command, subcmd, verb) !== null);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Return `{ segment, explicitRepo }` for every raw external-write segment across all three verb
|
|
259
|
+
* forms (`gh issue create` / `gh issue comment` / `gh pr comment`). PreToolUse gate use only —
|
|
260
|
+
* lets the gate decide in-scope-ness per segment so a leading out-of-scope write can't shield a
|
|
261
|
+
* later in-scope one. `explicitRepo` is the segment's `--repo`/`-R` value or null.
|
|
262
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
263
|
+
*/
|
|
264
|
+
export function extractRepoFlagsFromExternalWriteSegments(command) {
|
|
265
|
+
return EXTERNAL_WRITE_VERB_FORMS.flatMap(([subcmd, verb]) =>
|
|
266
|
+
extractRepoFlagsFromGhSubcmdVerbSegments(command, subcmd, verb),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Return the first segment in the command that is a `gh pr <verb>` call (ignoring --help/-h),
|
|
272
|
+
* or null. Scans ALL segments so compound commands (`echo ok && gh pr merge 1`) are caught.
|
|
273
|
+
*/
|
|
274
|
+
function findGhPrVerbSegment(command, verb) {
|
|
275
|
+
return findGhSubcmdVerbSegment(command, "pr", verb);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Generic `gh pr <verb>` detector — checks the FIRST shell segment only.
|
|
280
|
+
*
|
|
281
|
+
* Used by the Pi extension's post-execute handler (`onUserBash`) to record that `gh pr ready`
|
|
282
|
+
* actually ran. First-segment-only is correct for that use: `false && gh pr ready 42` short-
|
|
283
|
+
* circuits so ready never executes, and the extension should not record a spurious invocation.
|
|
284
|
+
*
|
|
285
|
+
* For the Claude Code PreToolUse gate (block before execution), use
|
|
286
|
+
* `commandContainsGhPrReady`/`commandContainsGhPrMerge` instead — those scan ALL segments.
|
|
287
|
+
*/
|
|
288
|
+
function isGhPrVerbCommand(command, verb) {
|
|
289
|
+
const re = ghPrVerbRegex(verb);
|
|
290
|
+
const segment = firstShellSegment(command);
|
|
291
|
+
if (!segment || !re.test(segment)) return false;
|
|
292
|
+
const remainder = segment.replace(re, "").trim();
|
|
293
|
+
if (!remainder) return true;
|
|
103
294
|
const args = remainder.split(/\s+/).map((a) => a.toLowerCase());
|
|
104
295
|
return !args.includes("--help") && !args.includes("-h");
|
|
105
296
|
}
|
|
106
297
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
298
|
+
/** Extract PR number from a single already-isolated segment (shared by both first- and all-segment paths). */
|
|
299
|
+
function extractPrNumberFromSegment(segment, verb) {
|
|
300
|
+
const re = ghPrVerbRegex(verb);
|
|
301
|
+
if (!segment || !re.test(segment)) return null;
|
|
302
|
+
const remainder = segment.replace(re, "").trim();
|
|
114
303
|
if (!remainder) {
|
|
115
304
|
return null;
|
|
116
305
|
}
|
|
@@ -135,13 +324,11 @@ export function extractPrNumberFromGhPrReady(command) {
|
|
|
135
324
|
return null;
|
|
136
325
|
}
|
|
137
326
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
const remainder = segment.replace(/^gh\s+pr\s+ready(?:\s|$)/i, "").trim();
|
|
327
|
+
/** Extract repo flag from a single already-isolated segment. */
|
|
328
|
+
function extractRepoFlagFromSegment(segment, verb) {
|
|
329
|
+
const re = ghPrVerbRegex(verb);
|
|
330
|
+
if (!segment || !re.test(segment)) return null;
|
|
331
|
+
const remainder = segment.replace(re, "").trim();
|
|
145
332
|
if (!remainder) {
|
|
146
333
|
return null;
|
|
147
334
|
}
|
|
@@ -151,13 +338,130 @@ export function extractRepoFlagFromGhPrReady(command) {
|
|
|
151
338
|
const lower = token.toLowerCase();
|
|
152
339
|
if (lower === "-r" || lower === "--repo") {
|
|
153
340
|
if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) {
|
|
154
|
-
return tokens[i + 1];
|
|
341
|
+
return stripSurroundingQuotes(tokens[i + 1]);
|
|
155
342
|
}
|
|
156
343
|
}
|
|
157
344
|
const repoEqMatch = token.match(/^(?:--repo|-R)=(.+)$/i);
|
|
158
345
|
if (repoEqMatch) {
|
|
159
|
-
return repoEqMatch[1];
|
|
346
|
+
return stripSurroundingQuotes(repoEqMatch[1]);
|
|
160
347
|
}
|
|
161
348
|
}
|
|
162
|
-
|
|
349
|
+
// No explicit --repo/-R flag: fall back to an inline GH_REPO= env assignment (flag wins,
|
|
350
|
+
// mirroring gh's own precedence). Applied here too so gh pr ready/merge/create scope checks get
|
|
351
|
+
// consistent GH_REPO handling — the root-cause fix, not just the external-write path (#1074).
|
|
352
|
+
return extractGhRepoEnvAssignment(segment);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** First-segment extractor for `gh pr <verb>` PR number — Pi extension public API. */
|
|
356
|
+
function extractPrNumberFromGhPrVerb(command, verb) {
|
|
357
|
+
return extractPrNumberFromSegment(firstShellSegment(command), verb);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** First-segment extractor for `gh pr <verb>` --repo flag — Pi extension public API. */
|
|
361
|
+
function extractRepoFlagFromGhPrVerb(command, verb) {
|
|
362
|
+
return extractRepoFlagFromSegment(firstShellSegment(command), verb);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** @param {string} command @returns {boolean} */
|
|
366
|
+
export function isGhPrReadyCommand(command) {
|
|
367
|
+
return isGhPrVerbCommand(command, "ready");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** @param {string} command @returns {number|null} */
|
|
371
|
+
export function extractPrNumberFromGhPrReady(command) {
|
|
372
|
+
return extractPrNumberFromGhPrVerb(command, "ready");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** @param {string} command @returns {string|null} */
|
|
376
|
+
export function extractRepoFlagFromGhPrReady(command) {
|
|
377
|
+
return extractRepoFlagFromGhPrVerb(command, "ready");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Whether `command` contains a `gh pr merge` invocation in the FIRST shell segment,
|
|
382
|
+
* ignoring `--help`/`-h`. Used by the Pi extension's post-execute handler.
|
|
383
|
+
* For the Claude Code PreToolUse gate, use `commandContainsGhPrMerge` instead.
|
|
384
|
+
* @param {string} command @returns {boolean}
|
|
385
|
+
*/
|
|
386
|
+
export function isGhPrMergeCommand(command) {
|
|
387
|
+
return isGhPrVerbCommand(command, "merge");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Whether `command` contains a `gh pr ready` invocation in ANY shell segment.
|
|
392
|
+
* For use in the Claude Code PreToolUse gate only — blocks the whole command pre-emptively
|
|
393
|
+
* regardless of shell short-circuit semantics (`false && gh pr ready 42` is still blocked).
|
|
394
|
+
* @param {string} command @returns {boolean}
|
|
395
|
+
*/
|
|
396
|
+
export function commandContainsGhPrReady(command) {
|
|
397
|
+
return findGhPrVerbSegment(command, "ready") !== null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether `command` contains a `gh pr merge` invocation in ANY shell segment.
|
|
402
|
+
* For use in the Claude Code PreToolUse gate only — blocks the whole command pre-emptively.
|
|
403
|
+
* @param {string} command @returns {boolean}
|
|
404
|
+
*/
|
|
405
|
+
export function commandContainsGhPrMerge(command) {
|
|
406
|
+
return findGhPrVerbSegment(command, "merge") !== null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Extract PR number from `gh pr ready` in any shell segment — PreToolUse gate use only. */
|
|
410
|
+
export function extractPrNumberFromGhPrReadyAnywhere(command) {
|
|
411
|
+
return extractPrNumberFromSegment(findGhPrVerbSegment(command, "ready"), "ready");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** @param {string} command @returns {string|null} */
|
|
415
|
+
export function extractRepoFlagFromGhPrReadyAnywhere(command) {
|
|
416
|
+
return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "ready"), "ready");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Extract PR number from `gh pr merge` in any shell segment — PreToolUse gate use only. */
|
|
420
|
+
export function extractPrNumberFromGhPrMergeAnywhere(command) {
|
|
421
|
+
return extractPrNumberFromSegment(findGhPrVerbSegment(command, "merge"), "merge");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** @param {string} command @returns {string|null} */
|
|
425
|
+
export function extractRepoFlagFromGhPrMergeAnywhere(command) {
|
|
426
|
+
return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "merge"), "merge");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Whether `command` contains a raw `gh pr create` invocation in ANY shell segment.
|
|
431
|
+
* PreToolUse gate use only — blocks raw `gh pr create` so PR creation flows through the
|
|
432
|
+
* canonical wrapper (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always
|
|
433
|
+
* creates a draft and self-assigns. The wrapper runs `gh pr create` inside a node child
|
|
434
|
+
* process, so its Bash command string (`node …/create-pr.mjs …`) never matches this — only a
|
|
435
|
+
* literal `gh pr create` in the agent's shell command does.
|
|
436
|
+
* @param {string} command @returns {boolean}
|
|
437
|
+
*/
|
|
438
|
+
export function commandContainsGhPrCreate(command) {
|
|
439
|
+
return findGhPrVerbSegment(command, "create") !== null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Extract repo flag from `gh pr create` in any shell segment — PreToolUse gate use only. */
|
|
443
|
+
export function extractRepoFlagFromGhPrCreateAnywhere(command) {
|
|
444
|
+
return extractRepoFlagFromSegment(findGhPrVerbSegment(command, "create"), "create");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Return one `{ segment, explicitRepo }` entry for EVERY `gh pr create` segment (ignoring
|
|
449
|
+
* --help/-h), not just the first. PreToolUse gate use only: the create-scope decision must
|
|
450
|
+
* consider every create segment, so a leading out-of-scope create can't shield a later
|
|
451
|
+
* in-scope raw create (`gh pr create --repo other/repo && gh pr create --fill`).
|
|
452
|
+
* `explicitRepo` is the segment's `--repo`/`-R` value or null when none is present.
|
|
453
|
+
* @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
|
|
454
|
+
*/
|
|
455
|
+
export function extractRepoFlagsFromGhPrCreateSegments(command) {
|
|
456
|
+
return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** @param {string} command @returns {number|null} */
|
|
460
|
+
export function extractPrNumberFromGhPrMerge(command) {
|
|
461
|
+
return extractPrNumberFromGhPrVerb(command, "merge");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** @param {string} command @returns {string|null} */
|
|
465
|
+
export function extractRepoFlagFromGhPrMerge(command) {
|
|
466
|
+
return extractRepoFlagFromGhPrVerb(command, "merge");
|
|
163
467
|
}
|
|
@@ -697,13 +697,6 @@ export const OUTER_TERMINAL_STATES = Object.freeze([
|
|
|
697
697
|
OUTER_STATE.NEEDS_RECONCILE,
|
|
698
698
|
]);
|
|
699
699
|
|
|
700
|
-
export const OUTER_NONTERMINAL_STATES = Object.freeze([
|
|
701
|
-
OUTER_STATE.CONTINUE_CURRENT_WAIT,
|
|
702
|
-
OUTER_STATE.HANDOFF_TO_COPILOT_LOOP,
|
|
703
|
-
OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP,
|
|
704
|
-
OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER,
|
|
705
|
-
]);
|
|
706
|
-
|
|
707
700
|
const OUTER_TERMINAL_STATE_SET = new Set(OUTER_TERMINAL_STATES);
|
|
708
701
|
const ALL_OUTER_STATES = Object.freeze([...OUTER_STATE_VALUES]);
|
|
709
702
|
|
|
@@ -714,26 +707,6 @@ export const OUTER_GRAPH = Object.freeze({
|
|
|
714
707
|
terminalStates: OUTER_TERMINAL_STATES,
|
|
715
708
|
});
|
|
716
709
|
|
|
717
|
-
export const OUTER_STATE_TO_OUTER_ACTION = Object.freeze({
|
|
718
|
-
[OUTER_STATE.CONTINUE_CURRENT_WAIT]: "continue_wait",
|
|
719
|
-
[OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: "reenter_copilot_loop",
|
|
720
|
-
[OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: "reenter_reviewer_loop",
|
|
721
|
-
[OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: "continue_wait",
|
|
722
|
-
[OUTER_STATE.STOP_NEEDS_HUMAN]: "stop",
|
|
723
|
-
[OUTER_STATE.DONE_TERMINAL]: "done",
|
|
724
|
-
[OUTER_STATE.NEEDS_RECONCILE]: "stop",
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
export const OUTER_STATE_TO_ROUTING_OUTCOME = Object.freeze({
|
|
728
|
-
[OUTER_STATE.CONTINUE_CURRENT_WAIT]: ROUTING_OUTCOME.CONTINUE_CURRENT_WAIT,
|
|
729
|
-
[OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: ROUTING_OUTCOME.HANDOFF_TO_COPILOT_LOOP,
|
|
730
|
-
[OUTER_STATE.HANDOFF_TO_REVIEWER_LOOP]: ROUTING_OUTCOME.HANDOFF_TO_REVIEWER_LOOP,
|
|
731
|
-
[OUTER_STATE.STAY_WITH_CURRENT_LIVE_OWNER]: ROUTING_OUTCOME.STAY_WITH_CURRENT_LIVE_OWNER,
|
|
732
|
-
[OUTER_STATE.STOP_NEEDS_HUMAN]: ROUTING_OUTCOME.STOP_NEEDS_HUMAN,
|
|
733
|
-
[OUTER_STATE.DONE_TERMINAL]: ROUTING_OUTCOME.DONE_TERMINAL,
|
|
734
|
-
[OUTER_STATE.NEEDS_RECONCILE]: ROUTING_OUTCOME.NEEDS_RECONCILE,
|
|
735
|
-
});
|
|
736
|
-
|
|
737
710
|
export const OUTER_NEXT_ACTIONS = Object.freeze({
|
|
738
711
|
[OUTER_STATE.CONTINUE_CURRENT_WAIT]: "Remain in outer wait and re-inspect after the bounded interval.",
|
|
739
712
|
[OUTER_STATE.HANDOFF_TO_COPILOT_LOOP]: "Re-enter the Copilot loop.",
|
|
@@ -148,6 +148,30 @@ function isBlockedCiStatus(status) {
|
|
|
148
148
|
return status === "failure";
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Single source of truth for whether the Copilot review round cap has been
|
|
153
|
+
* reached (issue #1126). `copilot-pr-handoff.mjs` enforces the cap by calling
|
|
154
|
+
* `interpretLoopState`, which uses this predicate internally; every other
|
|
155
|
+
* caller that needs the same "cap reached" boolean (gate coordination,
|
|
156
|
+
* detect-pr-gate-coordination-state) MUST call this function too rather than
|
|
157
|
+
* re-deriving `copilotReviewRoundCount >= maxCopilotRounds` locally, so the
|
|
158
|
+
* two never disagree at the cap boundary.
|
|
159
|
+
*
|
|
160
|
+
* `copilotReviewRoundCount` counts COMPLETED rounds, so `>=` means every
|
|
161
|
+
* permitted round has already happened. `maxCopilotRounds` of `null`/`0`/
|
|
162
|
+
* non-number means the cap does not apply (unlimited or disabled).
|
|
163
|
+
*
|
|
164
|
+
* @param {object} params
|
|
165
|
+
* @param {number} params.copilotReviewRoundCount
|
|
166
|
+
* @param {number|null} [params.maxCopilotRounds]
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRounds }) {
|
|
170
|
+
return typeof maxCopilotRounds === "number" && maxCopilotRounds > 0
|
|
171
|
+
&& typeof copilotReviewRoundCount === "number"
|
|
172
|
+
&& copilotReviewRoundCount >= maxCopilotRounds;
|
|
173
|
+
}
|
|
174
|
+
|
|
151
175
|
export function normalizeCiStatus(rollup) {
|
|
152
176
|
return normalizeStatusCheckRollupContract(rollup).overallStatus;
|
|
153
177
|
}
|
|
@@ -375,8 +399,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
|
|
|
375
399
|
const maxRounds = refinementConfig?.maxCopilotRounds;
|
|
376
400
|
const reviewInFlight = s.copilotReviewRequestStatus === "requested"
|
|
377
401
|
|| s.copilotReviewRequestStatus === "already-requested";
|
|
378
|
-
if (
|
|
379
|
-
&& s.copilotReviewRoundCount >= maxRounds
|
|
402
|
+
if (isCopilotRoundCapReached({ copilotReviewRoundCount: s.copilotReviewRoundCount, maxCopilotRounds: maxRounds })
|
|
380
403
|
&& state !== STATE.NO_PR && state !== STATE.DONE
|
|
381
404
|
&& state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
|
|
382
405
|
&& state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
|