@dev-loops/core 1.0.3 → 1.0.4-pre.1

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.
@@ -11,20 +11,71 @@ import { trimmedOrNull } from "../loop/normalize.mjs";
11
11
  // review is.
12
12
  export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
13
13
 
14
- // Copilot's COMMENTED review summary opens with a disposition header whose
15
- // emoji is the authoritative signal: "### 🟡 Changes recommended" (findings)
16
- // vs "### 🟢 Approval recommended" (clean). Keying on the 🟡 marker means a
17
- // clean body that merely quotes the phrase "changes recommended" — with or
18
- // without markdown emphasis ("No **changes recommended**", "No _changes
19
- // recommended_") — is never a false finding. The strong no-emoji signal is
20
- // already covered by the CHANGES_REQUESTED state.
21
- const COPILOT_CHANGES_RECOMMENDED_MARKER = "🟡";
14
+ // Copilot's `ccr-overview-v2` COMMENTED review opens with ONE of three
15
+ // disposition headers: "### 🟢 Approval recommended" (clean), "### 🟡 Changes
16
+ // recommended" (findings), or "### 🔵 Needs a closer look" (non-approval, the
17
+ // reviewer is uncertain). All Copilot reviews are COMMENTED, so this header is
18
+ // the only body signal. We match the disposition by its TEXT, not the emoji:
19
+ // the old 🟡-only key read the 🔵 "Needs a closer look" non-approval as clean
20
+ // and would fail-open on any future glyph change. Line-anchor the disposition
21
+ // header (`^###\s+`, multiline) with the emoji optional so a body merely
22
+ // QUOTING a phrase ("No changes recommended") never false-positives.
23
+ const COPILOT_DISPOSITION_HEADER_RE = /^###\s+(.+)$/mu;
24
+ // Strip a leading emoji/glyph run (any leading non-letters) so the text alone
25
+ // is compared, then lowercase for a case-insensitive disposition lookup.
26
+ const COPILOT_DISPOSITION_LEADING_GLYPHS_RE = /^[^\p{L}]+/u;
27
+ const COPILOT_CLEAN_DISPOSITION = "approval recommended";
28
+ const COPILOT_CHANGES_RECOMMENDED_DISPOSITION = "changes recommended";
29
+ const COPILOT_NEEDS_CLOSER_LOOK_DISPOSITION = "needs a closer look";
30
+
31
+ // Canonical current-head Copilot review body dispositions. `changes_recommended`
32
+ // (🟡) and `unrecognized` are actionable non-approvals; `needs_closer_look` (🔵)
33
+ // is a soft, conductor-overridable non-approval; `clean` (🟢) and `none` (no
34
+ // disposition header) are not findings. Both the loop-block signal
35
+ // (copilotReviewBodySignalsChanges) and the merge-convergence precondition read
36
+ // the SAME classification, so the two can never drift.
37
+ export const COPILOT_DISPOSITION = Object.freeze({
38
+ CLEAN: "clean",
39
+ CHANGES_RECOMMENDED: "changes_recommended",
40
+ NEEDS_CLOSER_LOOK: "needs_closer_look",
41
+ UNRECOGNIZED: "unrecognized",
42
+ NONE: "none",
43
+ });
22
44
 
