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

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.
Files changed (47) hide show
  1. package/package.json +12 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. package/src/security/secret-scan.mjs +330 -0
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.7",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -21,11 +21,14 @@
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",
25
+ "./github/gh": "./src/github/gh.mjs",
24
26
  "./github/issue-ops": "./src/github/issue-ops.mjs",
25
27
  "./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
26
28
  "./github/repo-slug": "./src/github/repo-slug.mjs",
27
29
  "./github/review-threads": "./src/github/review-threads.mjs",
28
30
  "./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
31
+ "./loop/agent-stall": "./src/loop/agent-stall.mjs",
29
32
  "./loop/bash-command-classify": "./src/loop/bash-command-classify.mjs",
30
33
  "./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
31
34
  "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
@@ -35,6 +38,7 @@
35
38
  "./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
36
39
  "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
37
40
  "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
41
+ "./loop/markdown-sections": "./src/loop/markdown-sections.mjs",
38
42
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
39
43
  "./loop/phase-files": "./src/loop/phase-files.mjs",
40
44
  "./loop/policy-constants": "./src/loop/policy-constants.mjs",
@@ -54,9 +58,14 @@
54
58
  "./loop/queue-membership": "./src/loop/queue-membership.mjs",
55
59
  "./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
56
60
  "./loop/queue-state": "./src/loop/queue-state.mjs",
61
+ "./loop/size-budget-merge-gate": "./src/loop/size-budget-merge-gate.mjs",
62
+ "./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
63
+ "./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
64
+ "./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
57
65
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
58
66
  "./loop/run-context": "./src/loop/run-context.mjs",
59
67
  "./loop/run-inspection": "./src/loop/run-inspection.mjs",
68
+ "./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
60
69
  "./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
61
70
  "./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
62
71
  "./loop/steering": "./src/loop/steering.mjs",
@@ -68,6 +77,7 @@
68
77
  "./loop/ui-review-diagnose": "./src/loop/ui-review-diagnose.mjs",
69
78
  "./loop/ui-review-report": "./src/loop/ui-review-report.mjs",
70
79
  "./loop/ui-review-teardown": "./src/loop/ui-review-teardown.mjs",
80
+ "./security/secret-scan": "./src/security/secret-scan.mjs",
71
81
  "./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
72
82
  "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
73
83
  "./projects/resolve-project": "./src/projects/resolve-project.mjs",
@@ -75,6 +85,7 @@
75
85
  "./tracker": "./src/tracker/index.mjs",
76
86
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
77
87
  "./loop/main-checkout-ff": "./src/loop/main-checkout-ff.mjs",
88
+ "./loop/normalize": "./src/loop/normalize.mjs",
78
89
  "./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
79
90
  },
