dev-loops 0.6.0 → 0.7.1

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 (172) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/{info.md → loop-info.md} +2 -2
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/{start-spike.md → loop-start-spike.md} +1 -1
  11. package/.claude/commands/{start.md → loop-start.md} +1 -1
  12. package/.claude/commands/{status.md → loop-status.md} +1 -1
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  16. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  17. package/.claude/skills/dev-loop/SKILL.md +15 -11
  18. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  19. package/.claude/skills/docs/anti-patterns.md +2 -1
  20. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  21. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  22. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  23. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  24. package/.claude/skills/docs/local-planning-flow.md +1 -1
  25. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  26. package/.claude/skills/docs/merge-preconditions.md +17 -1
  27. package/.claude/skills/docs/plan-file-contract.md +1 -1
  28. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  29. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  30. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  31. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  32. package/.claude/skills/local-implementation/SKILL.md +25 -14
  33. package/.claude/skills/loop-grill/SKILL.md +163 -0
  34. package/AGENTS.md +1 -0
  35. package/CHANGELOG.md +95 -0
  36. package/README.md +9 -9
  37. package/agents/dev-loop.agent.md +3 -2
  38. package/agents/developer.agent.md +1 -1
  39. package/agents/docs.agent.md +1 -1
  40. package/agents/fixer.agent.md +1 -1
  41. package/agents/quality.agent.md +1 -1
  42. package/agents/refiner.agent.md +1 -1
  43. package/agents/review.agent.md +3 -2
  44. package/cli/index.mjs +35 -42
  45. package/extension/README.md +4 -4
  46. package/extension/checks.ts +1 -5
  47. package/extension/harness-types.ts +1 -0
  48. package/extension/index.ts +6 -0
  49. package/extension/pi-extension-adapter.ts +2 -0
  50. package/extension/presentation.ts +7 -19
  51. package/package.json +9 -10
  52. package/scripts/github/capture-review-threads.mjs +2 -9
  53. package/scripts/github/comment-issue.mjs +2 -9
  54. package/scripts/github/create-label.mjs +133 -0
  55. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  56. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  57. package/scripts/github/edit-pr.mjs +259 -0
  58. package/scripts/github/fetch-ci-logs.mjs +2 -9
  59. package/scripts/github/list-issues.mjs +2 -9
  60. package/scripts/github/manage-sub-issues.mjs +46 -10
  61. package/scripts/github/offer-human-handoff.mjs +8 -5
  62. package/scripts/github/post-gate-findings.mjs +14 -2
  63. package/scripts/github/probe-ci-status.mjs +8 -2
  64. package/scripts/github/probe-copilot-review.mjs +21 -14
  65. package/scripts/github/ready-for-review.mjs +26 -8
  66. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  67. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  68. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  69. package/scripts/github/request-copilot-review.mjs +8 -2
  70. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  71. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  72. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  73. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  74. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  75. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  76. package/scripts/github/view-pr.mjs +150 -0
  77. package/scripts/github/write-gate-context.mjs +248 -61
  78. package/scripts/github/write-gate-findings-log.mjs +75 -1
  79. package/scripts/lib/jq-output.mjs +18 -0
  80. package/scripts/loop/_post-convergence-change.mjs +211 -0
  81. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  82. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  83. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  84. package/scripts/loop/check-retro-tooling.mjs +14 -3
  85. package/scripts/loop/checkpoint-contract.mjs +7 -4
  86. package/scripts/loop/cleanup-worktree.mjs +12 -3
  87. package/scripts/loop/conductor-monitor.mjs +12 -18
  88. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  89. package/scripts/loop/debt-remediate.mjs +24 -12
  90. package/scripts/loop/detect-change-scope.mjs +36 -7
  91. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  92. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  93. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  94. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  95. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  96. package/scripts/loop/detect-pr-gate-coordination-state.mjs +149 -66
  97. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  98. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  99. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  100. package/scripts/loop/ensure-worktree.mjs +8 -3
  101. package/scripts/loop/info.mjs +12 -4
  102. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  103. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  104. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  105. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  106. package/scripts/loop/inspect-run.mjs +8 -2
  107. package/scripts/loop/outer-loop.mjs +8 -4
  108. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  109. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  110. package/scripts/loop/pre-flight-gate.mjs +9 -9
  111. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  112. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  113. package/scripts/loop/provision-worktree.mjs +74 -3
  114. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  115. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  116. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  117. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  118. package/scripts/loop/run-queue.mjs +18 -9
  119. package/scripts/loop/run-refinement-audit.mjs +8 -9
  120. package/scripts/loop/run-watch-cycle.mjs +2 -9
  121. package/scripts/loop/sanctioned-commands.mjs +104 -0
  122. package/scripts/loop/steer-loop.mjs +29 -16
  123. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  124. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  125. package/scripts/pages/build-site.mjs +59 -8
  126. package/scripts/pages/build-state-atlas.mjs +441 -0
  127. package/scripts/projects/_resolve-project.mjs +148 -0
  128. package/scripts/projects/add-queue-item.mjs +60 -54
  129. package/scripts/projects/archive-done-items.mjs +49 -84
  130. package/scripts/projects/ensure-queue-board.mjs +10 -36
  131. package/scripts/projects/list-queue-items.mjs +116 -65
  132. package/scripts/projects/move-queue-item.mjs +28 -49
  133. package/scripts/projects/reconcile-queue.mjs +253 -0
  134. package/scripts/projects/reorder-queue-item.mjs +41 -47
  135. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  136. package/scripts/projects/sync-item-status.mjs +15 -10
  137. package/scripts/refine/_refine-helpers.mjs +21 -5
  138. package/scripts/refine/exit-spike.mjs +18 -8
  139. package/scripts/refine/promote-plan.mjs +22 -16
  140. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  141. package/scripts/refine/refine-plan-file.mjs +13 -4
  142. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  143. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  144. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  145. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  146. package/scripts/refine/validate-plan-file.mjs +1 -1
  147. package/scripts/refine/validate-spike-file.mjs +1 -1
  148. package/scripts/refine/verify.mjs +11 -6
  149. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  150. package/scripts/release/extract-changelog-section.mjs +16 -2
  151. package/skills/copilot-pr-followup/SKILL.md +24 -12
  152. package/skills/dev-loop/SKILL.md +10 -6
  153. package/skills/docs/acceptance-criteria-verification.md +6 -2
  154. package/skills/docs/anti-patterns.md +2 -1
  155. package/skills/docs/artifact-authority-contract.md +22 -4
  156. package/skills/docs/copilot-loop-operations.md +1 -1
  157. package/skills/docs/cross-harness-regression-contract.md +60 -0
  158. package/skills/docs/issue-intake-procedure.md +1 -0
  159. package/skills/docs/local-planning-flow.md +1 -1
  160. package/skills/docs/local-planning-worked-example.md +1 -1
  161. package/skills/docs/merge-preconditions.md +17 -1
  162. package/skills/docs/plan-file-contract.md +1 -1
  163. package/skills/docs/public-dev-loop-contract.md +1 -1
  164. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  165. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  166. package/skills/docs/workflow-handoff-contract.md +39 -0
  167. package/skills/local-implementation/SKILL.md +25 -14
  168. package/skills/loop-grill/SKILL.md +165 -0
  169. package/.claude/commands/continue.md +0 -15
  170. package/scripts/loop/conductor.mjs +0 -233
  171. package/scripts/loop/detect-stale-runner.mjs +0 -265
  172. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -0,0 +1,211 @@
