@dev-loops/core 1.0.2 → 1.0.4-pre.0

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.
@@ -8,9 +8,65 @@
8
8
  * Pure and side-effect free.
9
9
  */
10
10
 
11
- /** The repository these gate guards apply to. */
11
+ /**
12
+ * The dev-loops repo itself. Retained ONLY for the Pi extension's
13
+ * (`extension/post-merge-update.ts`) dev-loops-repo SELF-UPDATE scoping (`markPendingUpdate` /
14
+ * `queueIfEligible`), which is legitimately anchored to this one repo, not to whatever repo the
15
+ * harness happens to run in. Both harness guard suites — the Claude PreToolUse Bash gate
16
+ * (`decideBashGate` in `hook-decisions.mjs`) and the Pi extension's `gh pr ready`/`gh pr merge`
17
+ * guards (`post-merge-update.ts`) — now resolve the managed repo dynamically via
18
+ * `deriveInManagedRepo` below instead of comparing against this hardcoded slug, so every guard
19
+ * also applies in a dev-loops-managed consumer repo.
20
+ */
12
21
  export const TARGET_REPO_SLUG = "mfittko/dev-loops";
13
22
 
23
+ /** `.devloops` config file extensions checked (in order) to decide whether a repo root is
24
+ * dev-loops-managed. Shared by every harness that resolves `inManagedContext` from the
25
+ * filesystem (`fs.existsSync(path.join(repoRoot, \`.devloops${ext}\`))`). */
26
+ export const DEVLOOPS_CONFIG_VARIANTS = ["", ".yaml", ".yml", ".json"];
27
+
28
+ /**
29
+ * Whether the current repo counts as "managed" for guard-gating purposes: inside a
30
+ * dev-loops-managed context (a `.devloops` config exists at its root) AND, when the managed
31
+ * repo's identity resolves, the cwd repo IS that managed repo. FAIL CLOSED: inside a managed
32
+ * context whose identity can't be resolved (`managedRepoSlug` null), the guard suite still
33
+ * applies rather than silently allowing everything.
34
+ * @param {Object} [params]
35
+ * @param {boolean} [params.inManagedContext] - Whether a `.devloops` config exists at the repo root.
36
+ * @param {string|null} [params.managedRepoSlug] - Resolved owner/name of the managed repo, or null.
37
+ * @param {string|null} [params.repoSlug] - Resolved owner/name of the cwd repo, or null.
38
+ * @returns {boolean}
39
+ */
40
+ export function deriveInManagedRepo({ inManagedContext = false, managedRepoSlug = null, repoSlug = null } = {}) {
41
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
42
+ const cwdSlug = (repoSlug ?? "").trim().toLowerCase() || null;
43
+ return Boolean(inManagedContext) && (managedSlug === null || cwdSlug === managedSlug);
44
+ }
45
+
46
+ /**
47
+ * Whether an explicit `--repo`/`-R` (or `GH_REPO=`) target is PROVABLY a different repo than the
48
+ * managed one — both slugs must resolve and differ. An unresolvable managed slug never proves
49
+ * foreign-ness (fail closed: the guard stays active rather than waving an explicit flag through).
50
+ * @param {string|null} explicitRepo
51
+ * @param {string|null} managedRepoSlug
52
+ * @returns {boolean}
53
+ */
54
+ export function explicitRepoProvenForeign(explicitRepo, managedRepoSlug) {
55
+ const managedSlug = (managedRepoSlug ?? "").trim().toLowerCase() || null;
56
+ const explicit = (explicitRepo ?? "").trim().toLowerCase() || null;
57
+ if (managedSlug === null || explicit === null) {
58
+ return false;
59
+ }
60
+ // Both sides must be clean owner/name identities before we can prove foreign: a
61
+ // managed slug that bypassed the normalizer (e.g. a hostile `acme/widgets;id` test
62
+ // double) can't be trusted to prove anything about the explicit `--repo` — fail
63
+ // closed (the managed-repo guard still applies) rather than wave it through.
64
+ if (!isCleanRepoSlug(managedSlug) || !isCleanRepoSlug(explicit)) {
65
+ return false;
66
+ }
67
+ return explicit !== managedSlug;
68
+ }
69
+
14
70
  /** Flags known to take a value argument for `gh pr ready` (not boolean flags). */
15
71
  export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
16
72
 