80
91
  "bin": {
@@ -22,6 +22,10 @@ export const ChangeCategory = Object.freeze({
22
22
  // child_process/shell exec, untrusted network fetch, destructive filesystem
23
23
  // ops / local-file upload). Triggers an up-front adversarial threat-model angle.
24
24
  SECURITY_SENSITIVE_SEAM: "SECURITY_SENSITIVE_SEAM",
25
+ // #1442: a changed file is on the prose surface (docs/articles/**,
26
+ // docs/presentations/**, README*, narrative docs/*.md) — ADR 0041 prose half.
27
+ // Triggers the required fail-closed `deslop` gate angle.
28
+ PROSE_PRESENT: "PROSE_PRESENT",
25
29
  });
26
30
 
27
31
  // ---------------------------------------------------------------------------
@@ -69,6 +73,12 @@ export const CATEGORY_ANGLE_MAP = {
69
73
  [ChangeCategory.SECURITY_SENSITIVE_SEAM]: [
70
74
  "threat-model", "input-validation", "scope", "correctness",
71
75
  ],
76
+ // #1442 (ADR 0041 prose half): a prose-surface path is present → run the
77
+ // required `deslop` gate angle. skills/docs/** is deliberately NOT prose, so
78
+ // a normative-contract-only docs diff never triggers deslop.
79
+ [ChangeCategory.PROSE_PRESENT]: [
80
+ "deslop",
81
+ ],
72
82
  };
73
83
 
74
84
  /**
@@ -21,6 +21,44 @@
21
21
  // fails closed toward a full re-review, which is what a version bump needs.
22
22
  const DOTFILE_CONFIG_BASENAMES = new Set([".devloops"]);
23
23
 
24
+ // #1442 (ADR 0041 prose half): the prose surface that triggers the required
25
+ // `deslop` gate angle. skills/docs/** is deliberately excluded — those are
26
+ // normative contracts (owned by the contract style guide + contradiction
27
+ // lens), not prose, and deslop's contrast-cutting must not fight RFC-2119
28
+ // modality.
29
+ const PROSE_PATH_RE = /^docs\/(articles|presentations)\//;
30
+ const NARRATIVE_DOC_RE = /^docs\/[^/]+\.(md|markdown)$/;
31
+ // #1442 review finding: the skills/docs/** exemption must hold for the README
32
+ // rule too. PROSE_PATH_RE / NARRATIVE_DOC_RE are anchored to `docs/` so
33
+ // skills/docs files never match them; the README basename rule is the ONLY
34
+ // path by which a skills/docs file could be classified prose (e.g. a future
35
+ // `skills/docs/README-*.md`). Carve the normative-contract subtree out
36
+ // explicitly so a README-named contract never arms deslop.
37
+ const SKILLS_DOCS_EXEMPT_RE = /^(skills\/docs|\x2eclaude\/skills\/docs)\//;
38
+
39
+ /**
40
+ * Whether a file path is on the prose surface (#1442): docs/articles/**,
41
+ * docs/presentations/**, README*, or a narrative direct-child docs/*.md.
42
+ * skills/docs/** (and .claude/skills/docs/**) is exempt (normative
43
+ * contracts, not prose) across ALL rules, including the README basename rule.
44
+ *
45
+ * @param {string} filePath
46
+ * @returns {boolean}
47
+ */
48
+ export function isProsePath(filePath) {
49
+ // #1442 review finding (input-validation): exported trust-boundary guard.
50
+ // Fail closed (false) on a non-string/empty argument rather than crashing in
51
+ // normalizeSep(filePath).replaceAll(...).
52
+ if (typeof filePath !== "string" || filePath.length === 0) return false;
53
+ const fp = normalizeSep(filePath);
54
+ if (SKILLS_DOCS_EXEMPT_RE.test(fp)) return false;
55
+ if (PROSE_PATH_RE.test(fp)) return true;
56
+ if (NARRATIVE_DOC_RE.test(fp)) return true;
57
+ const base = fp.split("/").pop() ?? "";
58
+ if (base.startsWith("README")) return true;
59
+ return false;
60
+ }
61
+
24
62
  /**
25
63
  * @typedef {object} T0Result
26
64
  * @property {string[]} files — flat file paths
@@ -56,6 +94,13 @@ export function analyzeT0(nameStatusOutput) {
56
94
  const extensions = new Set();
57
95
  const directories = new Set();
58
96
  let renameCount = 0;
97
+ // #1442 review finding: prose arming must be scoped to content-carrying
98
+ // rows. A pure deletion (`D` name-status row) has no prose content for the
99
+ // deslop reviewer to strip — arming deslop on it over-selects and re-runs
100
+ // the fan-out over a content-free delta. Added/modified/renamed-dest rows
101
+ // (A / M / M? / R-dest) carry content and still arm prose. Rename rows emit
102
+ // `R<score>\told\tnew` (rawPath = parts[2], the new content-bearing path).
103
+ let prosePresent = false;
59
104
 
60
105
  for (const line of lines) {
61
106
  const parts = line.split("\t");
@@ -73,6 +118,16 @@ export function analyzeT0(nameStatusOutput) {
73
118
  if (dir) directories.add(dir);
74
119
 
75
120
  if (status.startsWith("R")) renameCount++;
121
+ // #1442: a content-carrying prose file arms PROSE_PRESENT → deslop. Pure
122
+ // deletions (`D`) are excluded (no prose content to strip, avoids noise);
123
+ // a pure `R100` rename (git's 100% similarity score — no content changed)
124
+ // is likewise content-free and must not arm deslop. Renames with a score
125
+ // below 100 (`R<score>\told\tnew`, rawPath = the new content-bearing path)
126
+ // carry content and still arm prose.
127
+ if (prosePresent) continue;
128
+ if (status === "D" || status.startsWith("D")) continue;
129
+ if (status.startsWith("R") && status === "R100") continue;
130
+ if (isProsePath(path)) prosePresent = true;
76
131
  }
77
132
 
78
133
  const renameOnly = lines.length > 0 && renameCount === lines.length;
@@ -87,6 +142,7 @@ export function analyzeT0(nameStatusOutput) {
87
142
  directories: [...directories].sort(),
88
143
  renameOnly,
89
144
  allDocs,
145
+ prosePresent,
90
146
  };
91
147
  }
92
148
 
@@ -372,6 +428,7 @@ function t0FileCategories(t0) {
372
428
  const categories = [];
373
429
  if (t0.renameOnly) categories.push("RENAME_ONLY");
374
430
  if (t0.allDocs) categories.push("DOCS_ONLY");
431
+ if (t0.prosePresent) categories.push("PROSE_PRESENT");
375
432
  if (t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
376
433
  if (t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
377
434
  if (t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
@@ -392,6 +449,7 @@ function t0PresentSurfaceCategories(t0) {
392
449
  const categories = [];
393
450
  const cats = new Set(t0.files.map(classifyFile));
394
451
  if (cats.has("docs")) categories.push("DOCS_ONLY");
452
+ if (t0.prosePresent) categories.push("PROSE_PRESENT");
395
453
  if (cats.has("config")) categories.push("CONFIG_ONLY");
396
454
  if (cats.has("test")) categories.push("TEST_ONLY");
397
455
  if (cats.has("ci")) categories.push("CI_ONLY");
@@ -445,8 +503,17 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
445
503
  // When t1 is null (unambiguous diff), infer categories from t0
446
504
  // so dynamic angle resolution can narrow for config-only / test-only etc.
447
505
  if (!t1) {
506
+ // #1442 Copilot finding: a genuinely MIXED diff whose T1 never ran (no
507
+ // diffOutput) must NOT get a T0-only PROSE_PRESENT category. Non-empty
508
+ // categories set ambiguous=false, so a mixed code+prose diff would
509
+ // under-select to just deslop + always-include and drop the code-review
510
+ // core. T0-only inference is only safe for unambiguous diffs (docs-only /
511
+ // single surface); a mixed diff without hunk content is unclassifiable, so
512
+ // return empty categories and let resolveDynamicAngles fall back to the
513
+ // full angle set (fail closed).
514
+ const changeCategories = t0Ambiguous ? [] : inferCategoriesFromT0(t0);
448
515
  t1 = {
449
- changeCategories: inferCategoriesFromT0(t0),
516
+ changeCategories,
450
517
  hunkCount: 0,
451
518
  lineStats: { added: 0, deleted: 0 },
452
519
  };
@@ -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,99 @@ 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
+ * Env var that exempts an orchestrator-owned-commit dispatch from the SubagentStop
388
+ * uncommitted-work guard (#1786).
389
+ *
390
+ * A "LOCAL EDITS ONLY: no commit" dispatch (e.g. the `developer`/`quality`/`docs` delegation
391
+ * pattern in `skills/local-implementation/SKILL.md` "Delegation contract") tells the subagent to
392
+ * make local edits and report changed files, leaving commit + push to the dispatching
393
+ * orchestrator once it consolidates results. Without an exemption, that subagent's own
394
+ * SubagentStop event still sees the dirty worktree it was told not to commit and deadlocks. The
395
+ * dispatcher sets `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` for that dispatch to declare it owns the
396
+ * commit — same opt-in `DEVLOOPS_*` signal shape as `DEVLOOPS_COMMIT_AUTH_PENDING`, but distinct:
397
+ * this one exempts a non-interactive delegated dispatch whose commit responsibility sits with its
398
+ * caller, not an interactive session awaiting operator authorization. Left unset, an ordinary
399
+ * dispatch's commit-before-exit obligation stays enforced (fail closed by default).
400
+ */
401
+ export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT";
402
+
403
+ /**
404
+ * Decide whether a SubagentStop must be blocked because the subagent's worktree has
405
+ * uncommitted changes (#1619).
406
+ *
407
+ * `scripts/loop/cleanup-worktree.mjs` runs `git worktree remove --force` after a merge, so
408
+ * uncommitted changes in a worktree are destroyed with no warning. `LOCAL-COMMIT-BEFORE-EXIT`
409
+ * existed only as prose. This decider makes it mechanical: refuse the subagent stop when the
410
+ * cwd is under `tmp/worktrees/` and `git status --porcelain` is non-empty, unless the session
411
+ * is an interactive one awaiting commit authorization, or the dispatch is an explicit
412
+ * orchestrator-owned-commit exemption (#1786) (either exempt). A clean worktree, a cwd
413
+ * outside `tmp/worktrees/`, and a git-error/empty-porcelain case all allow the stop.
414
+ *
415
+ * Pure and side-effect free. The hook script gathers `cwd` and the `git status --porcelain`
416
+ * output and calls this; the block decision is surfaced via exit code 2 + stderr JSON by the
417
+ * hook (the SubagentStop contract differs from PreToolUse's `permissionDecision` form).
418
+ *
419
+ * @param {Object} params
420
+ * @param {string|undefined} params.cwd - Current working directory; a non-string value is
421
+ * treated as out of scope (allow) — the decider is fail-safe.
422
+ * @param {string|undefined} params.porcelain - Raw `git status --porcelain` output; a non-string
423
+ * or empty value is treated as clean (allow) — the decider is fail-safe.
424
+ * @param {boolean} [params.pendingCommitAuthorization] - True when the interactive session is
425
+ * awaiting commit authorization (exempt) — derived by the hook script from the
426
+ * `DEVLOOPS_COMMIT_AUTH_PENDING=1` opt-in env signal.
427
+ * @param {boolean} [params.orchestratorOwnsCommit] - True when this dispatch is an explicit
428
+ * orchestrator-owned-commit exemption (exempt) — derived by the hook script from the
429
+ * `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` opt-in env signal.
430
+ * @returns {HookDecision}
431
+ */
432
+ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false, orchestratorOwnsCommit = false }) {
433
+ if (typeof cwd !== "string" || !isUnderWorktreePath(cwd)) {
434
+ return ALLOW;
435
+ }
436
+ if (pendingCommitAuthorization || orchestratorOwnsCommit) {
437
+ return ALLOW;
438
+ }
439
+ if (typeof porcelain !== "string" || porcelain.trim() === "") {
440
+ return ALLOW;
441
+ }
442
+ const dirty = porcelain
443
+ .split("\n")
444
+ .map((l) => l.trim())
445
+ .filter(Boolean);
446
+ // The reason is fed back to the stopping subagent as context, so the path
447
+ // enumeration is capped: an unbounded list on a very dirty worktree produces a
448
+ // multi-megabyte reason that consumers truncate or choke on, burying the one
449
+ // actionable line. The "Dirty paths (N):" header below always carries the full
450
+ // dirty count; the trailing "… and X more" line (when present) carries the
451
+ // remaining count past the cap, not the full total.
452
+ const MAX_LISTED_DIRTY_PATHS = 50;
453
+ const listed = dirty.slice(0, MAX_LISTED_DIRTY_PATHS).map((p) => " " + p);
454
+ if (dirty.length > MAX_LISTED_DIRTY_PATHS) {
455
+ listed.push(` … and ${dirty.length - MAX_LISTED_DIRTY_PATHS} more (run \`git status --porcelain\` for the full list)`);
456
+ }
457
+ return {
458
+ decision: "block",
459
+ reason:
460
+ "LOCAL-COMMIT-BEFORE-EXIT: the worktree has uncommitted changes — refusing subagent exit " +
461
+ "to prevent silent data loss from post-merge worktree cleanup (cleanup-worktree.mjs runs " +
462
+ "`git worktree remove --force`). Commit your work before stopping. " +
463
+ `Dirty paths (${dirty.length}):\n` +
464
+ listed.join("\n"),
465
+ };
466
+ }
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
2
4
  import { parseArgs } from "node:util";
