@dev-loops/core 1.0.0-rc.4 → 1.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/src/claude/hook-decisions.mjs +172 -5
- package/src/cli/primitives.mjs +21 -0
- package/src/config/config.mjs +83 -2
- package/src/config/extension-defaults.yaml +5 -0
- package/src/github/comment-id-guard.mjs +70 -0
- package/src/github/copilot-helpers.mjs +31 -0
- package/src/github/issue-ops.mjs +6 -0
- package/src/loop/agent-stall.mjs +194 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-ci-status.mjs +18 -7
- package/src/loop/default-branch-guard.mjs +1 -1
- package/src/loop/gate-fanin.mjs +244 -0
- package/src/loop/handoff-envelope.mjs +28 -1
- package/src/loop/issue-refinement-artifact.mjs +154 -18
- package/src/loop/main-checkout-ff.mjs +39 -0
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/review-dispatch-plan.mjs +595 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/move-queue-item.mjs +37 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
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
|
-
*
|
|
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
|
|
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) &&
|
|
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
|
+
}
|
package/src/cli/primitives.mjs
CHANGED
|
@@ -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
|
package/src/config/config.mjs
CHANGED
|
@@ -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
|
/**
|
|
@@ -424,9 +425,20 @@ const WorkflowConfig = z.strictObject({
|
|
|
424
425
|
// it here would also mean renaming a shipped artifact contract, not just a
|
|
425
426
|
// config key. Out of scope for this config-shape RFC; revisit as its own
|
|
426
427
|
// change against skills/docs/gate-review-comment-contract.md + the envelope schema.
|
|
427
|
-
requireRetrospective: z.boolean().describe("Require a retrospective checkpoint before
|
|
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 }),
|
|
@@ -1431,6 +1444,35 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1431
1444
|
// is an existing, separate concern.
|
|
1432
1445
|
const validation = FileConfigSchema.safeParse(data);
|
|
1433
1446
|
if (!validation.success) {
|
|
1447
|
+
// Surface a visible WARNING (not just the structured error) so the
|
|
1448
|
+
// whole-layer drop is never silent (#1578): many consumers destructure
|
|
1449
|
+
// only `config` (or `config` + `warnings`) and never read `errors`, so a
|
|
1450
|
+
// schema-rejected layer would vanish without a trace. Naming the
|
|
1451
|
+
// offending keys here lets a stale raw-key config (e.g.
|
|
1452
|
+
// gates.<gate>.mandatoryAngles/excludeAngles) point at the canonical
|
|
1453
|
+
// angle-entry migration path.
|
|
1454
|
+
const offendingKeys = validation.error.issues
|
|
1455
|
+
.flatMap((i) => {
|
|
1456
|
+
if (i.code === "unrecognized_keys" && Array.isArray(i.keys) && i.keys.length) {
|
|
1457
|
+
const prefix = i.path.length ? `${i.path.join(".")}.` : "";
|
|
1458
|
+
return i.keys.map((k) => `${prefix}${k}`);
|
|
1459
|
+
}
|
|
1460
|
+
return i.path.length ? [i.path.join(".")] : [];
|
|
1461
|
+
});
|
|
1462
|
+
// Gate the raw-key migration hint: only append it when the offending
|
|
1463
|
+
// keys actually include the pre-redesign mandatoryAngles/excludeAngles
|
|
1464
|
+
// names, so an unrelated schema failure (e.g. a type error) does not get
|
|
1465
|
+
// misleading raw-key migration guidance. (#1578)
|
|
1466
|
+
const hasRawGateKey = offendingKeys.some((k) => /mandatoryAngles|excludeAngles/.test(k));
|
|
1467
|
+
const migrationHint = hasRawGateKey
|
|
1468
|
+
? ` Migrate raw gates.<gate>.mandatoryAngles/excludeAngles to the canonical angle-entry shape ` +
|
|
1469
|
+
`(gates.<gate>.angles with { name, mandatory: true } / { name, enabled: false }).`
|
|
1470
|
+
: ` Fix or remove the offending key(s) to restore this config layer.`;
|
|
1471
|
+
warnings.push(
|
|
1472
|
+
`${path.basename(filePath)}: config layer rejected by schema — the whole layer was dropped, so this layer's overrides are not applied (previously merged layers remain in effect). ` +
|
|
1473
|
+
`Offending key(s): ${offendingKeys.length ? offendingKeys.join(", ") : "(unknown)"}.` +
|
|
1474
|
+
migrationHint
|
|
1475
|
+
);
|
|
1434
1476
|
errors.push({
|
|
1435
1477
|
path: filePath,
|
|
1436
1478
|
message: `${path.basename(filePath)}: Schema validation failed: ${validation.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
|
|
@@ -2030,6 +2072,36 @@ export const DEFAULT_MAX_ANGLES_PER_GROUP = 3;
|
|
|
2030
2072
|
* `scheduleFanoutWaves` (@dev-loops/core/loop/gate-fanin).
|
|
2031
2073
|
*/
|
|
2032
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
|
+
}
|
|
2033
2105
|
|
|
2034
2106
|
/**
|
|
2035
2107
|
* Resolve `gates.fanout.maxAnglesPerGroup` (issue #1601, default 3, min 1).
|
|
@@ -2502,6 +2574,15 @@ export function resolveWorkflowConfig(config, key) {
|
|
|
2502
2574
|
return config?.workflow?.devModeDefault ?? DEFAULT_WORKFLOW_CONFIG.devModeDefault;
|
|
2503
2575
|
}
|
|
2504
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
|
+
|
|
2505
2586
|
throw new Error(`Unknown workflow config key: ${key}`);
|
|
2506
2587
|
}
|
|
2507
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)
|
package/src/github/issue-ops.mjs
CHANGED
|
@@ -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],
|