@@ -21,7 +77,7 @@ export const FLAGS_THAT_TAKE_VALUE = new Set(["-r", "--repo"]);
21
77
  * strings — so a segment must break on them too, else `echo hi\ngh pr create` evades
22
78
  * the gate. Used by all segment-splitting sites (DRY).
23
79
  */
24
- const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||\n|\r)\s*/;
80
+ const SHELL_SEGMENT_SEPARATOR = /\s*(?:&&|\|\||;|\||&|\n|\r)\s*/;
25
81
 
26
82
  /**
27
83
  * Strip a single balanced surrounding quote pair (`'…'` or `"…"`) from a shell arg value.
@@ -67,8 +123,32 @@ export function trimToNull(value) {
67
123
  return trimmed ? trimmed : null;
68
124
  }
69
125
 
126
+ /**
127
+ * Strict GitHub owner/name identity shape: each of the two path segments is
128
+ * `[A-Za-z0-9._-]+` — the character set GitHub itself allows in an owner or repo name. A slug
129
+ * outside this shape (a shell metacharacter, whitespace, path traversal, or an extra `/` segment)
130
+ * can never be a real GitHub identity.
131
+ */
132
+ const CLEAN_REPO_SLUG_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
133
+
134
+ /**
135
+ * Whether `slug` is a clean `owner/name` GitHub identity (see `CLEAN_REPO_SLUG_RE`). Exported so a
136
+ * sink that interpolates a repo slug into a shell command string (e.g. the Pi extension's
137
+ * `gateCommand` in `extension/post-merge-update.ts`) can defense-in-depth guard the interpolation,
138
+ * on top of `normalizeGitHubRepoSlug` already enforcing this shape at the source.
139
+ * @param {string|null|undefined} slug @returns {boolean}
140
+ */
141
+ export function isCleanRepoSlug(slug) {
142
+ return typeof slug === "string" && CLEAN_REPO_SLUG_RE.test(slug);
143
+ }
144
+
70
145
  /**
71
146
  * Normalize a git remote URL into an `owner/name` slug (lowercased), or null.
147
+ * A hostile remote (e.g. `git@github.com:acme/widgets;id`) never yields a slug carrying the
148
+ * injected metacharacters — the extracted candidate must match `CLEAN_REPO_SLUG_RE` or this
149
+ * returns null instead, so every caller (both harnesses' managed-repo scope checks, and any sink
150
+ * that interpolates the slug into a shell command) sees either a real GitHub identity or null,
151
+ * never shell-metacharacter-bearing text.
72
152
  * @param {string} remoteUrl
73
153
  * @returns {string|null}
74
154
  */
@@ -91,7 +171,8 @@ export function normalizeGitHubRepoSlug(remoteUrl) {
91
171
  if (!match) {
92
172
  continue;
93
173
  }
94
- return trimToNull(match[1])?.toLowerCase() ?? null;
174
+ const slug = trimToNull(match[1])?.toLowerCase() ?? null;
175
+ return isCleanRepoSlug(slug) ? slug : null;
95
176
  }
96
177
 
97
178
  return null;
@@ -154,6 +235,34 @@ function shellSegments(command) {
154
235
  */
155
236
  const SHELL_EXEC_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
156
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
+
157
266
  /**
158
267
  * Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
159
268
  * Tolerates a leading env-assignment/wrapper/path prefix so `GH_TOKEN=x gh pr create`,
@@ -486,6 +595,25 @@ export function extractRepoFlagsFromGhPrCreateSegments(command) {
486
595
  return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "create");
487
596
  }
488
597
 
598
+ /**
599
+ * Return `{ segment, explicitRepo }` for every `gh pr merge` segment (ignoring --help/-h) —
600
+ * PreToolUse gate scope check use only, so a proven-foreign leading segment can't shield a later
601
+ * managed one. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
602
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
603
+ */
604
+ export function extractRepoFlagsFromGhPrMergeSegments(command) {
605
+ return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "merge");
606
+ }
607
+
608
+ /**
609
+ * Return `{ segment, explicitRepo }` for every `gh pr ready` segment (ignoring --help/-h) —
610
+ * PreToolUse gate scope check use only. Mirrors `extractRepoFlagsFromGhPrCreateSegments`.
611
+ * @param {string} command @returns {{ segment: string, explicitRepo: string|null }[]}
612
+ */
613
+ export function extractRepoFlagsFromGhPrReadySegments(command) {
614
+ return extractRepoFlagsFromGhSubcmdVerbSegments(command, "pr", "ready");
615
+ }
616
+
489
617
  /** @param {string} command @returns {number|null} */