3
5
 
4
6
  /**
@@ -113,7 +115,7 @@ export function parsePositiveInteger(value, flag, parseError = null) {
113
115
 
114
116
  export function parseNonNegativeInteger(value, flag, parseError = null) {
115
117
  if (!/^\d+$/.test(value)) {
116
- throw toCliError(`${flag} must be a non-negative integer`, parseError);
118
+ throw toCliError(`${flag} must be a non-negative integer, got "${value}"`, parseError);
117
119
  }
118
120
  return Number(value);
119
121
  }
@@ -126,6 +128,27 @@ export function parseIssueNumber(value, parseError = null) {
126
128
  return parsePositiveInteger(value, "--issue", parseError);
127
129
  }
128
130
 
131
+ // Parse a comma-separated allowlist of numeric issue/PR ids (e.g. `1670,9000`
132
+ // from an `--allowed-refs` CLI option). Returns the ids as deduped numeric
133
+ // strings, empty array for an empty/whitespace-only input. Rejects any
134
+ // non-numeric (or zero) entry so a typo can never silently allowlist nothing.
135
+ export function parseAllowedRefsCsv(value, flag, parseError = null) {
136
+ const parts = String(value ?? "")
137
+ .split(",")
138
+ .map((s) => s.trim())
139
+ .filter((s) => s.length > 0);
140
+ const ids = [];
141
+ for (const part of parts) {
142
+ if (!/^\d+$/u.test(part) || Number(part) === 0) {
143
+ throw toCliError(`${flag} must be a comma-separated list of positive integers (got ${JSON.stringify(part)})`, parseError);
144
+ }
145
+ if (!ids.includes(part)) {
146
+ ids.push(part);
147
+ }
148
+ }
149
+ return ids;
150
+ }
151
+
129
152
  // `stdinText` is optional and additive: omit it and stdin stays closed exactly
130
153
  // as before. Supply it (a `gh api ... --input -` payload) and it is piped in,
131
154
  // so a caller that needs stdin no longer has to reach for a second, separately
@@ -146,6 +169,33 @@ export function runChild(command, args, env = process.env, stdinText = undefined
146
169
  });
147
170
  }
148
171
 
172
+ /**
173
+ * Resolve a `--body <text>` / `--body-file <path>` pair to the actual body
174
+ * string, the one file-reading step every `--body-file` CLI flag in this repo
175
+ * needs: read the file (or stdin, when `allowStdin` and `bodyFile === "-"`)
176
+ * and FAIL CLOSED (throw) on an empty/whitespace-only result, so a blank or
177
+ * unreadable `--body-file` can never silently clear a PR/comment body. When
178
+ * `bodyFile` is absent, `body` is returned unchanged (no emptiness check —
179
+ * callers that must reject an empty inline `--body` do that themselves,
180
+ * since not every caller enforces it).
181
+ *
182
+ * @param {object} input
183
+ * @param {string} [input.body] — inline body value, returned as-is when no bodyFile
184
+ * @param {string} [input.bodyFile] — path to read the body from ("-" = stdin, when allowStdin)
185
+ * @param {boolean} [input.allowStdin] — honor `bodyFile === "-"` as stdin (fd 0); default false
186
+ * @returns {Promise<string | undefined>}
187
+ */
188
+ export async function resolveBodyOrFile({ body, bodyFile, allowStdin = false } = {}) {
189
+ if (bodyFile === undefined || bodyFile === null) return body;
190
+ // fs/promises readFile does not accept an integer fd, so stdin (fd 0) is
191
+ // read synchronously via readFileSync, which does accept one.
192
+ const content = allowStdin && bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(bodyFile, "utf8");
193
+ if (content.trim().length === 0) {
194
+ throw new Error(`--body-file ${bodyFile} is empty`);
195
+ }
196
+ return content;
197
+ }
198
+
149
199
  export function runCommand(command, args, { cwd = process.cwd(), env = process.env } = {}) {
150
200
  return new Promise((resolve, reject) => {
151
201
  const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });