dev-loops 0.6.0 → 0.7.2

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 (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -4,6 +4,9 @@ import path from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
6
6
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
+ import { checkFanoutAngleCoverage, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
9
+ import { loadDevLoopConfig, resolveGateAngleContract, resolveRejectForeignAngles } from "@dev-loops/core/config";
7
10
  const USAGE = `Usage: write-gate-findings-log.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --verdict <clean|findings_present|blocked> --findings <json> [--tmp-root <path>]
8
11
  Write a durable <gate>-<headSha>.json log under deterministic tmp/ paths.
9
12
  Required:
@@ -14,7 +17,11 @@ Required:
14
17
  --verdict <clean|findings_present|blocked>
15
18
  --findings <json> JSON array of finding objects with severity, disposition, angle, and summary
16
19
  Optional:
20
+ --provenance <json> Fan-out provenance object: { distinctReviewers: <int>, perAngle: [{ angle, reviewer?, dispatchId?, model? }] }
21
+ distinctReviewers must be <= the distinct reviewers recorded in perAngle (perAngle non-empty when distinctReviewers > 0)
17
22
  --tmp-root <path> Root tmp directory (default: tmp/)
23
+
24
+ ${JQ_OUTPUT_USAGE}
18
25
  `.trim();
19
26
  function parseError(message) {
20
27
  return Object.assign(new Error(message), { usage: USAGE });
@@ -24,6 +31,7 @@ function normalizeGate(value) {
24
31
  const normalized = String(value).trim().toLowerCase();
25
32
  return gates.has(normalized) ? normalized : null;
26
33
  }
34
+ const GATE_CONFIG_KEY = { draft_gate: "draft", pre_approval_gate: "preApproval" };
27
35
  function normalizeVerdict(value) {
28
36
  const verdicts = new Set(["clean", "findings_present", "blocked"]);
29
37
  const normalized = String(value).trim().toLowerCase();
@@ -89,6 +97,95 @@ function parseFindingsJson(raw) {
89
97
  return entry;
90
98
  });
91
99
  }
100
+ /**
101
+ * Validate + normalize the fan-out provenance object. Records how many distinct
102
+ * reviewer agents were dispatched (distinctReviewers) and per-angle dispatch
103
+ * provenance (perAngle). Rejects MALFORMED or self-INCONSISTENT provenance (bad
104
+ * shape, or a distinctReviewers claim not backed by recorded dispatch entries).
105
+ * This raises the bar; it does NOT make provenance un-forgeable — a determined
106
+ * single agent can still write an internally-consistent blob. Un-forgeable
107
+ * recording is the Pi-harness bridge (subagent tool at child depth). Returns the
108
+ * normalized object.
109
+ */
110
+ export function parseProvenanceJson(raw) {
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ } catch {
115
+ throw parseError("--provenance must be valid JSON");
116
+ }
117
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
118
+ throw parseError("--provenance must be a JSON object");
119
+ }
120
+ if (!Number.isInteger(parsed.distinctReviewers) || parsed.distinctReviewers < 0) {
121
+ throw parseError("--provenance.distinctReviewers must be a non-negative integer");
122
+ }
123
+ if (!Array.isArray(parsed.perAngle)) {
124
+ throw parseError("--provenance.perAngle must be an array");
125
+ }
126
+ const perAngle = parsed.perAngle.map((a, i) => {
127
+ if (!a || typeof a !== "object" || Array.isArray(a)) {
128
+ throw parseError(`--provenance.perAngle[${i}] must be an object`);
129
+ }
130
+ if (typeof a.angle !== "string" || a.angle.trim().length === 0) {
131
+ throw parseError(`--provenance.perAngle[${i}].angle is required`);
132
+ }
133
+ const entry = { angle: a.angle.trim() };
134
+ for (const key of ["reviewer", "dispatchId", "model"]) {
135
+ if (key in a) {
136
+ if (typeof a[key] !== "string" || a[key].trim().length === 0) {
137
+ throw parseError(`--provenance.perAngle[${i}].${key} must be a non-empty string`);
138
+ }
139
+ entry[key] = a[key].trim();
140
+ }
141
+ }
142
+ return entry;
143
+ });
144
+ const normalized = { distinctReviewers: parsed.distinctReviewers, perAngle };
145
+ // Internal-consistency gate: a distinctReviewers claim must be backed by that
146
+ // many distinct recorded reviewer identities (closes the {n, perAngle:[]} loophole).
147
+ const consistencyError = provenanceConsistencyError(normalized);
148
+ if (consistencyError) {
149
+ throw parseError(`--${consistencyError}`);
150
+ }
151
+ return normalized;
152
+ }
153
+ /**
154
+ * Validate recorded provenance.perAngle against the gate's configured angle
155
+ * contract (mandatoryAngles + pool, resolved from .devloops/defaults). A
156
+ * missing mandatory angle always fails the write. A foreign (out-of-pool)
157
+ * angle fails the write unless `gates.rejectForeignAngles: false`, in which
158
+ * case it is returned as a warning instead. Throws a `parseError`-shaped Error
159
+ * (matching this module's other validation failures) on a fail-closed
160
+ * rejection.
161
+ *
162
+ * @param {{ perAngle: Array<{ angle: string }> }} provenance
163
+ * @param {"draft_gate"|"pre_approval_gate"} gate
164
+ * @param {{ repoRoot?: string }} [options]
165
+ * @returns {Promise<{ warning: string|null }>}
166
+ */
167
+ export async function checkProvenanceAngleCoverage(provenance, gate, { repoRoot = process.cwd() } = {}) {
168
+ const { config } = await loadDevLoopConfig({ repoRoot });
169
+ const gateKey = GATE_CONFIG_KEY[gate];
170
+ const { mandatoryAngles, pool } = resolveGateAngleContract(config, gateKey);
171
+ const { missingMandatory, foreignAngles } = checkFanoutAngleCoverage(provenance.perAngle, {
172
+ mandatoryAngles,
173
+ pool,
174
+ });
175
+ if (missingMandatory.length > 0) {
176
+ throw parseError(
177
+ `--provenance.perAngle is missing mandatory angle(s) for ${gate}: ${missingMandatory.join(", ")} (configured in gates.${gateKey}.mandatoryAngles)`,
178
+ );
179
+ }
180
+ if (foreignAngles.length > 0) {
181
+ const message = `--provenance.perAngle names angle(s) outside the configured pool for ${gate}: ${foreignAngles.join(", ")}`;
182
+ if (resolveRejectForeignAngles(config)) {
183
+ throw parseError(`${message} (add them to gates.${gateKey}.angles, or set gates.rejectForeignAngles: false to warn instead of fail)`);
184
+ }
185
+ return { warning: `${message} (gates.rejectForeignAngles is false; recorded as a warning)` };
186
+ }
187
+ return { warning: null };
188
+ }
92
189
  export function parseWriteGateFindingsLogCliArgs(argv) {
93
190
  const { tokens } = parseArgs({
94
191
  args: [...argv],
@@ -100,7 +197,9 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
100
197
  "head-sha": { type: "string" },
101
198
  verdict: { type: "string" },
102
199
  findings: { type: "string" },
200
+ provenance: { type: "string" },
103
201
  "tmp-root": { type: "string" },
202
+ ...JQ_OUTPUT_PARSE_OPTIONS,
104
203
  },
105
204
  allowPositionals: true,
106
205
  strict: false,
@@ -155,10 +254,15 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
155
254
  options.findings = requireTokenValue(token, parseError);
156
255
  continue;
157
256
  }
257
+ if (token.name === "provenance") {
258
+ options.provenance = requireTokenValue(token, parseError);
259
+ continue;
260
+ }
158
261
  if (token.name === "tmp-root") {
159
262
  options.tmpRoot = requireTokenValue(token, parseError).trim();
160
263
  continue;
161
264
  }
265
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
162
266
  throw parseError(`Unknown argument: ${token.rawName}`);
163
267
  }
