@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.
@@ -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
+ }