23
- export function copilotReviewBodySignalsChanges(state, body) {
45
+ /**
46
+ * Classify a Copilot review's current-head body disposition. Text-matched (not
47
+ * emoji-keyed) so a glyph change never silently reclassifies. Fails closed: an
48
+ * unrecognized `### ` disposition header returns UNRECOGNIZED (an actionable
49
+ * non-approval), so a future format change degrades safe.
50
+ */
51
+ export function classifyCopilotReviewBodyDisposition(state, body) {
24
52
  const normalizedState = typeof state === "string" ? state.toUpperCase() : "";
25
- if (normalizedState === "CHANGES_REQUESTED") return true;
26
- if (normalizedState !== "COMMENTED") return false;
27
- return typeof body === "string" && body.includes(COPILOT_CHANGES_RECOMMENDED_MARKER);
53
+ // A human-style CHANGES_REQUESTED is always actionable regardless of body.
54
+ if (normalizedState === "CHANGES_REQUESTED") return COPILOT_DISPOSITION.CHANGES_RECOMMENDED;
55
+ if (normalizedState !== "COMMENTED") return COPILOT_DISPOSITION.NONE;
56
+ if (typeof body !== "string") return COPILOT_DISPOSITION.NONE;
57
+
58
+ const headerMatch = body.match(COPILOT_DISPOSITION_HEADER_RE);
59
+ // No disposition header at all (empty body, generic footer, legacy format):
60
+ // no body signal, so this is not a finding.
61
+ if (!headerMatch) return COPILOT_DISPOSITION.NONE;
62
+
63
+ const disposition = headerMatch[1]
64
+ .replace(COPILOT_DISPOSITION_LEADING_GLYPHS_RE, "")
65
+ .trim()
66
+ .toLowerCase();
67
+ if (disposition === COPILOT_CLEAN_DISPOSITION) return COPILOT_DISPOSITION.CLEAN;
68
+ if (disposition === COPILOT_CHANGES_RECOMMENDED_DISPOSITION) return COPILOT_DISPOSITION.CHANGES_RECOMMENDED;
69
+ if (disposition === COPILOT_NEEDS_CLOSER_LOOK_DISPOSITION) return COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK;
70
+ // Fail closed on an unrecognized disposition header on the current head.
71
+ return COPILOT_DISPOSITION.UNRECOGNIZED;
72
+ }
73
+
74
+ export function copilotReviewBodySignalsChanges(state, body) {
75
+ const disposition = classifyCopilotReviewBodyDisposition(state, body);
76
+ return disposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED
77
+ || disposition === COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK
78
+ || disposition === COPILOT_DISPOSITION.UNRECOGNIZED;
28
79
  }
29
80
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
30
81
  // `review` is a RECOGNIZED gate header that carries no draft/pre-approval
@@ -213,7 +264,7 @@ export function sanitizeCopilotSummonTokens(text) {
213
264
  // line) from `text`, leaving only bare-text markdown to scan. Unlike
214
265
  // transformNonFencedLines, fenced content here must be REMOVED, not kept:
215
266
  // leaving it would let bare text inside a fence still match the summon scan.
216
- function stripMarkdownCodeForScan(text) {
267
+ export function stripMarkdownCodeForScan(text) {
217
268
  const lines = String(text).split(/\r?\n/);
218
269
  let inFencedBlock = false;
219
270
  let fencedDelimiter = "";
@@ -259,7 +310,8 @@ export function normalizeTimestamp(value) {
259
310
  export function extractReviewCommitSha(review) {
260
311
  const graphqlSha = typeof review?.commit?.oid === "string" ? review.commit.oid.trim() : "";
261
312
  const restSha = typeof review?.commit_id === "string" ? review.commit_id.trim() : "";
262
- const sha = graphqlSha || restSha;
313
+ const camelCaseSha = typeof review?.commitId === "string" ? review.commitId.trim() : "";
314
+ const sha = graphqlSha || restSha || camelCaseSha;
263
315
  return sha.length > 0 ? sha : null;
264
316
  }
265
317
 
@@ -717,6 +769,11 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
717
769
  let hasSubmittedReviewOnCurrentHead = false;
718
770
  let latestSubmittedReviewOnCurrentHeadAt = null;
719
771
  let hasBodyFindingOnCurrentHead = false;
772
+ // The id of the review whose body set hasBodyFindingOnCurrentHead: the
773
+ // review a copilot-body-disposition record must name to clear the finding.
774
+ // Updated on every branch that updates hasBodyFindingOnCurrentHead so the two
775
+ // never drift.
776
+ let bodyFindingReviewId = null;
720
777
  let completedCopilotReviewRounds = 0;
721
778
 
722
779
  for (const review of effectiveReviews) {
@@ -742,21 +799,28 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
742
799
  const submittedAt = typeof review?.submittedAt === "string"
743
800
  ? review.submittedAt
744
801
  : (typeof review?.submitted_at === "string" ? review.submitted_at : null);
802
+ const reviewId = review?.id !== null && review?.id !== undefined ? String(review.id) : null;
745
803
  if (submittedAt !== null && (latestSubmittedReviewOnCurrentHeadAt === null || submittedAt > latestSubmittedReviewOnCurrentHeadAt)) {
746
804
  latestSubmittedReviewOnCurrentHeadAt = submittedAt;
747
805
  hasBodyFindingOnCurrentHead = copilotReviewBodySignalsChanges(state, review?.body);
748
- } else if (submittedAt !== null && submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
749
- // Equal-timestamp tie on the same head: fail toward surfacing so array
750
- // order never silently drops a finding when two reviews share a timestamp.
751
- hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
752
- } else if (submittedAt === null && latestSubmittedReviewOnCurrentHeadAt === null) {
753
- hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || copilotReviewBodySignalsChanges(state, review?.body);
806
+ bodyFindingReviewId = hasBodyFindingOnCurrentHead ? reviewId : null;
807
+ } else if (submittedAt === latestSubmittedReviewOnCurrentHeadAt) {
808
+ // Equal-timestamp (or both-null) tie on the same head: fail toward
809
+ // surfacing so array order never silently drops a finding. When two
810
+ // tied reviews both signal, no single review owns the finding, so
811
+ // bodyFindingReviewId is null and no disposition record can clear it.
812
+ const tieSignals = copilotReviewBodySignalsChanges(state, review?.body);
813
+ if (tieSignals) {
814
+ bodyFindingReviewId = hasBodyFindingOnCurrentHead ? null : reviewId;
815
+ }
816
+ hasBodyFindingOnCurrentHead = hasBodyFindingOnCurrentHead || tieSignals;
754
817
  }
755
818
  }
756
819
  }
757
820
 
758
821
  return {
759
822
  copilotReviews,
823
+ effectiveCopilotReviews: effectiveReviews,
760
824
  copilotReviewIds: copilotReviews
761
825
  .map((review) => review?.id)
762
826
  .filter((id) => id !== null && id !== undefined)
@@ -767,5 +831,6 @@ export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs }
767
831
  hasSubmittedReviewOnCurrentHead,
768
832
  latestSubmittedReviewOnCurrentHeadAt,
769
833
  hasBodyFindingOnCurrentHead,
834
+ bodyFindingReviewId,
770
835
  };
771
836
  }
package/src/github/gh.mjs CHANGED
@@ -67,7 +67,8 @@ export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRu
67
67
  * @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
68
68
  * @param {object} [opts]
69
69
  * @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
70
- * in the response is returned instead of thrown.
70
+ * in the response is returned instead of thrown. This includes a non-zero
71
+ * `gh` exit whose stdout is JSON with an `errors` array.
71
72
  */
72
73
  export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
73
74
  const fieldArgs = [];
@@ -80,6 +81,18 @@ export async function ghGraphql(query, vars, env, runChild = defaultRunChild, {
80
81
  env,
81
82
  );
82
83
  if (result.code !== 0) {
84
+ // `gh api graphql` exits non-zero when the response carries GraphQL
85
+ // `errors` (e.g. NOT_FOUND) but still prints the JSON on stdout. With
86
+ // allowErrors the caller wants that errors array, so return it.
87
+ if (allowErrors) {
88
+ let errorPayload = null;
89
+ try {
90
+ errorPayload = JSON.parse(result.stdout);
91
+ } catch {
92
+ // not JSON: fall through to GH_API_ERROR
93
+ }
94
+ if (Array.isArray(errorPayload?.errors)) return errorPayload;
95
+ }
83
96
  const detail = result.stderr.trim() || `exit code ${result.code}`;
84
97
  throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
85
98
  }
@@ -121,6 +121,11 @@ export function parseReviewThreads(payload) {
121
121
  const threads = rawThreads.map((thread, threadIndex) => {
122
122
  const threadId = normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`);
123
123
  const isResolved = Boolean(thread?.isResolved);
124
+ // The review that opened the thread: the root (first) comment's
125
+ // pullRequestReview id. null when the payload does not carry it, so a
126
+ // consumer that needs attribution treats the thread as unattributed.
127
+ const rootReviewId = extractRawComments(thread)[0]?.pullRequestReview?.id;
128
+ const reviewId = typeof rootReviewId === "string" && rootReviewId.length > 0 ? rootReviewId : null;
124
129
  const normalizedComments = extractRawComments(thread)
125
130
  .map((comment, commentIndex) => normalizeComment(comment, threadId, commentIndex, { isResolved }))
126
131
  .sort((left, right) => compareIds(left.id, right.id));
@@ -141,6 +146,7 @@ export function parseReviewThreads(payload) {
141
146
  return {
142
147
  id: threadId,
143
148
  isResolved,
149
+ reviewId,
144
150
  isActionable: actionableCommentIds.length > 0,
145
151
  commentIds,
146
152
  commentDatabaseIds,
@@ -235,6 +235,34 @@ function shellSegments(command) {
235
235
  */
236
236
  const SHELL_EXEC_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
237
237
 
238
+ /**
239
+ * Leading prefix a code-verification/build command may carry before its real executable: the
240
+ * shared `SHELL_EXEC_PREFIX` (env assignments, `command`/`env`/`exec` wrapper words, binary path)
241
+ * plus `nice`/`timeout` process wrappers, scoped to this classifier only so the sibling `gh`/`git`
242
+ * classifiers above are not broadened by wrapper forms they never need to tolerate. Covers bare
243
+ * `nice`, `nice -n <N>`, bare `timeout <duration>`, and `timeout` carrying `-s <sig>`/`-k <dur>`/
244
+ * `--signal=<sig>`/`--kill-after=<dur>`/`--preserve-status`/`--foreground` before the duration —
245
+ * the wrapper forms the coordinator's daily verify/build commands are routinely run behind
246
+ * (`timeout 600 bun run verify`, `nice -n 10 bun run verify`). Not a full flag parser: other
247
+ * `timeout`/`nice` flags are a known, deliberately uncovered ceiling.
248
+ *
249
+ * The `env` wrapper word additionally tolerates zero-or-more trailing `NAME=value` assignments
250
+ * before the real executable (`env CI=1 bun run verify`, `env CI=1 FOO=bar npm test`) — the common
251
+ * everyday `env VAR=value ... cmd` CI-invocation shape, on top of the bare-leading-assignment form
252
+ * (`CI=1 bun run verify`) already covered by the shared assignment run at the front of this prefix.
253
+ * It also tolerates the common `env` OPTION forms (mixed freely with `NAME=value` assignments, in
254
+ * any order/count): `-i`/`--ignore-environment`, `-u <NAME>`/`--unset=<NAME>`, `-C <dir>`/
255
+ * `--chdir=<dir>`, `-S <str>`/`--split-string=<str>`, a bare `-`, and `--` — so
256
+ * `env -u DEVLOOPS_COORDINATOR_READONLY bun run verify`, `env -i bun run verify`, and
257
+ * `env -u FOO CI=1 npm test` all match. Closes the cheap classifier gap where an `env` flag (rather
258
+ * than a `NAME=value` assignment) reached the executable unclassified. Not a full `env` flag parser:
259
+ * any other/exotic `env` option is a known, deliberately uncovered ceiling (documented, not chased).
260
+ * `command`/`exec` do not get the same trailing-assignment/option tolerance — no known daily
261
+ * invocation shape needs it, and adding it would only widen the pattern without a use case.
262
+ */
263
+ const VERIFY_EXEC_PREFIX =
264
+ "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:env(?:\\s+(?:[A-Za-z_][A-Za-z0-9_]*=\\S*|-i|--ignore-environment|-u\\s+\\S+|--unset=\\S+|-C\\s+\\S+|--chdir=\\S+|-S\\s+\\S+|--split-string=\\S+|--|-))*\\s+|(?:command|exec)\\s+|nice(?:\\s+-n\\s+\\S+)?\\s+|timeout(?:\\s+(?:-s\\s+\\S+|-k\\s+\\S+|--signal=\\S+|--kill-after=\\S+|--preserve-status|--foreground))*\\s+\\S+\\s+)*(?:\\S*/)?";
265
+
238
266
  /**
239
267
  * Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
240
268
  * Tolerates a leading env-assignment/wrapper/path prefix so `GH_TOKEN=x gh pr create`,
@@ -802,29 +830,197 @@ export function commandContainsCopilotSummonComment(command) {
802
830
  }
803
831
 
804
832
  /**
805
- * COPILOT-FOLLOWUP-WAIT-TOOLS: a banned detached/polling wait — `nohup`, `disown`, `tmux new-session`,
806
- * `screen -dm`, or a `while`/`until`/`seq` loop whose body contains both a `sleep` and a gh or
807
- * loop-state call. Behavioral rule (required-rules classification `agent`): scoped in decideBashGate to the
808
- * dev-loop driving agent (subagent-only) so the main agent/operator retains manual wait tooling.
833
+ * Blank the CONTENTS of quoted string literals ('...' and "...") to a space, EXCEPT a quote that is
834
+ * the payload of an executable-code flag — a short-flag cluster CONTAINING `c` ANYWHERE in the
835
+ * cluster (`sh -c '…'`, `bash -c '…'`, `bash -lc '…'`, `bash -ec '…'`, `bash -cl '…'`, `bash -ci '…'`,
836
+ * `bash -cx '…'`) — bash treats ANY `-c`-containing short-flag cluster as command-execution
837
+ * regardless of where `c` sits in the cluster, not only one that ENDS in `c` — optionally followed by
838
+ * a `--` terminator (`bash -c -- '…'`), a long `--command` flag, or `eval` — that payload is REAL
839
+ * shell syntax to be executed, not inert data, so blanking it would hide an actual poll-loop
840
+ * construct wrapped in one of these forms. A real poll loop's structural tokens
841
+ * (`while`/`until`/`for`/`do`/`sleep`/`done`, a `[ -f … ]` file test) are UNQUOTED shell syntax; a
842
+ * quoted issue body, `--body` payload, or quoted example that merely mentions them carries them
843
+ * INSIDE quotes as inert data. Blanking those quoted contents is what lets the poll-loop matchers key
844
+ * on an actual loop CONSTRUCT rather than the token sequence appearing anywhere in a command
845
+ * (`gh issue create --body "while … sleep … done"` must not be flagged). The `s` (dotAll) flag lets
846
+ * `.` match a newline too, so a MULTI-LINE quoted `--body` (a real issue body commonly spans lines) is
847
+ * stripped in full, not just its first line.
848
+ * A quoted string that itself contains a command substitution (`$(...)`) or a backtick (`` `...` ``)
849
+ * is executable code, not inert data — its inner command runs regardless of the surrounding quotes.
850
+ * Blanking it would hide a real poll loop such as `while [ "$(gh pr view 5)" != MERGED ]; do sleep 5;
851
+ * done` that the gh/loop-state ban already denies, so such a quoted literal is PRESERVED (fail-closed
852
+ * direction) ahead of the `-c`/`--command`/`eval` exemption check below.
853
+ *
854
+ * The `-c`-cluster/`--command`/`eval` exemption is intentionally coarse in the fail-closed direction:
855
+ * it looks only for a `c` anywhere in a preceding short-flag cluster (or `--command`/`eval`), not for
856
+ * a shell-interpreter anchor. A non-shell command carrying `-c` (e.g. `grep -ci '<loop text>'`,
857
+ * `wc -c`) may therefore have its quoted argument preserved too and get over-denied — an accepted
858
+ * benign false positive, because tightening the exemption to a shell-interpreter anchor would risk a
859
+ * fail-open (missing a real `sh -c` poll loop), the worse direction.
860
+ *
861
+ * ponytail: blanks balanced quote pairs only, with the command-substitution/backtick preserve rule
862
+ * and the `-c`-cluster/`--command`/`eval` exemption above — no full shell tokenizer (mismatched/
863
+ * partial quotes and other exec-wrapper flags stay out of scope). Accepted ceiling: a deliberately
864
+ * quoted structural keyword (e.g. `"sleep"`) placed inside a real UNQUOTED loop is blanked like any
865
+ * other quoted literal and can therefore evade the ban — accepted as a deliberate-evasion class, not
866
+ * a natural shape a genuine poll loop takes.
867
+ * @param {string} command @returns {string}
868
+ */
869
+ function stripQuotedLiterals(command) {
870
+ return command.replace(/(['"])((?:(?!\1).)*)\1/gs, (match, _quote, inner, offset, full) => {
871
+ // A quoted string containing a command substitution ($()) or backtick is executable code, not
872
+ // inert data — its command runs regardless of the surrounding quotes. Blanking it would hide a
873
+ // real poll loop such as `while [ "$(gh pr view 5)" != MERGED ]; do sleep 5; done` that the
874
+ // gh/loop-state ban already denied. Preserve it (fail-closed direction).
875
+ if (/\$\(|`/.test(inner)) {
876
+ return match;
877
+ }
878
+ const before = full.slice(0, offset);
879
+ if (/(?:^|\s)(?:-[A-Za-z]*c[A-Za-z]*(?:\s+--)?|--command|eval)\s*$/.test(before)) {
880
+ return match; // executable -c/eval payload — leave the real shell syntax intact
881
+ }
882
+ return " ";
883
+ });
884
+ }
885
+
886
+ /**
887
+ * Whether COMMAND is (or contains) a sleep-poll loop over `gh`/`loop-state` — a `while`/`until`/
888
+ * `for` loop whose body contains both a `sleep` and a `gh` or `loop-state` call. Checked on the
889
+ * WHOLE command (not per-segment): the loop body is `;`-delimited, so a per-segment split would
890
+ * separate the loop head from its `sleep`/`gh` body calls and miss the pattern. `gh` must be a
891
+ * standalone token (not `grep gh-notes`), and `loop-state` must sit at a command-head position
892
+ * (not a substring inside `grep loop-state x`). Quoted literals are blanked first (see
893
+ * `stripQuotedLiterals`) so a quoted body/example that merely contains the tokens is not flagged.
894
+ * @param {string} command @returns {boolean}
895
+ */
896
+ export function commandIsSleepPollLoop(command) {
897
+ const whole = stripQuotedLiterals(command.trim());
898
+ return (
899
+ /(?:while|until|for)\b/i.test(whole) &&
900
+ /\bsleep\b/.test(whole) &&
901
+ /\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)
902
+ );
903
+ }
904
+
905
+ /**
906
+ * Whether COMMAND is (or contains) a bare FILE-MARKER poll loop: a `while`/`until`/`for` loop that
907
+ * repeatedly tests for a file's existence/type/permission (`[ -f … ]`, `[[ -e … ]]`, `test -f …`,
908
+ * `[ -r … ]`, `[ -L … ]`) and sleeps, with NO gated wait-tool. This is the orphan pattern under
909
+ * Claude Code — a `<tasks>/<id>.done` sentinel Claude Code never writes (completion arrives via
910
+ * async notification), so the loop never exits and, once backgrounded, orphans with no async wake to
911
+ * reap it. Distinct from `commandIsSleepPollLoop`, which keys on a `gh`/`loop-state` CALL: this keys
912
+ * on any bash FILE-test operator (existence, type, or permission — `efsdrwxugkOGLNShb`; string/numeric
913
+ * tests like `-z`/`-n`/`-t` are deliberately excluded, as those never test a marker FILE), catching a
914
+ * marker poll that calls no gh/loop-state at all. Verb-independent (while/until/for). Quoted literals
915
+ * are blanked first (see `stripQuotedLiterals`) so a quoted example or `--body` payload that merely
916
+ * contains the tokens is NOT flagged.
917
+ * @param {string} command @returns {boolean}
918
+ */
919
+ export function commandIsFileMarkerPollLoop(command) {
920
+ const whole = stripQuotedLiterals(command.trim());
921
+ return (
922
+ /(?:while|until|for)\b/i.test(whole) &&
923
+ /\bsleep\b/.test(whole) &&
924
+ /(?:\[\[?|\btest\b)\s+(?:!\s+)?-[a-hkprsuwxGLNOS]\b/.test(whole)
925
+ );
926
+ }
927
+
928
+ /**
929
+ * Whether COMMAND contains a bare `&` backgrounding control operator — not `&&` (logical AND) and
930
+ * not a redirection (`2>&1`, `>&2`, `&>file`, `&>>file`). A coarse whole-string check (no shell
931
+ * parse): redirection forms are stripped first, then any surviving lone `&` (not immediately
932
+ * preceded or followed by another `&`) counts.
933
+ * @param {string} command @returns {boolean}
934
+ */
935
+ function commandHasBareBackgroundOperator(command) {
936
+ const withoutRedir = command
937
+ .replace(/\d*>&\d*-?/g, " ") // 2>&1, 1>&2, >&2, >&-
938
+ .replace(/&>>?/g, " "); // &>file, &>>file
939
+ return /(?<!&)&(?!&)/.test(withoutRedir);
940
+ }
941
+
942
+ /**
943
+ * The wait/probe helper FAMILY: the Copilot/CI wait tools that MUST run as a bounded FOREGROUND
944
+ * probe — the `.mjs` helpers (`probe-copilot-review`, `wait-pr-checks`, `detect-copilot-loop-state`,
945
+ * `run-watch-cycle`, `probe-ci-status` — the sanctioned `ci-status`/`watch-ci` CI-status wait,
946
+ * skills/dev-loop/SKILL.md's "PR checks/status" entry), `gh run watch`, and the
947
+ * `dev-loops`/`dev-loops-run` `watch-cycle`/`watch-ci`/`watch-initial`/`gate probe-copilot` CLI
948
+ * verbs. A coarse ANYWHERE-in-the-string substring/family match (deliberately NOT exec-position
949
+ * anchored) — see `commandContainsDetachedWaitTool`'s JSDoc for the fail-closed rationale.
950
+ */
951
+ const WAIT_PROBE_FAMILY_RE = new RegExp(
952
+ [
953
+ "probe-copilot-review\\.mjs",
954
+ "wait-pr-checks\\.mjs",
955
+ "detect-copilot-loop-state\\.mjs",
956
+ "run-watch-cycle\\.mjs",
957
+ "probe-ci-status\\.mjs",
958
+ "gh\\s+run\\s+watch",
959
+ "watch-cycle",
960
+ "watch-ci",
961
+ "watch-initial",
962
+ "probe-copilot",
963
+ ].join("|"),
964
+ "i",
965
+ );
966
+
967
+ /**
968
+ * COPILOT-FOLLOWUP-WAIT-TOOLS: a banned detached/polling wait. This is a UNION of three
969
+ * independent deny conditions — NOT one big AND (an AND-condition here would inadvertently
970
+ * narrow the unconditional detach-wrapper ban below to "detach AND family reference", wrongly
971
+ * allowing a family-less `nohup node build.mjs &`):
972
+ *
973
+ * (1) `nohup`/`disown`/`tmux new-session`/`screen -dm` anywhere in a command segment — denied
974
+ * UNCONDITIONALLY, with NO wait/probe-family requirement.
975
+ * (2) A `while`/`until`/`for` sleep-poll loop over `gh`/`loop-state` (`commandIsSleepPollLoop`) —
976
+ * denied UNCONDITIONALLY — it IS the backgrounding signal.
977
+ * (3) OPTION-C, prevention-only scope: a bare `&` background (`commandHasBareBackgroundOperator`,
978
+ * including a `timeout …`/`env …`/`sh -c` wrapper of it) that ALSO references the wait/probe
979
+ * FAMILY anywhere in the command string (`WAIT_PROBE_FAMILY_RE`) — a coarse substring/family
980
+ * match, deliberately NOT exec-position anchored.
981
+ *
982
+ * Because (3)'s family match is coarse (anywhere in the string, not the executed token), NO wrapper
983
+ * can hide the reference from it: `timeout N … &`, `env … &`, `sh -c '… &'`, or a node loader flag
984
+ * (`node -r ./loader.mjs …/probe-copilot-review.mjs &`, `--require`/`--loader`/`--import`) all still
985
+ * carry the family token in the backgrounded command text, so all are denied. This trades precision
986
+ * for guaranteed coverage: a background command that merely MENTIONS a family name as an unrelated
987
+ * argument (`echo "see probe-copilot-review.mjs" &`) is also denied — a benign false positive,
988
+ * sanctioned by the issue's non-goals (this is a prevention gate, not an exec-position parser; a
989
+ * denied benign command simply falls back to the sanctioned foreground path). The precise
990
+ * exec-position parser this replaced (and the SubagentStop background-shell reaper it fed) is
991
+ * deferred to a follow-up safety-net effort.
992
+ *
993
+ * Actor-independent at the decideBashGate call site: the coordinator/main agent — not only a
994
+ * subagent — is the actor that leaves these orphaned under Claude Code (no async wake to join a
995
+ * backgrounded wait), so the gate catches its backgrounding too.
809
996
  * @param {string} command @returns {boolean}
810
997
  */
811
998
  export function commandContainsDetachedWaitTool(command) {
812
999
  const whole = command.trim();
813
- // Checked on the WHOLE command (not per-segment): the `while`/`until`/`for` loop body is
814
- // `;`-delimited, so a per-segment split would separate the loop head from its `sleep`/`gh`
815
- // body calls and miss the pattern. `gh` must be a standalone token (not `grep gh-notes`), and
816
- // `loop-state` must sit at a command-head position (not a substring inside `grep loop-state x`).
817
- if (/(?:while|until|for)\b/i.test(whole) && /\bsleep\b/.test(whole) && /\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)) {
818
- return true;
819
- }
820
- return shellSegments(command).some((segment) => {
821
- // `nohup`/`disown` only detach when they head a command (segment start, or right after a shell
822
- // operator) — a bare mention (`cat nohup.out`, `echo "nohup banned"`) is not a detach.
1000
+
1001
+ // (1) Unconditional detach-wrapper ban — no wait/probe-family requirement.
1002
+ const hasDetachWrapper = shellSegments(command).some((segment) => {
1003
+ // `nohup`/`disown` only detach when they head a command (segment start, or right after a
1004
+ // shell operator) — a bare mention (`cat nohup.out`, `echo "nohup banned"`) is not a detach.
823
1005
  if (/(?:^|[;&|])\s*(?:nohup|disown)\b/.test(segment)) return true;
824
1006
  if (/^tmux\s+new-session\b/i.test(segment)) return true;
825
1007
  if (/^screen\s+-dm/i.test(segment)) return true;
826
1008
  return false;
827
1009
  });
1010
+ if (hasDetachWrapper) {
1011
+ return true;
1012
+ }
1013
+
1014
+ // (2) Unconditional sleep-poll-loop ban — a gh/loop-state poll OR a bare file-marker poll.
1015
+ if (commandIsSleepPollLoop(whole) || commandIsFileMarkerPollLoop(whole)) {
1016
+ return true;
1017
+ }
1018
+
1019
+ // (3) OPTION-C: bare-`&` background AND a wait/probe family reference.
1020
+ if (!commandHasBareBackgroundOperator(whole)) {
1021
+ return false;
1022
+ }
1023
+ return WAIT_PROBE_FAMILY_RE.test(whole);
828
1024
  }
829
1025
 
830
1026
  /** Build a `node`/`python`/`python3` command-head matcher (env/wrapper/path prefix tolerated). */
@@ -875,3 +1071,44 @@ export function commandContainsInlineInterpreter(command) {
875
1071
  return false;
876
1072
  });
877
1073
  }
1074
+
1075
+ /**
1076
+ * A package-manager `test`/`verify`/`build` script/task invocation, the `run` keyword optional
1077
+ * (`npm test`, `npm run test`, `bun run verify`, `yarn build`, `pnpm run build`, ...). Anchored on
1078
+ * the executable HEAD (env-assignment/wrapper/path/nice/timeout prefix tolerated via
1079
+ * `VERIFY_EXEC_PREFIX`) so a path that merely contains the word "test" (`cat test/foo.test.mjs`)
1080
+ * never matches — the head token must literally be one of these four package-manager binaries.
1081
+ *
1082
+ * Tolerates a run of binary flags between the binary and `run`/the script name (`bun --bun run
1083
+ * verify`), and a `:`-namespaced sub-script (`test:extension`, `test:core`, `verify:docs`, ...) —
1084
+ * the tail is `(?:[:\s]|$)` rather than `(?:\s|$)` so `npm run test:unit` / `yarn test:ci` match
1085
+ * while `npm run build-docs` (hyphen form, a genuinely different script name) still does not.
1086
+ */
1087
+ const PACKAGE_MANAGER_VERIFY_RUN_RE = new RegExp(
1088
+ `^${VERIFY_EXEC_PREFIX}(?:bun|npm|yarn|pnpm)(?:\\s+--\\S+)*\\s+(?:run\\s+)?(?:test|verify|build)(?:[:\\s]|$)`,
1089
+ "i",
1090
+ );
1091
+
1092
+ /**
1093
+ * `vitest` run directly (any args: `vitest`, `vitest run`, `vitest --coverage`), or via the
1094
+ * `npx`/`bunx` package-runner (`npx vitest run`, `bunx vitest`) or `bun`'s `x` subcommand
1095
+ * (`bun x vitest`).
1096
+ */
1097
+ const VITEST_RE = new RegExp(`^${VERIFY_EXEC_PREFIX}(?:(?:npx|bunx)\\s+|bun\\s+x\\s+)?vitest(?:\\s|$)`, "i");
1098
+
1099
+ /**
1100
+ * COORDINATOR-VERIFY-DELEGATION: whether `command` contains a known code-verification/
1101
+ * build entrypoint in ANY shell segment — `bun test`/`bun run verify`/`bun run build`, `vitest`,
1102
+ * `npm test`/`npm run test`/`npm run build`, and the `yarn`/`pnpm` `test`/`build` equivalents
1103
+ * (with or without the `run` keyword). PreToolUse gate use only: the dev-loop COORDINATOR must
1104
+ * delegate these to a fresh WORKER subagent instead of running them inline; a worker subagent may
1105
+ * run them freely (the actor scoping lives in `decideBashGate`, not here).
1106
+ *
1107
+ * Compact orchestration commands the coordinator MAY still run inline never match — their head
1108
+ * token is not a package-manager binary or `vitest` (`dev-loops queue list`, `gh pr checks --json
1109
+ * --jq`, `detect-checkpoint-evidence`, `git log --oneline -1`, `git status --short`).
1110
+ * @param {string} command @returns {boolean}
1111
+ */
1112
+ export function commandContainsCodeVerificationEntrypoint(command) {
1113
+ return shellSegments(command).some((segment) => PACKAGE_MANAGER_VERIFY_RUN_RE.test(segment) || VITEST_RE.test(segment));
1114
+ }
@@ -20,16 +20,33 @@ const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
20
20
  export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
21
21
 
22
22
  /**
23
- * The same workflow ALSO surfaces as a check run under its job id
24
- * (`gate-evidence-runner`) beside the commit status named above, and both are
25
- * the loop's own derived signal. Excluding only the status context left the
26
- * runner's conclusion gating the loop's own pre_approval step: once the
27
- * workflow gained job-level concurrency, a superseded run is cancelled as
23
+ * The same workflow ALSO surfaces as check runs under its two job ids
24
+ * (`gate-evidence-runner`, the compute-heavy detector, and
25
+ * `gate-evidence-reporter`, the always-settling job that owns the status
26
+ * above — see docs/decisions/0076) beside the commit status named above, and
27
+ * all are the loop's own derived signal. Excluding only the status context
28
+ * left either job's conclusion gating the loop's own pre_approval step: once
29
+ * the workflow gained job-level concurrency, a superseded run is cancelled as
28
30
  * normal operation, and a cancelled run is deliberately NOT treated as green
29
31
  * (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
30
32
  * the whole head read "none" and the loop waited on CI forever.
31
33
  */
32
- export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([LOOP_DERIVED_CI_CHECK_NAME, "gate-evidence-runner"]);
34
+ export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([
35
+ LOOP_DERIVED_CI_CHECK_NAME,
36
+ "gate-evidence-runner",
37
+ "gate-evidence-reporter",
38
+ ]);
39
+
40
+ /**
41
+ * The two Gate-evidence JOB check-run names — the detector and the reporter.
42
+ * These (and ONLY these, never the `gate-evidence` status name) are the runs
43
+ * whose superseded CANCELLED check-runs `classifyBenignGateEvidenceUnstable`
44
+ * treats as cosmetic UNSTABLE noise.
45
+ */
46
+ export const GATE_EVIDENCE_JOB_CHECK_NAMES = Object.freeze([
47
+ "gate-evidence-runner",
48
+ "gate-evidence-reporter",
49
+ ]);
33
50
 
34
51
  function checkEntryName(entry) {
35
52
  if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
@@ -141,6 +158,27 @@ export function normalizeStatusCheckRollupStatus(rollup) {
141
158
  return "none";
142
159
  }
143
160
 
161
+ /**
162
+ * Resolve the normalized status of ONE named context/check within a
163
+ * `statusCheckRollup` (or check-runs-shaped) payload — e.g. whether the
164
+ * required `gate-evidence` context itself (as opposed to the loop's own
165
+ * exclusion of it, see `deriveLoopCiStatusFromRollup`) is success, failure,
166
+ * pending, or absent. Reuses the same name matching
167
+ * (`partitionEntriesByCheckName`) and state normalization
168
+ * (`normalizeStatusCheckRollupStatus`) the rollup helpers on this module
169
+ * already use, so a caller that needs one context's own state (e.g.
170
+ * merge-pr.mjs naming the real cause of a block on `gate-evidence`) does not
171
+ * re-derive name matching or status normalization.
172
+ *
173
+ * @param {Array<object>} rollup
174
+ * @param {string} contextName
175
+ * @returns {"success"|"failure"|"pending"|"none"}
176
+ */
177
+ export function resolveNamedContextState(rollup, contextName) {
178
+ const { matched } = partitionEntriesByCheckName(rollup, contextName);
179
+ return normalizeStatusCheckRollupStatus(matched);
180
+ }
181
+
144
182
  /**
145
183
  * Summarize the GitHub check-runs API payload for one head SHA.
146
184
  *
@@ -317,6 +355,78 @@ export function normalizeHeadScopedCiContract({
317
355
  return buildCiContract(overallStatus);
318
356
  }
319
357
 
358
+ /**
359
+ * Classify a `mergeStateStatus === "UNSTABLE"` as BENIGN when the required
360
+ * `gate-evidence` commit status is itself `success` and the ONLY non-success
361
+ * rollup entries are superseded Gate-evidence job check-runs (the
362
+ * `gate-evidence-runner` detector OR the `gate-evidence-reporter`, conclusion
363
+ * `CANCELLED`).
364
+ *
365
+ * Both jobs cancel superseded runs: the detector via `cancel-in-progress`, the
366
+ * reporter via its non-cancelling group cancelling a still-queued run superseded
367
+ * by a newer one. Each leaves a `cancelled` check-run on the head, so
368
+ * `mergeStateStatus` reads `UNSTABLE` on nearly every PR even when the required
369
+ * `gate-evidence` status on the head is green. The cancellation is correct and
370
+ * stays (docs/decisions/0076); this classifier only lets a reader distinguish
371
+ * that cosmetic noise from a real non-success.
372
+ *
373
+ * Fail-closed: only an actual `UNSTABLE` with a `success` `gate-evidence` status
374
+ * and no other non-success entry is benign. A failed (not cancelled) Gate-evidence
375
+ * job, a non-success `gate-evidence` status, or any other failing/pending check
376
+ * makes it non-benign.
377
+ *
378
+ * ponytail: `gh pr view --json statusCheckRollup` returns a single bounded page
379
+ * (~100 contexts); a very chatty PR could exceed it and hide a real failure,
380
+ * failing this open. Acceptable because this is a display-only surface — the
381
+ * merge path never consults it. Do NOT wire this classifier into a merge
382
+ * decision without adding pagination.
383
+ *
384
+ * @param {Array<object>} rollup A `gh pr view --json statusCheckRollup` payload.
385
+ * @param {string|null} mergeStateStatus
386
+ * @returns {{ benign: boolean, reason: string }}
387
+ */
388
+ export function classifyBenignGateEvidenceUnstable(rollup, mergeStateStatus) {
389
+ const state = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : "";
390
+ if (state !== "UNSTABLE") {
391
+ return { benign: false, reason: "mergeStateStatus is not UNSTABLE" };
392
+ }
393
+ if (!Array.isArray(rollup)) {
394
+ return { benign: false, reason: "status rollup unavailable" };
395
+ }
396
+ // The required gate-evidence signal is a commit STATUS (a StatusContext,
397
+ // `.context`), never a check-run (`.name`). Anchor the success guard on the
398
+ // StatusContext alone so a same-named success check-run can never stand in for
399
+ // an absent required status (partitionEntriesByCheckName matches `.name` OR
400
+ // `.context`, so it would otherwise accept either).
401
+ const gateEvidenceStatusEntries = rollup.filter(
402
+ (entry) => entry?.context === LOOP_DERIVED_CI_CHECK_NAME && typeof entry?.state === "string",
403
+ );
404
+ if (normalizeStatusCheckRollupStatus(gateEvidenceStatusEntries) !== "success") {
405
+ return { benign: false, reason: "gate-evidence status is not success" };
406
+ }
407
+ const offenders = [];
408
+ for (const entry of rollup) {
409
+ if (normalizeStatusCheckRollupStatus([entry]) === "success") continue;
410
+ const name = checkEntryName(entry);
411
+ const conclusion = typeof entry?.conclusion === "string" ? entry.conclusion.toUpperCase() : "";
412
+ // Only a cancelled Gate-evidence JOB check-run (runner or reporter) is the
413
+ // superseded-run artifact this classifier ignores — an explicit two-job
414
+ // allowlist, not LOOP_DERIVED_CI_CHECK_NAMES (which also carries the
415
+ // `gate-evidence` status name), so a cancelled entry named `gate-evidence`
416
+ // can never be waved through as benign.
417
+ if (GATE_EVIDENCE_JOB_CHECK_NAMES.includes(name) && conclusion === "CANCELLED") continue;
418
+ const status = typeof entry?.status === "string" ? entry.status.toUpperCase() : "";
419
+ offenders.push(`${name || "unknown"}=${conclusion || status || "?"}`);
420
+ }
421
+ if (offenders.length > 0) {
422
+ return { benign: false, reason: `non-benign non-success checks present: ${offenders.join(", ")}` };
423
+ }
424
+ return {
425
+ benign: true,
426
+ reason: "no non-success entry other than superseded Gate-evidence job cancellations; gate-evidence status is success",
427
+ };
428
+ }
429
+
320
430
  /**
321
431
  * Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
322
432
  * `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the