164
268
  const missing = ["repo", "pr", "gate", "headSha", "verdict", "findings"]
@@ -183,6 +287,13 @@ export function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
183
287
  }
184
288
  export async function writeGateFindingsLog(options, { repoRoot = process.cwd() } = {}) {
185
289
  const findings = parseFindingsJson(options.findings);
290
+ const provenance = options.provenance === undefined ? undefined : parseProvenanceJson(options.provenance);
291
+ // Angle-coverage enforcement (fail-closed on missing mandatory angles / foreign
292
+ // angles) only applies when provenance is actually recorded — provenance
293
+ // remains optional and additive (inline_single_agent writes never carry it).
294
+ const angleCoverage = provenance !== undefined
295
+ ? await checkProvenanceAngleCoverage(provenance, options.gate, { repoRoot })
296
+ : { warning: null };
186
297
  const logPath = buildLogPath({
187
298
  repo: options.repo,
188
299
  pr: options.pr,
@@ -200,9 +311,18 @@ export async function writeGateFindingsLog(options, { repoRoot = process.cwd() }
200
311
  loggedAt: new Date().toISOString(),
201
312
  findings,
202
313
  };
314
+ // Provenance is optional and additive: when absent the ledger writes exactly
315
+ // as before (no provenance key), preserving byte-identical output for the
316
+ // default / Claude-Code path. When present it records fan-out provenance for
317
+ // gates.requireFanoutProvenance enforcement.
318
+ if (provenance !== undefined) {
319
+ log.provenance = provenance;
320
+ }
203
321
  await mkdir(path.dirname(fullPath), { recursive: true });
204
322
  await writeFile(fullPath, JSON.stringify(log, null, 2) + "\n", "utf8");
205
- return { ok: true, path: logPath, log };
323
+ return angleCoverage.warning
324
+ ? { ok: true, path: logPath, log, warning: angleCoverage.warning }
325
+ : { ok: true, path: logPath, log };
206
326
  }
207
327
  async function main() {
208
328
  let options;
@@ -219,7 +339,13 @@ async function main() {
219
339
  }
220
340
  try {
221
341
  const result = await writeGateFindingsLog(options);
222
- process.stdout.write(JSON.stringify(result) + "\n");
342
+ // rejectForeignAngles: false is WARNING mode, not silence — surface the
343
+ // angle-coverage warning on stderr too (the JSON result carries it as
344
+ // `warning`). Suppressed under --silent.
345
+ if (result.warning && !options.silent) {
346
+ process.stderr.write(`WARNING: ${result.warning}\n`);
347
+ }
348
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
223
349
  } catch (error) {
224
350
  process.stderr.write(JSON.stringify({
225
351
  ok: false,
@@ -256,6 +256,24 @@ export const JQ_OUTPUT_USAGE = `Output filtering:
256
256
  --silent, -s Suppress stdout; map result to exit code only
257
257
  (0 = pass/truthy, 1 = fail/falsy). Composes with --jq as a predicate.`;
258
258
 
259
+ // Shared token-matcher for scripts that hand-roll a `parseArgs({ tokens: true })`
260
+ // loop (rather than reading `values.jq`/`values.silent` off a strict parse).
261
+ // Call once per `option` token: `if (matchJqOutputToken(token, options)) continue;`.
262
+ // `requireValue` extracts the `--jq <value>` argument; pass the script's own
263
+ // value-extraction function (e.g. `(t) => requireTokenValue(t, parseError)`) to
264
+ // preserve its existing error semantics. Defaults to the raw token value.
265
+ export function matchJqOutputToken(token, options, requireValue = (t) => t.value) {
266
+ if (token.name === "jq") {
267
+ options.jq = requireValue(token);
268
+ return true;
269
+ }
270
+ if (token.name === "silent") {
271
+ options.silent = true;
272
+ return true;
273
+ }
274
+ return false;
275
+ }
276
+
259
277
  // Apply --jq / --silent to a result object and emit. Returns the exit code the
260
278
  // CLI should use (0 success / truthy, 1 falsy or non-ok, 2 invalid filter).
261
279
  // Without jq/silent the result is printed verbatim as JSON (unchanged shape).
@@ -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`);