490
618
  export function extractPrNumberFromGhPrMerge(command) {
491
619
  return extractPrNumberFromGhPrVerb(command, "merge");
@@ -574,13 +702,22 @@ export function extractGhApiEndpointSegments(command) {
574
702
  return out;
575
703
  }
576
704
 
577
- /** The `gh api` segments whose endpoint is the target repo's URL path. Matches the absolute
578
- * slug-embedded form (`repos/mfittko/dev-loops/...`) and the bare relative form (`issues/...`),
579
- * which gh api resolves against the cwd repo — the decideBashGate call site gates the relative form
580
- * on `inTargetRepo`. */
581
- function targetGhApiPathRegex(suffix) {
582
- const slug = TARGET_REPO_SLUG.replace("/", "\\/");
583
- return new RegExp(`(?:repos/${slug}/|^)${suffix}`);
705
+ /** The `gh api` segments whose endpoint is the dev-loops-managed repo's URL path. Matches the
706
+ * absolute slug-embedded form (`repos/<managedSlug>/...`) when `managedSlug` resolves, plus the
707
+ * bare relative form (`issues/...`), which gh api resolves against the cwd repo — the
708
+ * decideBashGate call site gates the relative form on `inManagedRepo`. When `managedSlug` is null
709
+ * (the managed repo's identity could not be resolved in a managed context — AC4's fail-closed
710
+ * case), the absolute arm matches ANY owner/repo rather than a specific slug: identity is unknown,
711
+ * so an absolute write must be denied regardless of which repo it targets, not waved through for
712
+ * lack of a slug to compare against. The absolute arm fully regex-escapes a resolved slug (a `.`
713
+ * in a legitimate repo name must match literally, not as a wildcard) and matches
714
+ * case-insensitively (GitHub repo identity is case-insensitive). */
715
+ function managedGhApiPathRegex(suffix, managedSlug) {
716
+ if (!managedSlug) {
717
+ return new RegExp(`(?:repos/[^/]+/[^/]+/|^)${suffix}`, "i");
718
+ }
719
+ const slug = managedSlug.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
720
+ return new RegExp(`(?:repos/${slug}/|^)${suffix}`, "i");
584
721
  }
585
722
 
586
723
  /** Strip a `scheme://host` prefix from an absolute gh api URL endpoint (`https://api.github.com/...`),
@@ -617,15 +754,18 @@ function ghApiSegmentHasWriteMethod(segment) {
617
754
  }
618
755
 
619
756
  /**
620
- * SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the target
621
- * repo — the ad-hoc sub-issue mutation that must flow through the sanctioned `manage-sub-issues`
622
- * wrapper instead. Actor-independent (the issue's decided policy): the main agent gets no reserved
623
- * direct path to sub-issue writes. Anchored on the target repo's URL path segment AND an explicit write
624
- * method, so a `gh api` read or another repo's `sub_issues` write passes through (no false deny).
625
- * @param {string} command @returns {boolean}
757
+ * SUBISSUE-NO-ADHOC-BYPASS: raw `gh api` WRITE to `.../issues/<n>/sub_issues[/priority]` on the
758
+ * dev-loops-managed repo — the ad-hoc sub-issue mutation that must flow through the sanctioned
759
+ * `manage-sub-issues` wrapper instead. Actor-independent (the issue's decided policy): the main
760
+ * agent gets no reserved direct path to sub-issue writes. Anchored on the managed repo's URL path
761
+ * segment AND an explicit write method, so a `gh api` read or another repo's `sub_issues` write
762
+ * passes through (no false deny).
763
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
764
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
765
+ * @returns {boolean}
626
766
  */
627
- export function commandContainsSubIssueAdHocBypass(command) {
628
- const re = targetGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`);
767
+ export function commandContainsSubIssueAdHocBypass(command, managedSlug = null) {
768
+ const re = managedGhApiPathRegex(`issues/\\d+/sub_issues(?:/priority)?(?:\\s|$)`, managedSlug);
629
769
  return extractGhApiEndpointSegments(command).some(
630
770
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
631
771
  );
@@ -633,12 +773,15 @@ export function commandContainsSubIssueAdHocBypass(command) {
633
773
 
634
774
  /**
635
775
  * COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (REST half): raw `gh api` POST to
636
- * `.../pulls/<n>/comments/<m>/replies` on the target repo — the ad-hoc thread reply that must flow
637
- * through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved direct reply path.
638
- * @param {string} command @returns {boolean}
776
+ * `.../pulls/<n>/comments/<m>/replies` on the dev-loops-managed repo — the ad-hoc thread reply
777
+ * that must flow through `reply-resolve-review-thread(s).mjs`. Actor-independent: no reserved
778
+ * direct reply path.
779
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
780
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
781
+ * @returns {boolean}
639
782
  */
640
- export function commandContainsReplyResolveBypass(command) {
641
- const re = targetGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`);
783
+ export function commandContainsReplyResolveBypass(command, managedSlug = null) {
784
+ const re = managedGhApiPathRegex(`pulls/\\d+/comments/\\d+/replies(?:\\s|$)`, managedSlug);
642
785
  return extractGhApiEndpointSegments(command).some(
643
786
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
644
787
  );
@@ -657,12 +800,14 @@ export function commandContainsGraphqlResolveReviewThread(command) {
657
800
 
658
801
  /**
659
802
  * COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (REST half): raw `gh api` write to
660
- * `.../pulls/<n>/requested_reviewers` on the target repo — the ad-hoc Copilot review request that must
661
- * flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
662
- * @param {string} command @returns {boolean}
803
+ * `.../pulls/<n>/requested_reviewers` on the dev-loops-managed repo — the ad-hoc Copilot review
804
+ * request that must flow through `scripts/github/request-copilot-review.mjs`. Actor-independent.
805
+ * @param {string} command @param {string|null} [managedSlug] - Resolved managed-repo slug, or
806
+ * null when unresolvable (both the relative and any absolute repos/<owner>/<repo>/ form match then, fail closed).
807
+ * @returns {boolean}
663
808
  */
664
- export function commandContainsCopilotRequestBypass(command) {
665
- const re = targetGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`);
809
+ export function commandContainsCopilotRequestBypass(command, managedSlug = null) {
810
+ const re = managedGhApiPathRegex(`pulls/\\d+/requested_reviewers(?:\\s|$)`, managedSlug);
666
811
  return extractGhApiEndpointSegments(command).some(
667
812
  ({ segment, endpoint }) => Boolean(endpoint) && re.test(normalizeGhApiEndpoint(endpoint)) && ghApiSegmentHasWriteMethod(segment),
668
813
  );
@@ -685,29 +830,197 @@ export function commandContainsCopilotSummonComment(command) {
685
830
  }
686
831
 
687
832
  /**
688
- * COPILOT-FOLLOWUP-WAIT-TOOLS: a banned detached/polling wait — `nohup`, `disown`, `tmux new-session`,
689
- * `screen -dm`, or a `while`/`until`/`seq` loop whose body contains both a `sleep` and a gh or
690
- * loop-state call. Behavioral rule (required-rules classification `agent`): scoped in decideBashGate to the
691
- * 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.
692
996
  * @param {string} command @returns {boolean}
693
997
  */
694
998
  export function commandContainsDetachedWaitTool(command) {
695
999
  const whole = command.trim();
696
- // Checked on the WHOLE command (not per-segment): the `while`/`until`/`for` loop body is
697
- // `;`-delimited, so a per-segment split would separate the loop head from its `sleep`/`gh`
698
- // body calls and miss the pattern. `gh` must be a standalone token (not `grep gh-notes`), and
699
- // `loop-state` must sit at a command-head position (not a substring inside `grep loop-state x`).
700
- if (/(?:while|until|for)\b/i.test(whole) && /\bsleep\b/.test(whole) && /\bgh(?=\s|$)|(?:^|[;&|(])\s*loop-state(?=\s|$)/.test(whole)) {
701
- return true;
702
- }
703
- return shellSegments(command).some((segment) => {
704
- // `nohup`/`disown` only detach when they head a command (segment start, or right after a shell
705
- // 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.
706
1005
  if (/(?:^|[;&|])\s*(?:nohup|disown)\b/.test(segment)) return true;
707
1006
  if (/^tmux\s+new-session\b/i.test(segment)) return true;
708
1007
  if (/^screen\s+-dm/i.test(segment)) return true;
709
1008
  return false;
710
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);
711
1024
  }
712
1025
 
713
1026
  /** Build a `node`/`python`/`python3` command-head matcher (env/wrapper/path prefix tolerated). */
@@ -758,3 +1071,44 @@ export function commandContainsInlineInterpreter(command) {
758
1071
  return false;
759
1072
  });
760
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
+ }