1
+ // Single source of truth (#1103, #1126) for "did a significant product/test-logic
2
+ // change land since the last Copilot review at the round cap". Both
3
+ // detect-pr-gate-coordination-state.mjs and copilot-pr-handoff.mjs consume this
4
+ // so they agree on the round-cap escape hatch: at the cap, a significant
5
+ // post-convergence change reopens a Copilot cycle (rerequest) instead of the
6
+ // clean fallback; a doc/comment-only change stays at the clean fallback.
7
+ //
8
+ // It lives in scripts/loop/ (not packages/core) because significance is derived
9
+ // from a `gh api .../compare` diff — gh I/O that does not belong in core.
10
+ import { extractReviewCommitSha, isCopilotLogin, parseJsonText } from "../_core-helpers.mjs";
11
+ import { runChild } from "../_cli-primitives.mjs";
12
+
13
+ export function getLatestSubmittedCopilotReviewHeadSha(reviews) {
14
+ const copilotSubmitted = (Array.isArray(reviews) ? reviews : [])
15
+ .filter((review) => {
16
+ const login = review?.author?.login;
17
+ const state = String(review?.state ?? "").toUpperCase();
18
+ return isCopilotLogin(login) && state !== "PENDING";
19
+ })
20
+ .map((review, index) => {
21
+ const submittedAt = review?.submittedAt ?? review?.submitted_at;
22
+ const submittedAtMs = typeof submittedAt === "string" ? Date.parse(submittedAt) : Number.NaN;
23
+ return { review, submittedAtMs, index };
24
+ })
25
+ .sort((left, right) => {
26
+ const leftValid = !Number.isNaN(left.submittedAtMs);
27
+ const rightValid = !Number.isNaN(right.submittedAtMs);
28
+ if (leftValid && rightValid) {
29
+ return right.submittedAtMs - left.submittedAtMs;
30
+ }
31
+ if (leftValid !== rightValid) {
32
+ return leftValid ? -1 : 1;
33
+ }
34
+ return right.index - left.index;
35
+ });
36
+ const latest = copilotSubmitted[0]?.review;
37
+ const sha = extractReviewCommitSha(latest);
38
+ return typeof sha === "string" && sha.trim().length > 0 ? sha.trim() : null;
39
+ }
40
+
41
+ export function isTrivialDocumentationOnlyPath(filePath) {
42
+ if (typeof filePath !== "string") return true;
43
+ const normalized = filePath.trim().toLowerCase();
44
+ if (normalized.length === 0) return true;
45
+ if (normalized.startsWith("docs/")) return true;
46
+ return normalized.endsWith(".md")
47
+ || normalized.endsWith(".mdx")
48
+ || normalized.endsWith(".txt")
49
+ || normalized.endsWith(".rst")
50
+ || normalized.endsWith(".adoc");
51
+ }
52
+
53
+ // Extensions whose comment syntax we can classify with confidence (`//`, `/* */`).
54
+ const JS_TS_EXTENSIONS = [".mjs", ".cjs", ".js", ".jsx", ".ts", ".tsx", ".mts", ".cts"];
55
+
56
+ // Classifies one line's content given the current block-comment state.
57
+ // Returns { code, state }: code=true when the line carries anything that is not
58
+ // comment/blank; state = inBlockComment AFTER the line.
59
+ // Known ceilings (both err toward significance or genuinely-trivial cases only):
60
+ // - a line STARTING with `//` or `/*` inside a string literal is misread as a
61
+ // comment — such a line still has to literally begin with the token, which
62
+ // real code lines almost never do;
63
+ // - a `/*` opener sitting AFTER code on the same line (`foo(); /* start`) is
64
+ // not tracked (we return code=true immediately) — follow-up `*` body lines
65
+ // then read as code → significant, the safe direction.
66
+ function classifyLine(content, inBlock) {
67
+ let rest = content;
68
+ let state = inBlock;
69
+ while (true) {
70
+ if (state) {
71
+ const close = rest.indexOf("*/");
72
+ if (close === -1) return { code: false, state: true };
73
+ rest = rest.slice(close + 2);
74
+ state = false;
75
+ continue;
76
+ }
77
+ const trimmed = rest.trim();
78
+ if (trimmed.length === 0) return { code: false, state };
79
+ if (trimmed.startsWith("//")) return { code: false, state };
80
+ if (trimmed.startsWith("/*")) {
81
+ rest = trimmed.slice(2);
82
+ state = true;
83
+ continue;
84
+ }
85
+ // Anything else is code — including a bare `*`-leading line with NO open
86
+ // block observed: generator signatures (`*items() {`) and operator
87
+ // continuations (`* b`) start with `*` and are real code.
88
+ return { code: true, state };
89
+ }
90
+ }
91
+
92
+ // Content-level significance filter (#1137). A JS/TS file change is "comment-only"
93
+ // when EVERY added/removed line in its patch is blank OR a comment line. Such
94
+ // changes (JSDoc/inline-comment tweaks) must NOT reopen a Copilot round past the
95
+ // cap. Comment rules:
96
+ // - a line starting with `//` is a comment;
97
+ // - `/*` opens block-comment state, `*/` closes it; state is tracked across
98
+ // ALL patch lines (changed AND context) and RESET at every `@@` hunk header
99
+ // — a block opened before the hunk is unobservable, so a `*`-leading line
100
+ // with no observed opener in the same hunk is treated as CODE (conservative:
101
+ // bare `*` counts as comment ONLY inside an observed open `/* ... */` block).
102
+ // Conservative by construction — every ambiguous case returns false (= NOT
103
+ // classifiable as comment-only; the file remains subject to the existing
104
+ // size/count thresholds, it is NOT auto-escalated to significant):
105
+ // - patch missing (binary / oversized to diff) → false
106
+ // - extension we cannot classify (non-JS/TS code) → false
107
+ // - a changed line mixing code + comment → false
108
+ // - a patch with no parseable added/removed lines → false
109
+ // Input contract: GitHub compare `files[].patch` starts at `@@` and never
110
+ // contains `---`/`+++` file headers, so `+`/`-` prefixes are always content
111
+ // (`+++counter;` is the real added line `++counter;`, not a header).
112
+ // Note: line-scanner heuristic, upgrade to a real tokenizer only if string-literal misreads ever bite.
113
+ export function isCommentOnlyFileChange(file) {
114
+ const filename = typeof file?.filename === "string" ? file.filename : "";
115
+ const lower = filename.toLowerCase();
116
+ if (!JS_TS_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
117
+ // Not a classifiable code file (or a doc path handled elsewhere) → treat as code.
118
+ return false;
119
+ }
120
+ const patch = file?.patch;
121
+ if (typeof patch !== "string" || patch.length === 0) {
122
+ // No patch (binary / too large) → cannot prove trivial → treat as code.
123
+ return false;
124
+ }
125
+ let sawChangedLine = false;
126
+ let inBlock = false;
127
+ for (const line of patch.split("\n")) {
128
+ if (line.startsWith("@@")) {
129
+ inBlock = false; // hunk boundary: the gap is unobserved → reset state
130
+ continue;
131
+ }
132
+ if (line.startsWith("\\")) continue; // ""
133
+ const marker = line[0];
134
+ const content = line.slice(1);
135
+ if (marker === "+" || marker === "-") {
136
+ sawChangedLine = true;
137
+ const { code, state } = classifyLine(content, inBlock);
138
+ if (code) return false;
139
+ inBlock = state;
140
+ } else {
141
+ // Context line: never classified as changed content, but it advances
142
+ // block-comment state so an opener/closer visible in the hunk is honored.
143
+ inBlock = classifyLine(content, inBlock).state;
144
+ }
145
+ }
146
+ // A patch with zero parseable changed lines is ambiguous → treat as code.
147
+ return sawChangedLine;
148
+ }
149
+
150
+ export async function detectPostConvergenceSignificantChange(
151
+ { repo, pr, currentHeadSha, reviews, changedFiles, roundCapReached, regularCopilotRounds },
152
+ { env = process.env, ghCommand = "gh" } = {},
153
+ ) {
154
+ if (!roundCapReached || !regularCopilotRounds) {
155
+ return false;
156
+ }
157
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
158
+ return false;
159
+ }
160
+ if (!currentHeadSha) {
161
+ // No usable current head → the compare call would be doomed; fail closed
162
+ // without issuing a wasted gh API request.
163
+ return false;
164
+ }
165
+ const lastReviewedHeadSha = getLatestSubmittedCopilotReviewHeadSha(reviews);
166
+ if (!lastReviewedHeadSha || lastReviewedHeadSha === currentHeadSha) {
167
+ return false;
168
+ }
169
+ const compareResult = await runChild(
170
+ ghCommand,
171
+ ["api", `repos/${repo}/compare/${lastReviewedHeadSha}...${currentHeadSha}`],
172
+ env,
173
+ );
174
+ if (compareResult.code !== 0) {
175
+ return false;
176
+ }
177
+ let payload;
178
+ try {
179
+ payload = parseJsonText(compareResult.stdout, { label: "gh compare" });
180
+ } catch {
181
+ return false;
182
+ }
183
+ const rawFiles = Array.isArray(payload?.files) ? payload.files : [];
184
+ if (rawFiles.length === 0) {
185
+ return false;
186
+ }
187
+ // Content-aware filter (#1137): drop comment/JSDoc-only JS/TS changes BEFORE the
188
+ // existing size/count thresholds so trivial comment fixes no longer reopen a
189
+ // Copilot round past the cap. Doc paths and un-classifiable files fall through
190
+ // unchanged (isCommentOnlyFileChange returns false for them), preserving the
191
+ // prior threshold behavior for everything else.
192
+ const files = rawFiles.filter((file) => !isCommentOnlyFileChange(file));
193
+ if (files.length === 0) {
194
+ return false;
195
+ }
196
+ const hasNonDocChanges = files.some((file) => !isTrivialDocumentationOnlyPath(file?.filename));
197
+ if (!hasNonDocChanges) {
198
+ return false;
199
+ }
200
+ const totalChangedLines = files.reduce((sum, file) => {
201
+ const changes = Number(file?.changes);
202
+ if (Number.isFinite(changes) && changes > 0) {
203
+ return sum + changes;
204
+ }
205
+ const additions = Number(file?.additions);
206
+ const deletions = Number(file?.deletions);
207
+ const fallback = (Number.isFinite(additions) ? additions : 0) + (Number.isFinite(deletions) ? deletions : 0);
208
+ return sum + (fallback > 0 ? fallback : 0);
209
+ }, 0);
210
+ return totalChangedLines >= 20 || files.length >= 2;
211
+ }
@@ -610,3 +610,73 @@ export async function ensureAsyncRunnerOwnership({
610
610
  }
611
611
  return claimRunnerOwnership({ repo, pr, runId, cwd, mode: "claim" });
612
612
  }
