@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.0-rc.5",
3
+ "version": "1.0.0-rc.6",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -21,11 +21,13 @@
21
21
  "./debt/shape": "./src/debt/shape.mjs",
22
22
  "./debt/signal": "./src/debt/debt-signal.mjs",
23
23
  "./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
24
+ "./github/comment-id-guard": "./src/github/comment-id-guard.mjs",
24
25
  "./github/issue-ops": "./src/github/issue-ops.mjs",
25
26
  "./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
26
27
  "./github/repo-slug": "./src/github/repo-slug.mjs",
27
28
  "./github/review-threads": "./src/github/review-threads.mjs",
28
29
  "./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
30
+ "./loop/agent-stall": "./src/loop/agent-stall.mjs",
29
31
  "./loop/bash-command-classify": "./src/loop/bash-command-classify.mjs",
30
32
  "./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
31
33
  "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
@@ -54,6 +56,9 @@
54
56
  "./loop/queue-membership": "./src/loop/queue-membership.mjs",
55
57
  "./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
56
58
  "./loop/queue-state": "./src/loop/queue-state.mjs",
59
+ "./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
60
+ "./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
61
+ "./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
57
62
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
58
63
  "./loop/run-context": "./src/loop/run-context.mjs",
59
64
  "./loop/run-inspection": "./src/loop/run-inspection.mjs",
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { resolveRunId } from "../loop/run-context.mjs";
13
+ import { isUnderWorktreePath } from "../loop/worktree-guard.mjs";
13
14
  import {
14
15
  commandContainsGhPrReady,
15
16
  commandContainsGhPrMerge,
@@ -22,13 +23,23 @@ import {
22
23
  commandContainsRawExternalWrite,
23
24
  extractRepoFlagsFromExternalWriteSegments,
24
25
  commandContainsGitStash,
26
+ extractGhApiEndpointSegments,
27
+ commandContainsSubIssueAdHocBypass,
28
+ commandContainsReplyResolveBypass,
29
+ commandContainsGraphqlResolveReviewThread,
30
+ commandContainsCopilotRequestBypass,
31
+ commandContainsCopilotSummonComment,
32
+ commandContainsDetachedWaitTool,
33
+ commandContainsInlineInterpreter,
25
34
  TARGET_REPO_SLUG,
26
35
  } from "../loop/bash-command-classify.mjs";
27
36
 
28
37
  /**
29
38
  * @typedef {Object} HookDecision
30
- * @property {"allow"|"deny"} decision
31
- * @property {string} [reason] - Human-readable reason (shown to Claude on deny).
39
+ * @property {"allow"|"deny"|"block"} decision — `block` is the SubagentStop vocabulary
40
+ * (exit 2 + stderr JSON), used by `decideSubagentStopGuard`; `allow`/`deny` are the PreToolUse
41
+ * vocabulary used by `decideBashGate`/`decideWriteGuard`.
42
+ * @property {string} [reason] - Human-readable reason (shown to the agent on deny/block).
32
43
  */
33
44
 
34
45
  const ALLOW = Object.freeze({ decision: "allow" });
@@ -78,18 +89,81 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
78
89
  * @param {boolean} [params.gatePassed] - Whether the relevant gate evidence exists for the PR.
79
90
  * @param {string|null} [params.gateError] - Error detail when the gate guard could not run.
80
91
  * @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload; non-null
81
- * string inside a subagent, null in the main agent. Scopes the external-write guard.
92
+ * string inside a subagent, null in the main agent. Scopes the subagent-only predicates.
93
+ * @param {boolean} [params.humanMergeOnly] - Effective repo `autonomy.humanMergeOnly` invariant
94
+ * (`resolveHumanMergeOnly`); when true, `gh pr merge` is refused actor-independently
95
+ * (STOP-HUMAN-MERGE-001), because the main agent is the actor that performs GitHub writes and a
96
+ * subagent-only deny would enforce nothing.
82
97
  * @returns {HookDecision}
83
98
  */
84
- export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null }) {
99
+ export function decideBashGate({ command, repoSlug = null, gatePassed = false, gateError = null, agentType = null, humanMergeOnly = false }) {
85
100
  if (typeof command !== "string") {
86
101
  return ALLOW;
87
102
  }
103
+ // Normalize (trim + case-fold) so a divergent slug (surrounding whitespace, casing) does not
104
+ // silently fail OPEN and disable every guard that depends on inTargetRepo (#1622).
105
+ const inTargetRepo = (repoSlug ?? "").trim().toLowerCase() === TARGET_REPO_SLUG.trim().toLowerCase();
106
+
107
+ // OPS-NO-INLINE-INTERPRETER (#1622): inline interpreters (`node -e`/`--eval`/`-p`, `python3 -c`,
108
+ // heredocs fed to node/python) are barred actor-independently on the target repo — the rule bars
109
+ // "Coordinator and agent flows"; sanctioned output parsing uses `--jq`/`--silent`, never an
110
+ // inline interpreter.
111
+ if (inTargetRepo && commandContainsInlineInterpreter(command)) {
112
+ return {
113
+ decision: "deny",
114
+ reason:
115
+ "OPS-NO-INLINE-INTERPRETER: inline interpreters (node -e/--eval/-p, python3 -c, heredoc to " +
116
+ "node/python) are barred in the dev-loop flow. Parse tool output via --jq/--silent and mutate " +
117
+ "files via the editor/patch tools or a --jq-composed --body-file, never an inline interpreter.",
118
+ };
119
+ }
120
+
121
+ // SUBISSUE-NO-ADHOC-BYPASS (#1622): ad-hoc `gh api` writes to the target repo's sub-issue endpoints.
122
+ // Actor-independent (no reserved direct path). Gated on the target repo: the absolute slug-embedded
123
+ // form identifies the target repo; the bare relative form (`gh api issues/5/sub_issues`) resolves
124
+ // against the cwd repo, so it is in scope only when running in the target repo (mirrors the #1047
125
+ // explicit-`--repo`/cwd-target posture).
126
+ if (inTargetRepo && commandContainsSubIssueAdHocBypass(command)) {
127
+ return {
128
+ decision: "deny",
129
+ reason:
130
+ "SUBISSUE-NO-ADHOC-BYPASS: ad-hoc `gh api` writes to the target repo's sub_issues endpoint are " +
131
+ "blocked. Manage sub-issues via the sanctioned manage-sub-issues wrapper instead.",
132
+ };
133
+ }
134
+
135
+ // COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER (#1622): ad-hoc thread-resolution writes — raw `gh api` POST
136
+ // to pulls/<n>/comments/<m>/replies, or a `gh api graphql` resolveReviewThread mutation (the Rest
137
+ // path names the target repo; the graphql form has no path-host repo, so it is scoped to the cwd
138
+ // repo). Actor-independent: reply through reply-resolve-review-thread(s).mjs.
139
+ if (inTargetRepo && (commandContainsReplyResolveBypass(command) || commandContainsGraphqlResolveReviewThread(command))) {
140
+ return {
141
+ decision: "deny",
142
+ reason:
143
+ "COPILOT-FOLLOWUP-REPLY-RESOLVE-HELPER: ad-hoc thread-reply mutations are blocked. Resolve review " +
144
+ "threads via scripts/github/reply-resolve-review-thread.mjs (one thread) or " +
145
+ "reply-resolve-review-threads.mjs (multiple threads, --message-map), not raw gh api/graphql.",
146
+ };
147
+ }
148
+
149
+ // COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY (#1622): ad-hoc Copilot review requests — raw `gh api` writes
150
+ // to pulls/<n>/requested_reviewers, or a bare `/copilot` / `/copilot re-review` comment summon on the
151
+ // target repo. Actor-independent: request Copilot via scripts/github/request-copilot-review.mjs.
152
+ if (inTargetRepo && (commandContainsCopilotRequestBypass(command) || commandContainsCopilotSummonComment(command))) {
153
+ return {
154
+ decision: "deny",
155
+ reason:
156
+ "COPILOT-FOLLOWUP-REQUEST-HELPER-ONLY: ad-hoc Copilot review requests are blocked. Request Copilot " +
157
+ "via scripts/github/request-copilot-review.mjs — do not write requested_reviewers or post a literal " +
158
+ "/copilot comment.",
159
+ };
160
+ }
161
+
88
162
  // `git stash` writes to `refs/stash`, one ref shared by every worktree over this repo's single
89
163
  // `.git` directory — a stash from one worktree can pop into another's. Block it outright on the
90
164
  // target repo; see skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout for the
91
165
  // stash-free alternative (git diff / a patch file / a scratch checkout).
92
- if (commandContainsGitStash(command) && (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase()) {
166
+ if (commandContainsGitStash(command) && inTargetRepo) {
93
167
  return {
94
168
  decision: "deny",
95
169
  reason:
@@ -130,7 +204,36 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
130
204
  const isReady = commandContainsGhPrReady(command);
131
205
  const isMerge = commandContainsGhPrMerge(command);
132
206
  const isCreate = commandContainsGhPrCreate(command);
207
+
208
+ // STOP-HUMAN-MERGE-001 (#1622): when the repo resolves `autonomy.humanMergeOnly`, `gh pr merge` is
209
+ // refused actor-independently — the main agent is the actor that performs GitHub writes, so only an
210
+ // actor-independent deny enforces the human-merge invariant (an agent-scoped deny would enforce
211
+ // nothing on the main-agent write path).
212
+ if (humanMergeOnly && isMerge && inTargetRepo) {
213
+ return {
214
+ decision: "deny",
215
+ reason:
216
+ "STOP-HUMAN-MERGE-001: this repo resolves autonomy.humanMergeOnly — the loop must stop at merge " +
217
+ "for a human action; the agent MUST NOT run `gh pr merge`. Leave the PR merge-ready and a human " +
218
+ "merges it.",
219
+ };
220
+ }
221
+
133
222
  if (!isReady && !isMerge && !isCreate) {
223
+ // COPILOT-FOLLOWUP-WAIT-TOOLS (#1622): banned detached/polling wait wrappers. Subagent-only — the
224
+ // rule is classified `agent` (behavioral guidance for the dev-loop driving agent); the main
225
+ // agent/operator retains manual wait tooling. The main agent's own sanctioned wait path is still
226
+ // the deterministic tools.
227
+ if (typeof agentType === "string" && inTargetRepo && commandContainsDetachedWaitTool(command)) {
228
+ return {
229
+ decision: "deny",
230
+ reason:
231
+ "COPILOT-FOLLOWUP-WAIT-TOOLS: wait only through deterministic tools (scripts/loop/detect-copilot-" +
232
+ "loop-state.mjs one-shot, dev-loops loop watch-cycle persistent, scripts/github/wait-pr-checks.mjs, " +
233
+ "gh run watch) — nohup/disown/tmux/screen detach and while-sleep-poll loops are barred for the " +
234
+ "dev-loop driving agent.",
235
+ };
236
+ }
134
237
  return ALLOW;
135
238
  }
136
239
 
@@ -265,3 +368,67 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
265
368
  "See skills/docs/main-agent-contract.md.",
266
369
  };
267
370
  }
371
+
372
+ /**
373
+ * Env var that exempts an interactive session awaiting commit authorization from the
374
+ * SubagentStop uncommitted-work guard (#1619).
375
+ *
376
+ * An opt-in signal set by the operator or the interactive coordination path
377
+ * (`DEVLOOPS_COMMIT_AUTH_PENDING=1`) when intentionally holding uncommitted work pending
378
+ * operator commit authorization — consistent with the operator-set `DEVLOOPS_*` env vars in
379
+ * this repo (`DEVLOOPS_MAIN_AGENT_READONLY`, `DEVLOOPS_ALLOW_MAIN`, `DEVLOOPS_SUBAGENT_AVAILABLE`),
380
+ * which are environment/operator signals rather than values written by a code path. A
381
+ * non-interactive (dispatched) subagent leaves it unset, so its commit-before-exit obligation
382
+ * stays enforced.
383
+ */
384
+ export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
385
+
386
+ /**
387
+ * Decide whether a SubagentStop must be blocked because the subagent's worktree has
388
+ * uncommitted changes (#1619).
389
+ *
390
+ * `scripts/loop/cleanup-worktree.mjs` runs `git worktree remove --force` after a merge, so
391
+ * uncommitted changes in a worktree are destroyed with no warning. `LOCAL-COMMIT-BEFORE-EXIT`
392
+ * existed only as prose. This decider makes it mechanical: refuse the subagent stop when the
393
+ * cwd is under `tmp/worktrees/` and `git status --porcelain` is non-empty, unless the session
394
+ * is an interactive one awaiting commit authorization (exempt). A clean worktree, a cwd
395
+ * outside `tmp/worktrees/`, and a git-error/empty-porcelain case all allow the stop.
396
+ *
397
+ * Pure and side-effect free. The hook script gathers `cwd` and the `git status --porcelain`
398
+ * output and calls this; the block decision is surfaced via exit code 2 + stderr JSON by the
399
+ * hook (the SubagentStop contract differs from PreToolUse's `permissionDecision` form).
400
+ *
401
+ * @param {Object} params
402
+ * @param {string|undefined} params.cwd - Current working directory; a non-string value is
403
+ * treated as out of scope (allow) — the decider is fail-safe.
404
+ * @param {string|undefined} params.porcelain - Raw `git status --porcelain` output; a non-string
405
+ * or empty value is treated as clean (allow) — the decider is fail-safe.
406
+ * @param {boolean} [params.pendingCommitAuthorization] - True when the interactive session is
407
+ * awaiting commit authorization (exempt) — derived by the hook script from the
408
+ * `DEVLOOPS_COMMIT_AUTH_PENDING=1` opt-in env signal.
409
+ * @returns {HookDecision}
410
+ */
411
+ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false }) {
412
+ if (typeof cwd !== "string" || !isUnderWorktreePath(cwd)) {
413
+ return ALLOW;
414
+ }
415
+ if (pendingCommitAuthorization) {
416
+ return ALLOW;
417
+ }
418
+ if (typeof porcelain !== "string" || porcelain.trim() === "") {
419
+ return ALLOW;
420
+ }
421
+ const dirty = porcelain
422
+ .split("\n")
423
+ .map((l) => l.trim())
424
+ .filter(Boolean);
425
+ return {
426
+ decision: "block",
427
+ reason:
428
+ "LOCAL-COMMIT-BEFORE-EXIT: the worktree has uncommitted changes — refusing subagent exit " +
429
+ "to prevent silent data loss from post-merge worktree cleanup (cleanup-worktree.mjs runs " +
430
+ "`git worktree remove --force`). Commit your work before stopping. " +
431
+ `Dirty paths (${dirty.length}):\n` +
432
+ dirty.map((p) => " " + p).join("\n"),
433
+ };
434
+ }
@@ -126,6 +126,27 @@ export function parseIssueNumber(value, parseError = null) {
126
126
  return parsePositiveInteger(value, "--issue", parseError);
127
127
  }
128
128
 
129
+ // Parse a comma-separated allowlist of numeric issue/PR ids (e.g. `1670,9000`
130
+ // from an `--allowed-refs` CLI option). Returns the ids as deduped numeric
131
+ // strings, empty array for an empty/whitespace-only input. Rejects any
132
+ // non-numeric (or zero) entry so a typo can never silently allowlist nothing.
133
+ export function parseAllowedRefsCsv(value, flag, parseError = null) {
134
+ const parts = String(value ?? "")
135
+ .split(",")
136
+ .map((s) => s.trim())
137
+ .filter((s) => s.length > 0);
138
+ const ids = [];
139
+ for (const part of parts) {
140
+ if (!/^\d+$/u.test(part) || Number(part) === 0) {
141
+ throw toCliError(`${flag} must be a comma-separated list of positive integers (got ${JSON.stringify(part)})`, parseError);
142
+ }
143
+ if (!ids.includes(part)) {
144
+ ids.push(part);
145
+ }
146
+ }
147
+ return ids;
148
+ }
149
+
129
150
  // `stdinText` is optional and additive: omit it and stdin stays closed exactly
130
151
  // as before. Supply it (a `gh api ... --input -` payload) and it is piped in,
131
152
  // so a caller that needs stdin no longer has to reach for a second, separately
@@ -286,7 +286,8 @@ const FanoutConfig = z.strictObject({
286
286
  mode: z.enum(["grouped", "per-angle"]).default("grouped").describe("Angle dispatch mode: grouped batches related angles onto one reviewer each (default); per-angle bypasses the configured-groups table and emits one singleton unit per angle (the original full-scrutiny shape). per-angle is equivalent to maxAnglesPerGroup: 1 in dispatch unit size ONLY when no configured multi-angle group matches a resolved angle; otherwise per-angle bypasses configured groups while maxAnglesPerGroup: 1 honors them (matched first, never split)."),
287
287
  groups: z.array(FanoutGroup).optional().describe("Static named angle groups consulted in grouped mode. An angle absent from every group joins the auto-chunked leftover pool (chunked into units of ≤maxAnglesPerGroup)."),
288
288
  maxAnglesPerGroup: z.number().int().min(1).default(3).describe("Max angles per auto-chunked dispatch unit for leftover ungrouped angles (default 3, min 1). Configured groups are matched first and never split by this knob; mode: per-angle bypasses the table entirely (one singleton per angle)."),
289
- maxConcurrent: z.number().int().min(1).default(4).describe("Max dispatch units (groups) the conductor dispatches concurrently per wave (default 4, min 1). The wave plan is emitted by write-gate-context.mjs via scheduleFanoutWaves (scheduleParallelWaves)."),
289
+ maxConcurrent: z.number().int().min(1).default(4).describe("Max dispatch units (groups) the conductor dispatches concurrently per wave (default 4, min 1). The wave plan is emitted by write-gate-context.mjs via scheduleFanoutWaves (scheduleParallelWaves). Ignored when sequential is true (which forces one unit per wave)."),
290
+ sequential: z.boolean().default(false).describe("Dispatch heavy reviewers one at a time (serial) instead of wave-by-wave parallel (issue #1726). When true, effective fan-out concurrency is one dispatch unit per wave regardless of maxConcurrent, so each heavy reviewer completes and writes its evidence artifact before the next starts. Distinct reviewers, real fan-in/ledger, and provenance are unchanged — this only bounds dispatch concurrency. Default false keeps shipped behaviour unchanged for other harnesses/repos (cross-harness non-regression #1086); a repo sets it in .devloops to bound concurrency for all its PRs."),
290
291
  });
291
292
 
292
293
  /**
@@ -427,6 +428,17 @@ const WorkflowConfig = z.strictObject({
427
428
  requireRetrospective: z.boolean().describe("Require a retrospective checkpoint for the previous qualifying async completion before the next dev-loop start/resume."),
428
429
  requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
429
430
  devModeDefault: z.boolean().describe("Default new loops to dev mode."),
431
+ // Agent-level stall detection (#1669): when a dev-loop child shows no turn
432
+ // progress for `thresholdMinutes` with no pending request, the parent bails
433
+ // to a fresh-context recovery dispatch instead of waiting through a manual
434
+ // interrupt+resume. `enabled: false` disables the auto-bail and restores
435
+ // the old wait behavior.
436
+ stallDetection: z
437
+ .strictObject({
438
+ enabled: z.boolean().default(true).describe("Enable agent-level stall -> auto-fresh-dispatch."),
439
+ thresholdMinutes: z.number().int().min(1).default(5).describe("No-turn-progress window in minutes before a child is treated as stalled."),
440
+ })
441
+ .optional(),
430
442
  // No default here and absent from BUILT_IN_DEFAULTS — unset means "keep
431
443
  // auto-detecting the default branch" (see resolveBaseBranch), never a static
432
444
  // "main". Bare branch name; consumers add the `origin/` remote-ref prefix
@@ -795,6 +807,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
795
807
  requireRetrospective: false,
796
808
  requireDraftFirst: false,
797
809
  devModeDefault: false,
810
+ stallDetection: Object.freeze({ enabled: true, thresholdMinutes: 5 }),
798
811
  }),
799
812
  localImplementation: Object.freeze({
800
813
  lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
@@ -2059,6 +2072,36 @@ export const DEFAULT_MAX_ANGLES_PER_GROUP = 3;
2059
2072
  * `scheduleFanoutWaves` (@dev-loops/core/loop/gate-fanin).
2060
2073
  */
2061
2074
  export const DEFAULT_FANOUT_MAX_CONCURRENT = 4;
2075
+ export const DEFAULT_FANOUT_SEQUENTIAL = false;
2076
+
2077
+ /**
2078
+ * Resolve `gates.fanout.sequential` (issue #1726, default false). Serial
2079
+ * (one-at-a-time) dispatch of heavy reviewers so each completes and writes its
2080
+ * evidence before the next starts — the concurrency bound that keeps genuine
2081
+ * fan-out from SIGTERMing under child-safe parallel overload. Separate from
2082
+ * `maxConcurrent` so a repo may choose either serial (sequential: true) or a
2083
+ * small parallel cap (maxConcurrent: 1-2, sequential: false); the shipped
2084
+ * default stays false for cross-harness non-regression (#1086).
2085
+ * @param {DevLoopConfig} config
2086
+ * @returns {boolean}
2087
+ */
2088
+ export function resolveFanoutSequential(config) {
2089
+ const s = config?.gates?.fanout?.sequential;
2090
+ return s === true;
2091
+ }
2092
+
2093
+ /**
2094
+ * Resolve the effective fan-out concurrency (dispatch units per wave) for a
2095
+ * round: 1 when `gates.fanout.sequential` is set (serial dispatch forces one
2096
+ * unit per wave), else `resolveFanoutMaxConcurrent`. The conductor builds the
2097
+ * wave plan from this effective value (issue #1726).
2098
+ * @param {DevLoopConfig} config
2099
+ * @returns {number}
2100
+ */
2101
+ export function resolveFanoutEffectiveConcurrency(config) {
2102
+ if (resolveFanoutSequential(config)) return 1;
2103
+ return resolveFanoutMaxConcurrent(config);
2104
+ }
2062
2105
 
2063
2106
  /**
2064
2107
  * Resolve `gates.fanout.maxAnglesPerGroup` (issue #1601, default 3, min 1).
@@ -2531,6 +2574,15 @@ export function resolveWorkflowConfig(config, key) {
2531
2574
  return config?.workflow?.devModeDefault ?? DEFAULT_WORKFLOW_CONFIG.devModeDefault;
2532
2575
  }
2533
2576
 
2577
+ if (key === "stallDetection") {
2578
+ const configured = config?.workflow?.stallDetection;
2579
+ const def = DEFAULT_WORKFLOW_CONFIG.stallDetection;
2580
+ return {
2581
+ enabled: configured?.enabled ?? def.enabled,
2582
+ thresholdMinutes: configured?.thresholdMinutes ?? def.thresholdMinutes,
2583
+ };
2584
+ }
2585
+
2534
2586
  throw new Error(`Unknown workflow config key: ${key}`);
2535
2587
  }
2536
2588
 
@@ -278,6 +278,11 @@ workflow:
278
278
  # it on consumers' product phases (#846). Matches the code default; the dev-loops repo opts in
279
279
  # via its own repo-root .devloops (which takes precedence over these extension defaults).
280
280
  devModeDefault: false
281
+ # Agent-level stall detection (#1669): auto-bail to fresh-context dispatch
282
+ # when a dev-loop child shows no turn progress for the window.
283
+ stallDetection:
284
+ enabled: true
285
+ thresholdMinutes: 5
281
286
 
282
287
  # Light-mode threshold for small local changes.
283
288
  localImplementation:
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ISSUE/PR-ID GUARD for generated comment bodies.
3
+ *
4
+ * Mandate (#1731, operator directive): generated gate/review/verdict comment
5
+ * bodies must NEVER emit raw issue or PR ids. Public comment surfaces are
6
+ * world-readable, and a bare `#<digits>` in a comment body is auto-linked by
7
+ * GitHub to that issue/PR — leaking internal cross-references and violating
8
+ * the no-ids-in-comments rule.
9
+ *
10
+ * This helper fails CLOSED: it refuses (throws) a body that contains a raw
11
+ * `#<digits>` token, unless that id is explicitly allowlisted as a deliberate
12
+ * cross-reference (`allowedRefs`). There is deliberately NO silent stripping —
13
+ * a stripped id could silently drop a needed cross-ref while still posting;
14
+ * refusal forces the caller to make the cross-ref deliberate (or reword).
15
+ *
16
+ * Wire this into every comment/review write helper that posts a GENERATED
17
+ * body (verdict comments, gate findings reviews, inline finding comments,
18
+ * review-thread replies, and the generic comment/edit writers). Applying it at
19
+ * the low-level POST/PATCH write point means current AND future comment flows
20
+ * are guarded automatically — a future writer that routes through these
21
+ * helpers cannot emit an issue/PR id without an explicit allowlist entry.
22
+ *
23
+ * Deliberate cross-reference mechanism: pass the id(s) to allow as
24
+ * `allowedRefs: ["1670"]`. This is the ONLY sanctioned way a generated comment
25
+ * body may reference an issue/PR id. Keep the allowlist small and deliberate.
26
+ */
27
+
28
+ // Matches a bare GitHub auto-link issue/PR reference: `#<digits>`. Bound to
29
+ // 1..9 digits to avoid absurd ids while covering the full GitHub id space.
30
+ const ISSUE_PR_ID_RE = /#(\d{1,9})/g;
31
+
32
+ /**
33
+ * Extract the raw issue/PR id tokens found in a body (as strings, deduped).
34
+ * Returns [] for non-string input (and for a body with no `#<digits>`).
35
+ */
36
+ export function extractIssuePrIds(body) {
37
+ if (typeof body !== "string" || body.length === 0) return [];
38
+ const found = new Set();
39
+ for (const m of body.matchAll(ISSUE_PR_ID_RE)) {
40
+ found.add(m[1]);
41
+ }
42
+ return [...found];
43
+ }
44
+
45
+ /**
46
+ * Fail-closed guard: returns `body` unchanged when it contains no raw
47
+ * issue/PR id (or every id it contains is explicitly allowlisted). Throws
48
+ * otherwise, refusing to emit the body.
49
+ *
50
+ * @param {string} body - the generated comment body to guard.
51
+ * @param {object} [opts]
52
+ * @param {string} [opts.ref] - human label for the guarded surface (error context).
53
+ * @param {Iterable<number|string>} [opts.allowedRefs] - explicit allowlist of
54
+ * deliberate cross-reference ids permitted to appear in the body.
55
+ * @returns {string} the (unchanged, since no stripping) body.
56
+ */
57
+ export function guardCommentBodyNoIssuePrIds(body, { ref = "generated comment body", allowedRefs = [] } = {}) {
58
+ if (typeof body !== "string") return body;
59
+ const allow = new Set(Array.from(allowedRefs ?? [], (id) => String(id)));
60
+ const offending = extractIssuePrIds(body).filter((id) => !allow.has(id));
61
+ if (offending.length > 0) {
62
+ throw new Error(
63
+ `comment-id-guard refused to emit ${ref}: contains raw issue/PR id reference(s) ` +
64
+ `#${offending.join(", #")}. Bare #digits in generated comment bodies violate the ` +
65
+ `no-ids-in-comments rule (public leakage). Reword to a generic reference, or pass the ` +
66
+ `id(s) to allowedRefs on the guarded write to make an explicit deliberate cross-reference.`,
67
+ );
68
+ }
69
+ return body;
70
+ }
@@ -80,6 +80,37 @@ export function isCopilotLogin(login) {
80
80
  return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
81
81
  }
82
82
 
83
+ /**
84
+ * Resolve whether Copilot is present as a reviewer on a PR from the REVIEW
85
+ * surface only — requested reviewers plus submitted reviews — never from
86
+ * assignees (#1670).
87
+ *
88
+ * Copilot review is configured in two ways: Copilot is either formally listed
89
+ * in the PR's `requested_reviewers`, or it is a configured auto-reviewer
90
+ * (`copilot-pull-request-reviewer[bot]`) that submits an actual review without
91
+ * ever appearing in `requested_reviewers`. Both are review-surface facts.
92
+ * Assignment is a disjoint surface and must never decide presence: on a
93
+ * reviewer-configured repo Copilot is never an assignee, so an assignee-based
94
+ * proxy would falsely report a fully-configured Copilot reviewer as absent and
95
+ * could let the gate skip the Copilot-convergence requirement on a false premise.
96
+ *
97
+ * @param {object} params
98
+ * @param {boolean} [params.requested] - Copilot is listed in the PR's requested_reviewers
99
+ * @param {Array<{author?: {login?: string}}>} [params.reviews] - PR review list
100
+ * @returns {{ present: boolean, sources: string[] }}
101
+ */
102
+ export function resolveCopilotReviewPresence({ requested = false, reviews = [] } = {}) {
103
+ const list = Array.isArray(reviews) ? reviews : [];
104
+ const sources = [];
105
+ if (requested === true) {
106
+ sources.push("requested_reviewer");
107
+ }
108
+ if (list.some((review) => isCopilotLogin(review?.author?.login))) {
109
+ sources.push("submitted_review");
110
+ }
111
+ return { present: sources.length > 0, sources };
112
+ }
113
+
83
114
  // Anti-summon literal: bare-text `@copilot` or a `/copilot*` slash command. Both
84
115
  // the write-side sanitizer and the read-side guard scan key off this shape so a
85
116
  // gate-evidence comment can quote the rule (inside a code span/fenced block)
@@ -3,6 +3,7 @@ import { readFileSync, statSync } from "node:fs";
3
3
  import { runChild as defaultRunChild } from "../cli/primitives.mjs";
4
4
  import { parseJsonText } from "./review-threads.mjs";
5
5
  import { parseRepoSlug } from "./repo-slug.mjs";
6
+ import { guardCommentBodyNoIssuePrIds } from "./comment-id-guard.mjs";
6
7
 
7
8
  /**
8
9
  * Core `gh issue` operations, extracted from the thin CLI wrappers under
@@ -227,6 +228,11 @@ export async function resolveCommentBody(options) {
227
228
 
228
229
  export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
229
230
  const body = await resolveCommentBody(options);
231
+ // ISSUE/PR-ID GUARD (#1731): a generated comment body must never emit a raw
232
+ // issue/PR id (fail-closed unless explicitly allowlisted). `allowedRefs` is
233
+ // the ONLY sanctioned escape for a deliberate cross-reference, threaded from
234
+ // the generic CLI writers' --allowed-refs option.
235
+ guardCommentBodyNoIssuePrIds(body, { ref: "issue comment body", allowedRefs: options.allowedRefs });
230
236
  const result = await run(
231
237
  ghCommand,
232
238
  ["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],