@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.6
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 +6 -1
- package/src/claude/hook-decisions.mjs +172 -5
- package/src/cli/primitives.mjs +21 -0
- package/src/config/config.mjs +53 -1
- package/src/config/extension-defaults.yaml +5 -0
- package/src/github/comment-id-guard.mjs +70 -0
- package/src/github/copilot-helpers.mjs +31 -0
- package/src/github/issue-ops.mjs +6 -0
- package/src/loop/agent-stall.mjs +194 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/default-branch-guard.mjs +1 -1
- package/src/loop/handoff-envelope.mjs +28 -1
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/main-checkout-ff.mjs +39 -0
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/review-dispatch-plan.mjs +595 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/move-queue-item.mjs +37 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-level stall detection (#1669).
|
|
3
|
+
*
|
|
4
|
+
* A dev-loop child (subagent) that stops making turn progress for N minutes
|
|
5
|
+
* with no pending supervisor request is a stall: the parent should auto-bail
|
|
6
|
+
* to a fresh-context recovery dispatch (carrying the worktree state + a
|
|
7
|
+
* recovery brief) instead of waiting through a manual interrupt+resume
|
|
8
|
+
* round-trip. This is the deterministic detector behind that decision.
|
|
9
|
+
*
|
|
10
|
+
* It deliberately distinguishes a TRUE stall (no turn progress) from a
|
|
11
|
+
* SANCTIONED LONG WATCH (an active bash/subagent tool call that heartbeats
|
|
12
|
+
* its runner claim). The sanctioned-watch heartbeat is the existing
|
|
13
|
+
* runner-coordination `activeRun.updatedAt` (assertRunnerOwnership) — only
|
|
14
|
+
* sanctioned long waits refresh that claim, so a fresh heartbeat means the
|
|
15
|
+
* run is legitimately busy waiting, not stalled.
|
|
16
|
+
*
|
|
17
|
+
* This module is pure and harness-agnostic: it takes millisecond signals and
|
|
18
|
+
* returns a verdict. The CLI probe (`scripts/loop/detect-agent-stall.mjs`)
|
|
19
|
+
* sources those signals from pi run artifacts + runner-coordination state.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const AGENT_STALL_STATUS = Object.freeze({
|
|
23
|
+
STALLED: "stalled",
|
|
24
|
+
NOT_STALLED: "not_stalled",
|
|
25
|
+
NO_EVIDENCE: "no_evidence",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const AGENT_STALL_REASON = Object.freeze({
|
|
29
|
+
PENDING_REQUEST: "pending_request",
|
|
30
|
+
ACTIVE_TURNS: "active_turns",
|
|
31
|
+
SANCTIONED_WATCH: "sanctioned_watch",
|
|
32
|
+
BELOW_THRESHOLD: "below_threshold",
|
|
33
|
+
NO_SIGNAL: "no_signal",
|
|
34
|
+
DISABLED: "disabled",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default no-turn-progress window before a child is treated as stalled.
|
|
39
|
+
* Matches the issue's "e.g. 5min".
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_AGENT_STALL_THRESHOLD_MS = 5 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a positive threshold from a `thresholdMinutes` value, falling back
|
|
45
|
+
* to {@link DEFAULT_AGENT_STALL_THRESHOLD_MS} for missing/invalid input.
|
|
46
|
+
* Mirrors `resolveStaleRunnerMaxAgeMs` conventions in `_stale-runner-detection.mjs`.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveAgentStallThresholdMs(thresholdMinutes) {
|
|
49
|
+
const n = Number(thresholdMinutes);
|
|
50
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
51
|
+
return DEFAULT_AGENT_STALL_THRESHOLD_MS;
|
|
52
|
+
}
|
|
53
|
+
return Math.floor(n * 60 * 1000);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeMs(value) {
|
|
57
|
+
if (value === null || value === undefined) return null;
|
|
58
|
+
const n = typeof value === "number" ? value : Date.parse(String(value));
|
|
59
|
+
return Number.isFinite(n) ? n : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect whether a dev-loop child has stalled.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} [options]
|
|
66
|
+
* @param {number|string|null} [options.lastActivityAt] Turn-progress signal
|
|
67
|
+
* (last assistant turn / status `lastActivityAt`). ms or parseable date.
|
|
68
|
+
* @param {number|string|null} [options.sanctionedWatchAt] Sanctioned-watch
|
|
69
|
+
* heartbeat (runner-coordination `activeRun.updatedAt`). ms or parseable date.
|
|
70
|
+
* @param {boolean} [options.pendingRequest] True when the child is blocked on a
|
|
71
|
+
* pending supervisor request (never a stall).
|
|
72
|
+
* @param {number|string} [options.now] Reference time (default `Date.now()`).
|
|
73
|
+
* @param {number} [options.thresholdMs] No-turn-progress window.
|
|
74
|
+
* @returns {{status: string, reason: string, stalled: boolean,
|
|
75
|
+
* turnAgeMs: (number|null), watchAgeMs: (number|null), thresholdMs: number}}
|
|
76
|
+
*/
|
|
77
|
+
export function detectAgentStall({
|
|
78
|
+
lastActivityAt = null,
|
|
79
|
+
sanctionedWatchAt = null,
|
|
80
|
+
pendingRequest = false,
|
|
81
|
+
now = Date.now(),
|
|
82
|
+
thresholdMs = DEFAULT_AGENT_STALL_THRESHOLD_MS,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const nowMs = normalizeMs(now) ?? Date.now();
|
|
85
|
+
const actMs = normalizeMs(lastActivityAt);
|
|
86
|
+
const watchMs = normalizeMs(sanctionedWatchAt);
|
|
87
|
+
const hasTurn = actMs !== null;
|
|
88
|
+
const hasWatch = watchMs !== null;
|
|
89
|
+
|
|
90
|
+
if (pendingRequest) {
|
|
91
|
+
return {
|
|
92
|
+
status: AGENT_STALL_STATUS.NOT_STALLED,
|
|
93
|
+
reason: AGENT_STALL_REASON.PENDING_REQUEST,
|
|
94
|
+
stalled: false,
|
|
95
|
+
turnAgeMs: hasTurn ? Math.max(0, nowMs - actMs) : null,
|
|
96
|
+
watchAgeMs: hasWatch ? Math.max(0, nowMs - watchMs) : null,
|
|
97
|
+
thresholdMs,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!hasTurn && !hasWatch) {
|
|
102
|
+
return {
|
|
103
|
+
status: AGENT_STALL_STATUS.NO_EVIDENCE,
|
|
104
|
+
reason: AGENT_STALL_REASON.NO_SIGNAL,
|
|
105
|
+
stalled: false,
|
|
106
|
+
turnAgeMs: null,
|
|
107
|
+
watchAgeMs: null,
|
|
108
|
+
thresholdMs,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const turnAgeMs = hasTurn ? Math.max(0, nowMs - actMs) : Infinity;
|
|
113
|
+
const watchAgeMs = hasWatch ? Math.max(0, nowMs - watchMs) : Infinity;
|
|
114
|
+
|
|
115
|
+
// Active turn progress within the window => clearly not stalled.
|
|
116
|
+
if (turnAgeMs <= thresholdMs) {
|
|
117
|
+
return {
|
|
118
|
+
status: AGENT_STALL_STATUS.NOT_STALLED,
|
|
119
|
+
reason: AGENT_STALL_REASON.ACTIVE_TURNS,
|
|
120
|
+
stalled: false,
|
|
121
|
+
turnAgeMs,
|
|
122
|
+
watchAgeMs: hasWatch ? watchAgeMs : null,
|
|
123
|
+
thresholdMs,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// No recent turn progress, but a fresh sanctioned-watch heartbeat => the
|
|
128
|
+
// child is legitimately busy waiting on a long watch, NOT stalled (#1669 AC2).
|
|
129
|
+
if (watchAgeMs <= thresholdMs) {
|
|
130
|
+
return {
|
|
131
|
+
status: AGENT_STALL_STATUS.NOT_STALLED,
|
|
132
|
+
reason: AGENT_STALL_REASON.SANCTIONED_WATCH,
|
|
133
|
+
stalled: false,
|
|
134
|
+
turnAgeMs,
|
|
135
|
+
watchAgeMs,
|
|
136
|
+
thresholdMs,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// No turn progress for the window and no sanctioned-watch heartbeat.
|
|
141
|
+
if (hasTurn) {
|
|
142
|
+
return {
|
|
143
|
+
status: AGENT_STALL_STATUS.STALLED,
|
|
144
|
+
reason: AGENT_STALL_REASON.BELOW_THRESHOLD,
|
|
145
|
+
stalled: true,
|
|
146
|
+
turnAgeMs,
|
|
147
|
+
watchAgeMs: hasWatch ? watchAgeMs : null,
|
|
148
|
+
thresholdMs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// No turn signal at all and no fresh watch heartbeat => treat as stalled.
|
|
153
|
+
return {
|
|
154
|
+
status: AGENT_STALL_STATUS.STALLED,
|
|
155
|
+
reason: AGENT_STALL_REASON.NO_SIGNAL,
|
|
156
|
+
stalled: true,
|
|
157
|
+
turnAgeMs: null,
|
|
158
|
+
watchAgeMs: hasWatch ? watchAgeMs : null,
|
|
159
|
+
thresholdMs,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build a compact recovery brief for a fresh-context dispatch (#1669 AC3).
|
|
165
|
+
* Carries the worktree/run identity plus a short human-readable "where it
|
|
166
|
+
* stalled" line. Pure string-shaping; the caller supplies the observed facts.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} [options]
|
|
169
|
+
* @param {string|null} [options.runId] Async run id.
|
|
170
|
+
* @param {string|null} [options.cwd] Worktree working directory.
|
|
171
|
+
* @param {string|null} [options.lastAction] Last known action/phase.
|
|
172
|
+
* @param {string} [options.reason] Stall reason token.
|
|
173
|
+
* @returns {{runId: string|null, cwd: string|null, brief: string}}
|
|
174
|
+
*/
|
|
175
|
+
export function buildAgentStallRecoveryBrief({
|
|
176
|
+
runId = null,
|
|
177
|
+
cwd = null,
|
|
178
|
+
lastAction = null,
|
|
179
|
+
reason = "",
|
|
180
|
+
} = {}) {
|
|
181
|
+
const r = typeof runId === "string" && runId.trim().length > 0 ? runId.trim() : null;
|
|
182
|
+
const work = typeof cwd === "string" && cwd.trim().length > 0 ? cwd.trim() : null;
|
|
183
|
+
const action = typeof lastAction === "string" && lastAction.trim().length > 0
|
|
184
|
+
? lastAction.trim()
|
|
185
|
+
: "unknown last action";
|
|
186
|
+
const why = typeof reason === "string" && reason.trim().length > 0 ? reason.trim() : "stalled";
|
|
187
|
+
const brief = [
|
|
188
|
+
`Recovery dispatch (${why}) for run ${r ?? "(unknown)"}.`,
|
|
189
|
+
`Worktree: ${work ?? "(unknown)"}.`,
|
|
190
|
+
`Last known action: ${action}.`,
|
|
191
|
+
"Carry forward worktree state and resume from the last known action; do not restart from scratch.",
|
|
192
|
+
].join(" ");
|
|
193
|
+
return { runId: r, cwd: work, brief, lastAction: action };
|
|
194
|
+
}
|
|
@@ -496,3 +496,280 @@ export function extractPrNumberFromGhPrMerge(command) {
|
|
|
496
496
|
export function extractRepoFlagFromGhPrMerge(command) {
|
|
497
497
|
return extractRepoFlagFromGhPrVerb(command, "merge");
|
|
498
498
|
}
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// gh api URL-path matchers + the six guard-rule classifiers (#1622).
|
|
502
|
+
// These make the six rules that describe operations the Bash gate could refuse
|
|
503
|
+
// enforceable at one seam (decideBashGate in hook-decisions.mjs), where raw
|
|
504
|
+
// `gh api` shapes were previously unclassified (anything expressed as a raw API
|
|
505
|
+
// call was invisible to the gate).
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
/** gh api value-taking flags (short forms). Each consumes the following token. Lowercase (compared
|
|
509
|
+
* against token.toLowerCase()) — covers every value-taking short flag gh api accepts so a flag
|
|
510
|
+
* placed BEFORE the endpoint skips its value and the real endpoint is still read (#1622):
|
|
511
|
+
* -X/--method, -m/--method, -f/--field, -F/--raw-field (both case-fold to -f), -q/--jq, -p/--preview,
|
|
512
|
+
* -t/--template, -r/--repo. `-h` (help) is intentionally EXCLUDED: it is a boolean help flag that
|
|
513
|
+
* consumes no value, and case-folding it together with `-H` (header) made a mid-command `-h`
|
|
514
|
+
* swallow the real endpoint and bypass the write-path deny (#1622). `-H` is matched as an exact
|
|
515
|
+
* token in the scanner so it stays a value-taking flag despite the case-fold. */
|
|
516
|
+
const GH_API_VALUE_FLAGS = new Set(["-x", "-m", "-f", "-r", "-q", "-p", "-t"]);
|
|
517
|
+
/** gh api value-taking flags (long forms). Each consumes the following token. */
|
|
518
|
+
const GH_API_VALUE_LONG_FLAGS = new Set([
|
|
519
|
+
"--method", "--field", "--raw-field", "--header", "--repo", "--jq", "--preview",
|
|
520
|
+
"--template", "--hostname", "--input", "--cache", "--cache-ttl", "--unix-socket",
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
function ghApiRegex() {
|
|
524
|
+
return new RegExp(`^${SHELL_EXEC_PREFIX}gh\\s+api(?:\\s|$)`, "i");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Return one `{ segment, endpoint }` entry for EVERY `gh api <endpoint>` call (ignoring --help/-h).
|
|
529
|
+
* `endpoint` is the first positional (non-flag) token after `gh api`, skipping value-taking flags and
|
|
530
|
+
* their values (`gh api -X POST repos/...`, `gh api --method POST repos/...`). Env-assignment /
|
|
531
|
+
* `command`/`env`/`exec` wrapper / binary-path prefixes are tolerated via the shared SHELL_EXEC_PREFIX
|
|
532
|
+
* (`GH_TOKEN=x gh api ...`, `/usr/bin/gh api ...`). Node-wrapper commands (`node scripts/...`) never
|
|
533
|
+
* match — first token is `node`, not `gh`. The endpoint may be a full URL, a `repos/OWNER/REPO/...`
|
|
534
|
+
* path (gh api prefixes a bare path with `https://api.github.com/`), or `graphql`.
|
|
535
|
+
* @param {string} command @returns {{ segment: string, endpoint: string|null }[]}
|
|
536
|
+
*/
|
|
537
|
+
export function extractGhApiEndpointSegments(command) {
|
|
538
|
+
const re = ghApiRegex();
|
|
539
|
+
const out = [];
|
|
540
|
+
for (const segment of shellSegments(command)) {
|
|
541
|
+
if (!re.test(segment)) continue;
|
|
542
|
+
const remainder = segment.replace(re, "").replace(/(?:--help|-h)\s*$/i, "").trim();
|
|
543
|
+
if (!remainder) continue;
|
|
544
|
+
const tokens = remainder.split(/\s+/);
|
|
545
|
+
let endpoint = null;
|
|
546
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
547
|
+
const token = tokens[i];
|
|
548
|
+
if (!token.startsWith("-")) {
|
|
549
|
+
endpoint = token;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
// `-h`/`--help` is a boolean help flag (consumes no value); mid-command it must not swallow
|
|
553
|
+
// the endpoint and silently bypass the write-path deny. `-H` is the case-sensitive header
|
|
554
|
+
// value flag and stays value-taking here even though `-h` is excluded from the folded set.
|
|
555
|
+
if (token === "-h" || token === "--help") continue;
|
|
556
|
+
const lower = token.toLowerCase();
|
|
557
|
+
if (token === "-H" || GH_API_VALUE_FLAGS.has(lower) || GH_API_VALUE_LONG_FLAGS.has(lower)) {
|
|
558
|
+
i += 1; // consume the flag's value token
|
|
559
|
+
// A value that opens with a quote may span whitespace (e.g. `-H "Accept: application/vnd.github+json"`)
|
|
560
|
+
// — keep consuming tokens until the matching closing quote so the quoted value is skipped whole
|
|
561
|
+
// and a later positional endpoint is not mis-read as the value's remainder.
|
|
562
|
+
if (i < tokens.length && (tokens[i][0] === '"' || tokens[i][0] === "'")) {
|
|
563
|
+
const quote = tokens[i][0];
|
|
564
|
+
while (i < tokens.length && !tokens[i].endsWith(quote)) i += 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// A quoted endpoint (`gh api "repos/..."`) carries its surrounding quotes through the tokenizer;
|
|
569
|
+
// strip them so the write-path/anchor regexes see the bare path.
|
|
570
|
+
if (endpoint && endpoint.length >= 2 && (endpoint[0] === '"' || endpoint[0] === "'")) {
|
|
571
|
+
const q = endpoint[0];
|
|
572
|
+
if (endpoint.endsWith(q)) endpoint = endpoint.slice(1, -1);
|
|
573
|
+
}
|
|
574
|
+
out.push({ segment, endpoint });
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The `gh api` segments whose endpoint is the target repo's URL path. Matches the absolute
|
|
580
|
+
* slug-embedded form (`repos/mfittko/dev-loops/...`) and the bare relative form (`issues/...`),
|
|
581
|
+
* which gh api resolves against the cwd repo — the decideBashGate call site gates the relative form
|
|
582
|
+
* on `inTargetRepo`. */
|
|
583
|
+
function targetGhApiPathRegex(suffix) {
|
|
584
|
+
const slug = TARGET_REPO_SLUG.replace("/", "\\/");
|
|
585
|
+
return new RegExp(`(?:repos/${slug}/|^)${suffix}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
|
|
589
|
+
* yielding the bare `/repos/<slug>/…` path that the write-path anchors match. gh api accepts both a
|
|
590
|
+
* bare `repos/<slug>/…`/`issues/…` path and an absolute https:// URL, so both must reach the same
|
|
591
|
+
* anchors or an absolute-URL write bypasses the deny (#1622). */
|
|
592
|
+
function normalizeGhApiEndpoint(endpoint) {
|
|
593
|
+
if (!endpoint) return endpoint;
|
|
594
|
+
return endpoint.replace(/^https?:\/\/[^/]+/, "").replace(/^\//, "").replace(/\/+$/, "");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Whether any `gh api` segment targets the `graphql` endpoint. */
|
|
598
|
+
function ghApiGraphqlSegments(command) {
|
|
599
|
+
return extractGhApiEndpointSegments(command).filter(({ endpoint }) => endpoint && /^graphql$/i.test(endpoint));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Whether a `gh api` segment names an explicit write method (POST/PUT/PATCH/DELETE). gh api
|
|
603
|
+
* defaults to GET, so the ad-hoc-write predicates require an explicit write method to refuse. */
|
|
604
|
+
function ghApiSegmentHasWriteMethod(segment) {
|
|
605
|
+
const re = ghApiRegex();
|
|
606
|
+
if (!re.test(segment)) return false;
|
|
607
|
+
const tokens = segment.replace(re, "").trim().split(/\s+/).filter(Boolean);
|
|
608
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
609
|
+
// Only a method FLAG (`--method`, `-X`/`-m`, or an inline `=POST`) declares an explicit write
|
|
610
|
+
// method. A method-looking token inside a FIELD VALUE (`-F 'body=--method DELETE'`) is data, not
|
|
611
|
+
// the method flag, so the read gets no write-method deny (token-scoped, not segment-scoped).
|
|
612
|
+
const m = tokens[i].match(/^(?:--method|-X|-m)(?:=([A-Za-z]+)|([A-Za-z]+))?$/i);
|
|
613
|
+
if (!m) continue;
|
|
614
|
+
const inline = m[1] ?? m[2];
|
|
615
|
+
const value = (inline ?? tokens[i + 1] ?? "").replace(/^["']|["']$/g, "");
|
|
616
|
+
if (/^(?:POST|PUT|PATCH|DELETE)\b/i.test(value)) return true;
|
|
617
|
+
}
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the target
|
|
623
|
+
* repo — the ad-hoc sub-issue mutation that must flow through the sanctioned `manage-sub-issues`
|
|
624
|
+
* wrapper instead. Actor-independent (the issue's decided policy): the main agent gets no reserved
|
|
625
|
+
* direct path to sub-issue writes. Anchored on the target repo's URL path segment AND an explicit write
|
|
626
|
+
* method, so a `gh api` read or another repo's `sub_issues` write passes through (no false deny).
|
|
627
|
+
* @param {string} command @returns {boolean}
|
|
628
|
+
*/
|
|
629
|
+
export function commandContainsSubIssueAdHocBypass(command) {
|
|
630
|
+
const re = targetGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`);
|
|
631
|
+
return extractGhApiEndpointSegments(command).some(
|
|
632
|
+
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (REST half): raw `gh api` POST to
|
|
638
|
+
* `.../pulls/<n>/comments/<m>/replies` on the target repo — the ad-hoc thread reply that must flow
|
|
639
|
+
* through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved direct reply path.
|
|
640
|
+
* @param {string} command @returns {boolean}
|
|
641
|
+
*/
|
|
642
|
+
export function commandContainsReplyResolveBypass(command) {
|
|
643
|
+
const re = targetGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`);
|
|
644
|
+
return extractGhApiEndpointSegments(command).some(
|
|
645
|
+
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (GraphQL half): a raw `gh api graphql` that carries a
|
|
651
|
+
* `resolveReviewThread` mutation — the ad-hoc GraphQL thread-resolution bypass, which must also flow
|
|
652
|
+
* through `reply-resolve-review-thread(s).mjs`. `graphql` has no path-host repo (gh api graphql
|
|
653
|
+
* resolves against the cwd repo), so the surrounding decideBashGate scopes it to the target repo.
|
|
654
|
+
* @param {string} command @returns {boolean}
|
|
655
|
+
*/
|
|
656
|
+
export function commandContainsGraphqlResolveReviewThread(command) {
|
|
657
|
+
return ghApiGraphqlSegments(command).some(({ segment }) => /resolveReviewThread/.test(segment));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (REST half): raw `gh api` write to
|
|
662
|
+
* `.../pulls/<n>/requested_reviewers` on the target repo — the ad-hoc Copilot review request that must
|
|
663
|
+
* flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
|
|
664
|
+
* @param {string} command @returns {boolean}
|
|
665
|
+
*/
|
|
666
|
+
export function commandContainsCopilotRequestBypass(command) {
|
|
667
|
+
const re = targetGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`);
|
|
668
|
+
return extractGhApiEndpointSegments(command).some(
|
|
669
|
+
({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (comment-summon half): a raw `gh pr comment` body carrying a
|
|
675
|
+
* bare Copilot summon (`/copilot` or `/copilot re-review`). The agent MUST request Copilot via
|
|
676
|
+
* `request-copilot-review.mjs`, never by posting a literal `/copilot` comment. Actor-independent: even
|
|
677
|
+
* the main agent (which may otherwise post `gh pr comment`) must not summon Copilot by comment.
|
|
678
|
+
* @param {string} command @returns {boolean}
|
|
679
|
+
*/
|
|
680
|
+
export function commandContainsCopilotSummonComment(command) {
|
|
681
|
+
if (!findGhSubcmdVerbSegment(command, "pr", "comment")) return false;
|
|
682
|
+
// A bare summon is `/copilot` or `/copilot re-review` on its own (optionally quoted) — never a
|
|
683
|
+
// prose mention like `see /copilot for more` / `see /copilot docs`. A bare `/copilot` must run to
|
|
684
|
+
// the end of the (quoted) body; the explicit `re-review` form allows trailing modifiers
|
|
685
|
+
// (`/copilot re-review now`) so appending a word cannot defeat the summon deny (#1622).
|
|
686
|
+
// A summon is `/copilot`/`/copilot re-review` at the START of the quoted body — anchored on the
|
|
687
|
+
// opening quote so a trailing prose mention (`--body "see /copilot"` / `"thanks /copilot"`) is
|
|
688
|
+
// NOT misread as a bare summon, and an in-prose `/copilot re-review` (`"see ... re-review in
|
|
689
|
+
// docs"`) is likewise not a summon. Only `gh pr comment` segments reach here (guard above).
|
|
690
|
+
return /(["'])\s*\/copilot(?:\s+re-review\b(?:\s+[^\s"']+)*|\s*(?:["']|$))/i.test(command);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* COPILOT-FOLLOWUP-WAIT-TOOLS: a banned detached/polling wait — `nohup`, `disown`, `tmux new-session`,
|
|
695
|
+
* `screen -dm`, or a `while`/`until`/`seq` loop whose body contains both a `sleep` and a gh or
|
|
696
|
+
* loop-state call. Behavioral rule (required-rules classification `agent`): scoped in decideBashGate to the
|
|
697
|
+
* dev-loop driving agent (subagent-only) so the main agent/operator retains manual wait tooling.
|
|
698
|
+
* @param {string} command @returns {boolean}
|
|
699
|
+
*/
|
|
700
|
+
export function commandContainsDetachedWaitTool(command) {
|
|
701
|
+
const whole = command.trim();
|
|
702
|
+
// while/until/seq polling loop with both a sleep and a gh or loop-state call. The loop body is
|
|
703
|
+
// `;`-delimited, so this is checked against the whole command (a per-segment split would
|
|
704
|
+
// separate the `while` head from the `sleep`/`gh` body calls and miss the pattern).
|
|
705
|
+
// A polling loop is detected wherever the `while`/`until`/`seq` head appears (a leading expression
|
|
706
|
+
// like `gh pr view 1 && while ...` must not silence the deny) as long as the body carries both a
|
|
707
|
+
// `sleep` and a gh/loop-state call. `gh` must be a standalone token (followed by whitespace/end) —
|
|
708
|
+
// a bare mention of `gh` inside another word (`grep gh-notes`) is not a GitHub call.
|
|
709
|
+
// while/until/for loop heads (a bare `seq` sequence generator is not a loop head on its own —
|
|
710
|
+
// `seq | while read` is caught by the `while` head), with `sleep` and a gh/loop-state *call*.
|
|
711
|
+
// loop-state must sit at a command-head position (`; lo`, `&& lo`, start), not be a substring of
|
|
712
|
+
// a grep/echo target (no false-deny on `grep loop-state x`).
|
|
713
|
+
if (/(?:while|until|for)\b/i.test(whole) && /\bsleep\b/.test(whole) && /\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)) {
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
return shellSegments(command).some((segment) => {
|
|
717
|
+
// `nohup`/`disown` only detach when they head a command (segment start, or right after a shell
|
|
718
|
+
// operator) — a bare mention (`cat nohup.out`, `echo "nohup banned"`) is not a detach.
|
|
719
|
+
if (/(?:^|[;&|])\s*(?:nohup|disown)\b/.test(segment)) return true;
|
|
720
|
+
if (/^tmux\s+new-session\b/i.test(segment)) return true;
|
|
721
|
+
if (/^screen\s+-dm/i.test(segment)) return true;
|
|
722
|
+
return false;
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Build a `node`/`python`/`python3` command-head matcher (env/wrapper/path prefix tolerated). */
|
|
727
|
+
function interpreterRegex(bin) {
|
|
728
|
+
return new RegExp(`^${SHELL_EXEC_PREFIX}${bin}(?:\\s|$)`, "i");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* OPS-NO-INLINE-INTERPRETER: an inline interpreter — `node -e`/`--eval`/`-p`, `python3 -c`, or a
|
|
733
|
+
* heredoc fed to node/python (`node - <<EOF`, `python3 - <<EOF`). Ported from the long-orphaned
|
|
734
|
+
* inline-interpreter classifier in the retrospective-tooling check (zero production callers). Sanctioned
|
|
735
|
+
* output parsing uses `--jq`/`--silent`, never an inline interpreter. Actor-independent: the rule bars
|
|
736
|
+
* "Coordinator and agent flows" (both actors). Script-path invocations (running a `.mjs` file,
|
|
737
|
+
* `python3 script.py`) never match.
|
|
738
|
+
* @param {string} command @returns {boolean}
|
|
739
|
+
*/
|
|
740
|
+
export function commandContainsInlineInterpreter(command) {
|
|
741
|
+
return shellSegments(command).some((segment) => {
|
|
742
|
+
const s = segment.trim();
|
|
743
|
+
if (interpreterRegex("node").test(s)) {
|
|
744
|
+
const code = s.replace(interpreterRegex("node"), "").trim();
|
|
745
|
+
// Heredoc fed straight to node where node is the command head (`node - <<EOF`, `node <<EOF`),
|
|
746
|
+
// so `grep node <<EOF` (interpreter is grep) does not false-positive as an inline interpreter.
|
|
747
|
+
if (/^(?:-\s*)?<</i.test(code)) return true;
|
|
748
|
+
const tokens = code.split(/\s+/).filter(Boolean);
|
|
749
|
+
// Node value-taking flags (short + long) each consume the following token. Consuming them lets
|
|
750
|
+
// a value-taking flag BEFORE the interpreter flag (`node --require ./setup.js -e "..."`) route
|
|
751
|
+
// on to `-e`/`--eval`/`-p` instead of breaking the scan at the flag's value (#1622).
|
|
752
|
+
const NODE_VALUE_FLAGS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader", "--env-file", "--conditions", "-C", "--cwd"]);
|
|
753
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
754
|
+
const t = tokens[i];
|
|
755
|
+
// `-e`/`-p` may be directly attached to the code (`node -e"console.log(1)"`), which a
|
|
756
|
+
// prefix match catches; break at the first non-flag token so a later `-e` on a script path
|
|
757
|
+
// is a script argument.
|
|
758
|
+
if (t === "-e" || t.startsWith("-e") || t === "--eval" || t.startsWith("--eval=") || t === "-p" || t.startsWith("-p")) return true;
|
|
759
|
+
if (!t.startsWith("-")) break; // script path reached — a later `-e` is a script argument
|
|
760
|
+
if (NODE_VALUE_FLAGS.has(t)) { i += 1; continue; } // skip the flag's value token
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (interpreterRegex("python3?").test(s)) {
|
|
764
|
+
const code = s.replace(interpreterRegex("python3?"), "").trim();
|
|
765
|
+
// Heredoc fed straight to python where python is the command head (`python3 - <<EOF`).
|
|
766
|
+
if (/^(?:-\s*)?<</i.test(code)) return true;
|
|
767
|
+
const tokens = code.split(/\s+/).filter(Boolean);
|
|
768
|
+
for (const t of tokens) {
|
|
769
|
+
if (t === "-c" || t.startsWith("-c")) return true;
|
|
770
|
+
if (!t.startsWith("-")) break; // script path reached — a later `-c` is a script argument
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return false;
|
|
774
|
+
});
|
|
775
|
+
}
|