613
+ /**
614
+ * Env-aware, best-effort release for the run-completion/stop path (issue #1109).
615
+ *
616
+ * Mirrors {@link ensureAsyncRunnerOwnership}: a no-op when no async run id is
617
+ * present, so it is harness-agnostic — Claude Code with no DEVLOOPS_RUN_ID yields
618
+ * `skipped_no_async_run_id` and never touches the coordination file.
619
+ *
620
+ * Non-fatal by contract: a release conflict (the claim is owned by another run,
621
+ * or no record remains) is swallowed into an `ok:true` result so a failed release
622
+ * can never block a stop/checkpoint. Fail-closed competitor semantics are
623
+ * preserved — {@link releaseRunnerOwnership} only clears a claim THIS run owns, so
624
+ * a genuinely active competing run's claim is left intact.
625
+ *
626
+ * Resolves to one of `skipped_no_async_run_id` (no run id present),
627
+ * `release_skipped` (release conflict swallowed), `release_error` (release threw),
628
+ * or the passthrough `releaseRunnerOwnership` result — `released` when this run's
629
+ * claim was cleared, or `release_noop` when the target has no coordination record.
630
+ */
631
+ export async function releaseAsyncRunnerOwnership({
632
+ repo,
633
+ pr,
634
+ env = process.env,
635
+ cwd = process.cwd(),
636
+ } = {}) {
637
+ const filePath = defaultRunnerCoordinationFilePathForTarget({ repo, pr }, cwd);
638
+ const runId = normalizeRunId(resolveRunId(env));
639
+ if (runId === null) {
640
+ return {
641
+ ok: true,
642
+ status: "skipped_no_async_run_id",
643
+ repo: normalizeRepoSlug(repo),
644
+ pr: normalizePr(pr),
645
+ runId: null,
646
+ activeRun: null,
647
+ exitSignals: [],
648
+ filePath,
649
+ };
650
+ }
651
+ try {
652
+ const released = await releaseRunnerOwnership({ repo, pr, runId, cwd });
653
+ if (released.ok) {
654
+ return released;
655
+ }
656
+ return {
657
+ ok: true,
658
+ status: "release_skipped",
659
+ repo: released.repo ?? normalizeRepoSlug(repo),
660
+ pr: released.pr ?? normalizePr(pr),
661
+ runId,
662
+ activeRun: released.activeRun ?? null,
663
+ exitSignals: released.exitSignals ?? [],
664
+ skippedReason: released.error,
665
+ message: released.message,
666
+ filePath: released.filePath ?? filePath,
667
+ };
668
+ } catch (error) {
669
+ return {
670
+ ok: true,
671
+ status: "release_error",
672
+ repo: normalizeRepoSlug(repo),
673
+ pr: normalizePr(pr),
674
+ runId,
675
+ activeRun: null,
676
+ exitSignals: [],
677
+ skippedReason: "release_threw",
678
+ message: error instanceof Error ? error.message : String(error),
679
+ filePath,
680
+ };
681
+ }
682
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared worktree-relative repo-root / ledger-checkout resolver (issue #1052,
3
+ * folds #1019 and #1050). ONE shared resolver: derive repo root from the
4
+ * checkout under operation (git-toplevel, not ambient cwd) for config reads,
5
+ * and enumerate ALL checkouts (main + every worktree) for ledger reads so a
6
+ * ledger written in any worktree is visible regardless of which checkout runs
7
+ * the check.
8
+ *
9
+ * #1050: the pre-PR-worktree flow means the session cwd is a DIFFERENT checkout
10
+ * than the PR worktree. Ledgers get written cwd-relative in the PR worktree but
11
+ * read cwd-relative from the session checkout, so they diverge and clean gates
12
+ * false-block on "missing pre-merge gate evidence". resolveLedgerCheckouts
13
+ * enumerates every checkout so a ledger written in one is found from any.
14
+ *
15
+ * #1019: .devloops is read at EXACTLY <repoRoot>/.devloops with no upward walk.
16
+ * Reading it from process.cwd() (a subdir or sibling checkout) silently falls
17
+ * back to defaults (maxCopilotRounds -> 5). resolveRepoRoot derives the root
18
+ * from the checkout's git-toplevel so config resolves correctly.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { parseAllWorktreePaths, parseMainWorktreePath } from "@dev-loops/core/loop/worktree-guard";
22
+
23
+ export function resolveRepoRoot(cwd, { gitCommand = "git" } = {}) {
24
+ try {
25
+ return execFileSync(gitCommand, ["rev-parse", "--show-toplevel"], {
26
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
27
+ }).trim() || cwd;
28
+ } catch {
29
+ // git failed or cwd is not inside a git repo (or git is unavailable) -> fall back to cwd.
30
+ return cwd;
31
+ }
32
+ }
33
+
34
+ export function resolveLedgerCheckouts(cwd, { gitCommand = "git" } = {}) {
35
+ const roots = [];
36
+ const add = (p) => { if (typeof p === "string" && p.length > 0 && !roots.includes(p)) roots.push(p); };
37
+ add(resolveRepoRoot(cwd, { gitCommand }));
38
+ try {
39
+ const listing = execFileSync(gitCommand, ["worktree", "list"], {
40
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
41
+ });
42
+ add(parseMainWorktreePath(listing));
43
+ for (const p of parseAllWorktreePaths(listing)) add(p);
44
+ } catch { /* git failed, not inside a git repo, or git unavailable: cwd-toplevel is all we have */ }
45
+ if (roots.length === 0) add(cwd);
46
+ return roots;
47
+ }
@@ -12,7 +12,7 @@
12
12
  * dev-loops loop build-envelope --input resolver-output.json
13
13
  *
14
14
  * Or via npx:
15
- * npx dev-loops loop build-envelope --input resolver-output.json
15
+ * npx dev-loops@<version> loop build-envelope --input resolver-output.json
16
16
  */
17
17
  import { readFile } from "node:fs/promises";
18
18
  import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
@@ -23,6 +23,8 @@ import { buildDevLoopHandoffEnvelope } from "@dev-loops/core/loop/handoff-envelo
23
23
  import { loadDevLoopConfig } from "@dev-loops/core/config";
24
24
  import { createPiAdapter } from "@dev-loops/core/harness";
25
25
  import { parseArgs } from "node:util";
26
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
27
+ import { SANCTIONED_COMMANDS } from "./sanctioned-commands.mjs";
26
28
 
27
29
  const USAGE = `Usage: build-handoff-envelope.mjs --input <path>
28
30
  Build a deterministic handoff envelope from startup resolver output and settings.
@@ -30,7 +32,7 @@ Required:
30
32
  --input <path> Path to resolver output JSON (from resolve-dev-loop-startup.mjs)
31
33
  Optional:
32
34
  --gate-state <json> Gate state JSON string
33
- { currentHeadSha?, ciStatus?, unresolvedThreadCount?, copilotRoundCount? }
35
+ { currentHeadSha?, ciStatus?, unresolvedThreadCount?, copilotRoundCount?, retrospectiveFindings? }
34
36
  --overrides <json> Overrides JSON string
35
37
  { mergeAuthorized?, preferLocal?, scopeConstraint?, customStopAt? }
36
38
  --repo <owner/name> Repository slug override (falls back to bundle.repoSlug or bundle.repo)
@@ -41,9 +43,11 @@ Error output (stderr, JSON):
41
43
  { "ok": false, "error": "...", "usage": "..." }
42
44
  Runtime failures:
43
45
  { "ok": false, "error": "..." }
46
+ ${JQ_OUTPUT_USAGE}
44
47
  Exit codes:
45
48
  0 Success
46
- 1 Argument error or runtime failure`.trim();
49
+ 1 Argument error or runtime failure
50
+ 2 Invalid --jq filter`.trim();
47
51
 
48
52
  const parseError = buildParseError(USAGE);
49
53
 
@@ -73,6 +77,7 @@ export function parseBuildHandoffEnvelopeCliArgs(argv) {
73
77
  "gate-state": { type: "string" },
74
78
  overrides: { type: "string" },
75
79
  repo: { type: "string" },
80
+ ...JQ_OUTPUT_PARSE_OPTIONS,
76
81
  },
77
82
  allowPositionals: true,
78
83
  strict: false,
@@ -105,6 +110,7 @@ export function parseBuildHandoffEnvelopeCliArgs(argv) {
105
110
  options.repo = requireTokenValue(token, parseError);
106
111
  continue;
107
112
  }
113
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
108
114
  throw parseError(`Unknown argument: ${token.rawName}`);
109
115
  }
110
116
 
@@ -143,6 +149,10 @@ export async function buildHandoffEnvelopeCli(
143
149
  // Build options for envelope builder
144
150
  const envelopeOptions = {};
145
151
 
152
+ // Carry the canonical sanctioned operation → wrapper command map (issue #1081)
153
+ // into every emitted envelope by DEFAULT so spawned subagents never re-derive it.
154
+ envelopeOptions.sanctionedCommands = SANCTIONED_COMMANDS;
155
+
146
156
  // Repo slug: explicit --repo, then resolver output bundle, then git remote
147
157
  const bundleSlug = resolverOutput?.bundle?.repoSlug ?? resolverOutput?.bundle?.repo ?? null;
148
158
  const repoSlug = options.repo ?? bundleSlug ?? detectRepoSlug(repoRoot);
@@ -186,7 +196,7 @@ export async function runCli(
186
196
 
187
197
  try {
188
198
  const envelope = await buildHandoffEnvelopeCli(options, { adapter });
189
- stdout.write(`${JSON.stringify(envelope)}\n`);
199
+ process.exitCode = emitResult(envelope, { jq: options.jq, silent: options.silent, stdout, stderr });
190
200
  } catch (err) {
191
201
  const msg = formatCliError(err);
192
202
  stderr.write(`${msg}\n`);
@@ -54,6 +54,7 @@ import { readFileSync } from "node:fs";
54
54
  import process from "node:process";
55
55
  import { parseArgs } from "node:util";
56
56
  import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
57
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
57
58
 
58
59
  const USAGE = `Usage: node scripts/loop/check-retro-tooling.mjs [--transcript <path>] [--json]
59
60
 
@@ -65,10 +66,13 @@ Options:
65
66
  --transcript <path> File of newline-delimited commands (default: read stdin)
66
67
  --json Emit machine-readable JSON (default: human summary)
67
68
 
69
+ ${JQ_OUTPUT_USAGE}
70
+ (--jq/--silent only apply together with --json; the default text output is unaffected.)
71
+
68
72
  Exit codes:
69
73
  0 No violations
70
74
  1 One or more violations found
71
- 2 Argument/runtime error`;
75
+ 2 Argument/runtime error, or invalid --jq filter`;
72
76
 
73
77
  /**
74
78
  * Write-ops that currently have no internal dev-loops wrapper. Recorded
@@ -81,6 +85,10 @@ const ALLOWED_WRITE_OPS = Object.freeze([
81
85
  /^gh\s+pr\s+ready\b/,
82
86
  /^gh\s+issue\s+create\b/,
83
87
  /^gh\s+issue\s+edit\b/,
88
+ // `gh label create` HAS a wrapper (scripts/github/create-label.mjs); this
89
+ // entry is belt-and-suspenders so a bare invocation (e.g. surfaced from the
90
+ // wrapper's own subprocess) classifies as an allowed write-op, not a violation.
91
+ /^gh\s+label\s+create\b/,
84
92
  ]);
85
93
 
86
94
  /** Split a command line into top-level segments on &&, ||, |, ;. */
@@ -190,6 +198,7 @@ function parseCliArgs(argv) {
190
198
  transcript: { type: "string" },
191
199
  json: { type: "boolean" },
192
200
  help: { type: "boolean", short: "h" },
201
+ ...JQ_OUTPUT_PARSE_OPTIONS,
193
202
  },
194
203
  strict: true,
195
204
  allowPositionals: false,
@@ -221,8 +230,10 @@ async function run(argv, { stdout, stderr }) {
221
230
  const { violations, allowedWriteOps, internalToolingOnly } = analyzeTranscript(transcript);
222
231
 
223
232
  if (values.json) {
224
- stdout.write(`${JSON.stringify({ ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps })}\n`);
225
- } else if (internalToolingOnly) {
233
+ const payload = { ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps };
234
+ return emitResult(payload, { jq: values.jq, silent: values.silent, stdout, stderr });
235
+ }
236
+ if (internalToolingOnly) {
226
237
  stdout.write(`internalToolingOnly: true — no agent-level raw gh/python/node -e calls found.\n`);
227
238
  if (allowedWriteOps.length > 0) {
228
239
  stdout.write(`Allowed write-ops (no wrapper yet): ${allowedWriteOps.length}\n`);
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import process from "node:process";
5
5
  import { parseArgs } from "node:util";
6
6
  import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
8
 
8
9
  const CHECKPOINT_FILE = ".pi/dev-loop-retrospective-checkpoint.json";
9
10
  const ALLOWED_STATES = new Set(["required", "complete", "skipped", "none", "missing"]);
@@ -17,7 +18,9 @@ Required:
17
18
 
18
19
  Optional:
19
20
  --notes <text> Required when --state is complete
20
- --reason <text> Required when --state is skipped`;
21
+ --reason <text> Required when --state is skipped
22
+
23
+ ${JQ_OUTPUT_USAGE}`;
21
24
 
22
25
  function parseError(message) {
23
26
  return Object.assign(new Error(message), { usage: USAGE });
@@ -43,6 +46,7 @@ function parseCliArgs(argv) {
43
46
  notes: { type: "string" },
44
47
  reason: { type: "string" },
45
48
  help: { type: "boolean", short: "h" },
49
+ ...JQ_OUTPUT_PARSE_OPTIONS,
46
50
  },
47
51
  strict: true,
48
52
  allowPositionals: false,
@@ -67,7 +71,7 @@ function parseCliArgs(argv) {
67
71
  throw parseError('state "skipped" requires --reason');
68
72
  }
69
73
 
70
- return { state, notes: values.notes ?? null, reason: values.reason ?? null };
74
+ return { state, notes: values.notes ?? null, reason: values.reason ?? null, jq: values.jq, silent: values.silent === true };
71
75
  }
72
76
 
73
77
  async function run(argv) {
@@ -82,8 +86,7 @@ async function run(argv) {
82
86
  const checkpointPath = path.join(process.cwd(), CHECKPOINT_FILE);
83
87
  await mkdir(path.dirname(checkpointPath), { recursive: true });
84
88
  await writeFile(checkpointPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
85
- process.stdout.write(JSON.stringify({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }) + "\n");
86
- return 0;
89
+ return emitResult({ ok: true, path: CHECKPOINT_FILE, checkpoint: payload }, { jq: parsed.jq, silent: parsed.silent });
87
90
  }
88
91
 
89
92
  if (isDirectCliRun(import.meta.url)) {
@@ -21,6 +21,7 @@ import { requireTokenValue } from "../_cli-primitives.mjs";
21
21
  import { parseArgs } from "node:util";
22
22
  import { resolveWorktreePath, WORKTREE_NAMESPACE } from "@dev-loops/core/loop/handoff-envelope";
23
23
  import { canonicalize } from "./_worktree-path.mjs";
24
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
24
25
 
25
26
  const USAGE = `Usage:
26
27
  cleanup-worktree.mjs --repo-root <p> (--issue <n> | --pr <n> | --path <p>)
@@ -37,7 +38,9 @@ Optional:
37
38
  Output (stdout, JSON):
38
39
  { "ok": bool, "removed": <path>|null, "reason": "<why>" }
39
40
  ok is true on success/skip (incl. fail-soft git errors); false ONLY when the
40
- path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).`.trim();
41
+ path is refused for being outside ${WORKTREE_NAMESPACE}/ (removed: null).
42
+
43
+ ${JQ_OUTPUT_USAGE}`.trim();
41
44
 
42
45
  const parseError = buildParseError(USAGE);
43
46
 
@@ -57,6 +60,7 @@ export function parseCleanupWorktreeCliArgs(argv) {
57
60
  issue: { type: "string" },
58
61
  pr: { type: "string" },
59
62
  path: { type: "string" },
63
+ ...JQ_OUTPUT_PARSE_OPTIONS,
60
64
  },
61
65
  allowPositionals: true,
62
66
  strict: false,
@@ -85,6 +89,7 @@ export function parseCleanupWorktreeCliArgs(argv) {
85
89
  options.path = requireTokenValue(token, parseError, { flagPattern: /^-/u });
86
90
  continue;
87
91
  }
92
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
88
93
  throw parseError(`Unknown argument: ${token.rawName}`);
89
94
  }
90
95
  if (options.help) return options;
@@ -155,14 +160,18 @@ export function cleanupWorktree({ repoRoot, issue, pr, path: explicitPath }, { g
155
160
  return { ok: true, removed: target, reason: "removed" };
156
161
  }
157
162
 
158
- export function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
163
+ export function runCli(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr } = {}) {
159
164
  const options = parseCleanupWorktreeCliArgs(argv);
160
165
  if (options.help) {
161
166
  stdout.write(`${USAGE}\n`);
162
167
  return;
163
168
  }
164
169
  const result = cleanupWorktree(options);
165
- stdout.write(`${JSON.stringify(result)}\n`);
170
+ // FAIL-SOFT contract (see file header): a parsed command always exits 0 on the
171
+ // non-jq path, even when `ok:false` (path refused) — it must never break a
172
+ // merge-completion caller. Force ok:true here so the default path keeps that
173
+ // contract; --jq/--silent can still read the real `ok` field explicitly.
174
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: true });
166
175
  }
167
176
 
168
177
  if (isDirectCliRun(import.meta.url)) {
@@ -4,7 +4,7 @@ import { access, open, readFile, readdir } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { requireTokenValue } from "../_cli-primitives.mjs";
7
- import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
7
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText, readJsonIfExists } from "../_core-helpers.mjs";
8
8
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
9
9
  import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
10
10
  import {
@@ -15,6 +15,7 @@ import {
15
15
  import { interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
16
16
  import { listOpenPrs } from "./_loop-pr-aggregation.mjs";
17
17
  import { parseArgs } from "node:util";
18
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
18
19
  const USAGE = `Usage: conductor-monitor.mjs --repo <owner/name> [--auto-resume]
19
20
  Aggregate Copilot-loop status across all open PRs in one repo.
20
21
  Required:
@@ -78,9 +79,11 @@ Error output (stderr, JSON):
78
79
  { "ok": false, "error": "...", "usage": "..." }
79
80
  gh/runtime failures:
80
81
  { "ok": false, "error": "..." }
82
+ ${JQ_OUTPUT_USAGE}
81
83
  Exit codes:
82
84
  0 Success
83
- 1 Argument error, gh failure, or indeterminate PR status`.trim();
85
+ 1 Argument error, gh failure, or indeterminate PR status
86
+ 2 Invalid --jq filter`.trim();
84
87
  const parseError = buildParseError(USAGE);
85
88
  const DEFAULT_SESSION_ROOT = path.join(os.homedir(), ".pi", "agent", "sessions");
86
89
  const RUN_STATE = {
@@ -125,6 +128,7 @@ function parseCliArgs(argv) {
125
128
  help: { type: "boolean", short: "h" },
126
129
  repo: { type: "string" },
127
130
  "auto-resume": { type: "boolean" },
131
+ ...JQ_OUTPUT_PARSE_OPTIONS,
128
132
  },
129
133
  allowPositionals: true,
130
134
  strict: false,
@@ -149,6 +153,7 @@ function parseCliArgs(argv) {
149
153
  options.autoResume = true;
150
154
  continue;
151
155
  }
156
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
152
157
  throw parseError(`Unknown argument: ${token.rawName}`);
153
158
  }
154
159
  if (options.repo === undefined) {
@@ -306,17 +311,6 @@ async function readFirstLineIfExists(filePath, chunkSize = 4096) {
306
311
  await handle?.close().catch(() => {});
307
312
  }
308
313
  }
309
- async function readJsonIfExists(filePath) {
310
- const text = await readTextIfExists(filePath);
311
- if (text === null) {
312
- return null;
313
- }
314
- try {
315
- return parseJsonText(text);
316
- } catch {
317
- return null;
318
- }
319
- }
320
314
  function normalizeRunState(value) {
321
315
  const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
322
316
  switch (normalized) {
@@ -455,7 +449,7 @@ async function scanSessionArtifactRoot(artifactsDir, records) {
455
449
  const record = records.get(key) ?? createRunRecord(runId, childIndex);
456
450
  const filePath = path.join(artifactsDir, entry.name);
457
451
  if (entry.name.endsWith("_meta.json")) {
458
- const meta = await readJsonIfExists(filePath);
452
+ const meta = await readJsonIfExists(filePath).catch(() => null);
459
453
  if (meta && typeof meta === "object") {
460
454
  records.set(key, mergeRunRecord(record, {
461
455
  agent: typeof meta.agent === "string" ? meta.agent : (record.agent ?? agent),
@@ -540,7 +534,7 @@ async function scanAsyncRunRoot(asyncRoot, records) {
540
534
  const asyncDir = path.join(asyncRoot, runDirEntry.name);
541
535
  const statusPath = path.join(asyncDir, "status.json");
542
536
  const eventsPath = path.join(asyncDir, "events.jsonl");
543
- const status = await readJsonIfExists(statusPath);
537
+ const status = await readJsonIfExists(statusPath).catch(() => null);
544
538
  if (!status || typeof status !== "object") {
545
539
  continue;
546
540
  }
@@ -632,7 +626,7 @@ async function scanAsyncResultRoot(resultsRoot, records) {
632
626
  }
633
627
  const filePath = path.join(resultsRoot, entry.name);
634
628
  if (entry.name.endsWith(".json")) {
635
- const result = await readJsonIfExists(filePath);
629
+ const result = await readJsonIfExists(filePath).catch(() => null);
636
630
  if (!result || typeof result !== "object") {
637
631
  continue;
638
632
  }
@@ -1807,7 +1801,7 @@ export async function runConductorMonitor(
1807
1801
  }
1808
1802
  export async function runCli(
1809
1803
  argv = process.argv.slice(2),
1810
- { stdout = process.stdout, env = process.env, ghCommand = "gh", cwd = process.cwd() } = {},
1804
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", cwd = process.cwd() } = {},
1811
1805
  ) {
1812
1806
  const options = parseCliArgs(argv);
1813
1807
  if (options.help) {
@@ -1819,7 +1813,7 @@ export async function runCli(
1819
1813
  ghCommand,
1820
1814
  repoRoot: cwd,
1821
1815
  });
1822
- stdout.write(`${JSON.stringify(result)}\n`);
1816
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
1823
1817
  }
1824
1818
  if (isDirectCliRun(import.meta.url)) {
1825
1819
  runCli().catch((error) => {