dev-loops 0.7.1 → 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 (86) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +2 -2
  6. package/.claude/commands/loop-enqueue.md +1 -1
  7. package/.claude/commands/loop-info.md +1 -1
  8. package/.claude/commands/loop-start-spike.md +1 -1
  9. package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
  10. package/.claude/skills/dev-loop/SKILL.md +6 -6
  11. package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
  12. package/.claude/skills/docs/anti-patterns.md +3 -2
  13. package/.claude/skills/docs/artifact-authority-contract.md +12 -11
  14. package/.claude/skills/docs/confirmation-rules.md +1 -0
  15. package/.claude/skills/docs/contract-style-guide.md +37 -0
  16. package/.claude/skills/docs/copilot-loop-operations.md +18 -14
  17. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  18. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  19. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  20. package/.claude/skills/docs/issue-intake-procedure.md +25 -19
  21. package/.claude/skills/docs/merge-preconditions.md +13 -11
  22. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  23. package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
  24. package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
  25. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  26. package/.claude/skills/docs/stop-conditions.md +30 -15
  27. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  28. package/.claude/skills/docs/validation-policy.md +4 -5
  29. package/.claude/skills/local-implementation/SKILL.md +48 -142
  30. package/CHANGELOG.md +6 -0
  31. package/agents/dev-loop.agent.md +8 -8
  32. package/agents/fixer.agent.md +1 -0
  33. package/agents/refiner.agent.md +2 -2
  34. package/agents/review.agent.md +2 -2
  35. package/package.json +5 -4
  36. package/scripts/_core-helpers.mjs +2 -0
  37. package/scripts/claude/headless-dev-loop.mjs +1 -1
  38. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  39. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  40. package/scripts/github/_review-thread-mutations.mjs +5 -2
  41. package/scripts/github/create-pr.mjs +119 -10
  42. package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
  43. package/scripts/github/list-review-threads.mjs +277 -0
  44. package/scripts/github/post-gate-findings.mjs +6 -3
  45. package/scripts/github/request-copilot-review.mjs +92 -26
  46. package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
  47. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  48. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  49. package/scripts/github/wait-pr-checks.mjs +171 -0
  50. package/scripts/github/write-gate-context.mjs +213 -7
  51. package/scripts/github/write-gate-findings-log.mjs +54 -2
  52. package/scripts/loop/copilot-pr-handoff.mjs +29 -2
  53. package/scripts/loop/detect-change-scope.mjs +2 -2
  54. package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
  56. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  57. package/scripts/loop/sanctioned-commands.mjs +2 -0
  58. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  59. package/scripts/pages/build-state-atlas.mjs +50 -48
  60. package/scripts/projects/_resolve-project.mjs +1 -148
  61. package/scripts/projects/list-queue-items.mjs +3 -377
  62. package/scripts/projects/move-queue-item.mjs +3 -410
  63. package/scripts/projects/reorder-queue-item.mjs +3 -22
  64. package/skills/copilot-pr-followup/SKILL.md +37 -23
  65. package/skills/dev-loop/SKILL.md +1 -1
  66. package/skills/docs/acceptance-criteria-verification.md +3 -2
  67. package/skills/docs/anti-patterns.md +3 -2
  68. package/skills/docs/artifact-authority-contract.md +12 -11
  69. package/skills/docs/confirmation-rules.md +1 -0
  70. package/skills/docs/contract-style-guide.md +37 -0
  71. package/skills/docs/copilot-loop-operations.md +18 -14
  72. package/skills/docs/cross-harness-regression-contract.md +2 -2
  73. package/skills/docs/debt-remediation-contract.md +1 -1
  74. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  75. package/skills/docs/issue-intake-procedure.md +25 -19
  76. package/skills/docs/merge-preconditions.md +13 -11
  77. package/skills/docs/pr-lifecycle-contract.md +44 -35
  78. package/skills/docs/public-dev-loop-contract.md +59 -43
  79. package/skills/docs/required-rules.json +149 -0
  80. package/skills/docs/retrospective-checkpoint-contract.md +18 -5
  81. package/skills/docs/spike-mode-contract.md +14 -7
  82. package/skills/docs/stop-conditions.md +30 -15
  83. package/skills/docs/tracker-first-loop-state.md +13 -8
  84. package/skills/docs/validation-policy.md +4 -5
  85. package/skills/local-implementation/SKILL.md +48 -142
  86. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -29,6 +29,7 @@
29
29
  * <tmpRoot>/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json
30
30
  */
31
31
  import { execFileSync } from "node:child_process";
32
+ import { createHash } from "node:crypto";
32
33
  import { mkdir, readFile, writeFile } from "node:fs/promises";
33
34
  import path from "node:path";
34
35
  import { parseArgs } from "node:util";
@@ -117,8 +118,10 @@ Optional:
117
118
  --branch <name> Source branch name
118
119
  --touched-files <json> JSON array of changed file path strings (separate from the diff-derived scope.changedFiles)
119
120
  --base <ref> Git ref to diff against (git diff <ref>...HEAD); populates scope.diffPath, scope.changedFiles, and adjacentCode (the full build-once bundle). Without it, the CLI emits an explicit thin briefing (scope.diffSource="none") — see docs/gate-review-sub-loop-contract.md.
120
- --acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL)
121
+ --acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL); also used as the linked-issue label in the rendered briefing prefix
121
122
  --validation-posture <text> Short description of the validation posture
123
+ --pr-body <text> PR description text, inlined into the rendered briefing prefix
124
+ --issue-body <text> Linked-issue body text, inlined into the briefing prefix under --acceptance-criteria's label; omitted entirely when absent
122
125
  --tmp-root <path> Root tmp directory (default: tmp/)
123
126
 
124
127
  ${JQ_OUTPUT_USAGE}
@@ -231,6 +234,8 @@ export function parseWriteGateContextCliArgs(argv) {
231
234
  base: { type: "string" },
232
235
  "acceptance-criteria": { type: "string" },
233
236
  "validation-posture": { type: "string" },
237
+ "pr-body": { type: "string" },
238
+ "issue-body": { type: "string" },
234
239
  "tmp-root": { type: "string" },
235
240
  ...JQ_OUTPUT_PARSE_OPTIONS,
236
241
  },
@@ -250,6 +255,8 @@ export function parseWriteGateContextCliArgs(argv) {
250
255
  base: null,
251
256
  acceptanceCriteria: null,
252
257
  validationPosture: null,
258
+ prBody: null,
259
+ issueBody: null,
253
260
  tmpRoot: "tmp",
254
261
  };
255
262
  for (const token of tokens) {
@@ -312,6 +319,14 @@ export function parseWriteGateContextCliArgs(argv) {
312
319
  options.validationPosture = requireTokenValue(token, parseError).trim();
313
320
  continue;
314
321
  }
322
+ if (token.name === "pr-body") {
323
+ options.prBody = requireTokenValue(token, parseError);
324
+ continue;
325
+ }
326
+ if (token.name === "issue-body") {
327
+ options.issueBody = requireTokenValue(token, parseError);
328
+ continue;
329
+ }
315
330
  if (token.name === "tmp-root") {
316
331
  options.tmpRoot = requireTokenValue(token, parseError).trim();
317
332
  continue;
@@ -426,6 +441,137 @@ export function buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot = "tmp" })
426
441
  return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.diff`);
427
442
  }
428
443
 
444
+ /**
445
+ * Build the deterministic path for the rendered invariant briefing prefix
446
+ * (GATE-EXEC-BRIEFING-PREFIX): the byte-identical block every per-angle
447
+ * reviewer of this gate pass is seeded with, before their angle-specific
448
+ * suffix. Mirrors buildGateContextPath/buildGateDiffPath. Exported so the
449
+ * fan-out reviewers and `verify-fresh-review-context.mjs --prefix-file` agree
450
+ * on the path with the context-builder.
451
+ *
452
+ * @param {object} input
453
+ * @param {string} input.repo — owner/name
454
+ * @param {number|string} input.pr
455
+ * @param {string} input.gate — draft_gate | pre_approval_gate
456
+ * @param {string} input.headSha
457
+ * @param {string} [input.tmpRoot] — default "tmp"
458
+ * @returns {string} relative briefing-prefix path
459
+ */
460
+ export function buildGateBriefingPrefixPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
461
+ const repoSlug = repoSlugFor(repo);
462
+ const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
463
+ return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.briefing-prefix.txt`);
464
+ }
465
+
466
+ /**
467
+ * Size cap (bytes) above which the rendered briefing prefix falls back to
468
+ * pointer mode for the diff section (a scope.diffPath reference when present,
469
+ * else an explicit unavailable-pointer disclosure) instead of inlining the
470
+ * diff text in a fenced block. No `gates.*` config knob exists for this
471
+ * yet — a named constant is the right size for a single fixed threshold;
472
+ * promote to config only if a real need for tuning it emerges.
473
+ */
474
+ export const BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES = 200 * 1024;
475
+
476
+ /**
477
+ * Render the invariant briefing-prefix text (GATE-EXEC-BRIEFING-PREFIX):
478
+ * header (repo/PR/head/gate/worktree + the mandatory verify-fresh-review-context.mjs
479
+ * instruction), PR body, linked-issue body (when present), the full diff at the
480
+ * reviewed head (inlined up to `capBytes`, else a pointer to `diffPath`), and a
481
+ * changed-files/adjacent-code summary — in that fixed order. Pure and
482
+ * deterministic: identical input always renders identical bytes, so two builds
483
+ * at the same head produce a byte-identical prefix (the fan-out's shared-prefix
484
+ * requirement).
485
+ *
486
+ * @param {object} input
487
+ * @param {string} input.repo
488
+ * @param {number|string} input.pr
489
+ * @param {string} input.gate
490
+ * @param {string} input.headSha
491
+ * @param {string} input.worktreeRoot — absolute path reviewers run in
492
+ * @param {string} input.contextPath — the sibling JSON artifact path
493
+ * @param {string} input.briefingPrefixPath — this rendered file's own path
494
+ * @param {string|null} [input.prBody]
495
+ * @param {string|null} [input.issueRef] — label for the linked-issue section (e.g. "#877")
496
+ * @param {string|null} [input.issueBody] — omit the whole section when null/empty
497
+ * @param {string|null} [input.diffOutput] — full diff text, when captured
498
+ * @param {string|null} [input.diffPath] — persisted `.diff` pointer (pointer-mode fallback)
499
+ * @param {string[]} [input.changedFiles]
500
+ * @param {object|null} [input.adjacentCode] — buildAdjacentBundle output
501
+ * @param {number} [input.capBytes] — default BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES
502
+ * @returns {{ text: string, prefixMode: "inline"|"pointer", diffBytes: number }}
503
+ */
504
+ export function renderBriefingPrefix({
505
+ repo, pr, gate, headSha, worktreeRoot, contextPath, briefingPrefixPath,
506
+ prBody = null, issueRef = null, issueBody = null,
507
+ diffOutput = null, diffPath = null, changedFiles = [], adjacentCode = null,
508
+ capBytes = BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES,
509
+ }) {
510
+ const hasDiffText = typeof diffOutput === "string" && diffOutput.length > 0;
511
+ const diffBytes = hasDiffText ? Buffer.byteLength(diffOutput, "utf8") : 0;
512
+ const prefixMode = hasDiffText && diffBytes > capBytes ? "pointer" : "inline";
513
+
514
+ const lines = [];
515
+ lines.push("# Gate Review Briefing — invariant prefix (GATE-EXEC-BRIEFING-PREFIX)");
516
+ lines.push("");
517
+ lines.push(`repo: ${repo}`);
518
+ lines.push(`pr: #${pr}`);
519
+ lines.push(`gate: ${gate}`);
520
+ lines.push(`head: ${headSha}`);
521
+ lines.push(`worktree: ${worktreeRoot}`);
522
+ lines.push(`prefixMode: ${prefixMode}`);
523
+ lines.push("");
524
+ lines.push(
525
+ `Mandatory: before doing any angle-specific work, run \`node scripts/github/verify-fresh-review-context.mjs --scope <your-angle> --context-path ${contextPath} --prefix-file ${briefingPrefixPath}\`. Refuse to proceed on contamination or a missing artifact.`,
526
+ );
527
+ lines.push("");
528
+ lines.push("## PR body");
529
+ lines.push("");
530
+ lines.push(typeof prBody === "string" && prBody.trim().length > 0 ? prBody.trim() : "(no PR body provided)");
531
+ lines.push("");
532
+ const hasIssueBody = typeof issueBody === "string" && issueBody.trim().length > 0;
533
+ if (hasIssueBody) {
534
+ lines.push(`## Linked issue${issueRef ? ` ${issueRef}` : ""}`);
535
+ lines.push("");
536
+ lines.push(issueBody.trim());
537
+ lines.push("");
538
+ }
539
+ lines.push(`## Diff at reviewed head (${headSha})`);
540
+ lines.push("");
541
+ if (!hasDiffText) {
542
+ lines.push("(no diff text captured for this bundle)");
543
+ } else if (prefixMode === "inline") {
544
+ lines.push("```diff");
545
+ lines.push(diffOutput.endsWith("\n") ? diffOutput.slice(0, -1) : diffOutput);
546
+ lines.push("```");
547
+ } else {
548
+ lines.push(
549
+ `Diff exceeds the ${capBytes}-byte inline cap (${diffBytes} bytes) — pointer mode. Read the full diff from:`,
550
+ );
551
+ lines.push(` ${diffPath ?? "(diff pointer unavailable — re-derive with git diff)"}`);
552
+ }
553
+ lines.push("");
554
+ lines.push("## Changed files + adjacent-code summary");
555
+ lines.push("");
556
+ const files = Array.isArray(changedFiles) ? changedFiles : [];
557
+ lines.push(`Changed files (${files.length}):`);
558
+ for (const f of files) lines.push(`- ${f}`);
559
+ const adjacentFiles = adjacentCode && Array.isArray(adjacentCode.files)
560
+ ? adjacentCode.files.filter((f) => f.role !== "changed")
561
+ : [];
562
+ if (adjacentCode) {
563
+ lines.push(`Adjacent files (${adjacentFiles.length}):`);
564
+ for (const f of adjacentFiles) lines.push(`- ${f.path} (${f.role})`);
565
+ lines.push(
566
+ `Stripped: ${adjacentCode.stripped?.length ?? 0}, Truncated: ${adjacentCode.truncated?.length ?? 0}, Missing: ${adjacentCode.missing?.length ?? 0}`,
567
+ );
568
+ } else {
569
+ lines.push("Adjacent files (0): (no adjacent-code bundle for this briefing)");
570
+ }
571
+
572
+ return { text: lines.join("\n") + "\n", prefixMode, diffBytes };
573
+ }
574
+
429
575
  /**
430
576
  * Parse `git diff --name-status` output into full repo-relative changed file
431
577
  * paths. Handles rename/copy entries (R100 old new, C75 old new) by recording
@@ -500,6 +646,12 @@ export function buildGateContextArtifact(options) {
500
646
  if (options.adjacentCode && typeof options.adjacentCode === "object") {
501
647
  artifact.adjacentCode = options.adjacentCode;
502
648
  }
649
+ // Whether the rendered briefing prefix inlined the reviewed-head diff (in a
650
+ // fenced block) or fell back to the diffPath pointer (size cap). Only set
651
+ // when a caller actually rendered a prefix (writeGateContext does, always).
652
+ if (typeof options.prefixMode === "string" && options.prefixMode.length > 0) {
653
+ artifact.prefixMode = options.prefixMode;
654
+ }
503
655
  return artifact;
504
656
  }
505
657
 
@@ -520,7 +672,7 @@ export function buildGateContextArtifact(options) {
520
672
  * @param {string} input.tmpRoot
521
673
  * @param {number} [input.maxFileBytes]
522
674
  * @param {{ repoRoot: string }} opts
523
- * @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null }>}
675
+ * @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null, diffOutput: string|null }>}
524
676
  */
525
677
  async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFileBytes }, { repoRoot }) {
526
678
  const diffOutput = diff?.diffOutput;
@@ -562,7 +714,7 @@ async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFil
562
714
  }
563
715
  }
564
716
 
565
- return { diffPath, changedFiles, adjacentCode };
717
+ return { diffPath, changedFiles, adjacentCode, diffOutput: typeof diffOutput === "string" ? diffOutput : null };
566
718
  }
567
719
 
568
720
  /**
@@ -647,6 +799,19 @@ export function captureDiffFromBase(base, { repoRoot, maxBuffer = 64 * 1024 * 10
647
799
  return { nameStatusOutput, diffOutput };
648
800
  }
649
801
 
802
+ /**
803
+ * Write both the JSON context artifact AND its sibling rendered briefing
804
+ * prefix (GATE-EXEC-BRIEFING-PREFIX): the byte-identical invariant block every
805
+ * per-angle reviewer of this gate pass is seeded with. The prefix's
806
+ * `prefixMode` (inline|pointer, from the diff-size cap) is recorded on the
807
+ * JSON artifact so both files stay in sync.
808
+ *
809
+ * @param {object} options — parsed CLI options shape, optionally carrying
810
+ * `diffOutput`, `prBody`, `issueBody` (all null-safe; a caller with none of
811
+ * these still gets a valid — if thin — prefix).
812
+ * @param {{ repoRoot?: string }} [runtime]
813
+ * @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer" }>}
814
+ */
650
815
  export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
651
816
  const contextPath = buildGateContextPath({
652
817
  repo: options.repo,
@@ -655,14 +820,49 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
655
820
  headSha: options.headSha,
656
821
  tmpRoot: options.tmpRoot || "tmp",
657
822
  });
823
+ const briefingPrefixPath = buildGateBriefingPrefixPath({
824
+ repo: options.repo,
825
+ pr: options.pr,
826
+ gate: options.gate,
827
+ headSha: options.headSha,
828
+ tmpRoot: options.tmpRoot || "tmp",
829
+ });
830
+ const rendered = renderBriefingPrefix({
831
+ repo: options.repo,
832
+ pr: options.pr,
833
+ gate: options.gate,
834
+ headSha: options.headSha,
835
+ worktreeRoot: path.resolve(repoRoot),
836
+ contextPath,
837
+ briefingPrefixPath,
838
+ prBody: options.prBody ?? null,
839
+ issueRef: options.acceptanceCriteria ?? null,
840
+ issueBody: options.issueBody ?? null,
841
+ diffOutput: options.diffOutput ?? null,
842
+ diffPath: options.diffPath ?? null,
843
+ changedFiles: options.changedFiles ?? [],
844
+ adjacentCode: options.adjacentCode ?? null,
845
+ });
846
+
658
847
  const fullPath = path.resolve(repoRoot, contextPath);
659
848
  const artifact = {
660
- ...buildGateContextArtifact(options),
849
+ ...buildGateContextArtifact({ ...options, prefixMode: rendered.prefixMode }),
661
850
  loggedAt: new Date().toISOString(),
662
851
  };
852
+ // Write ORDER matters: the sibling briefing prefix goes first and the JSON
853
+ // artifact last, so the artifact's existence is the completion marker for
854
+ // the whole set. Downstream consumers (readGateContext, the reviewers'
855
+ // --context-path guard) key on the JSON — a prefix-write failure must not
856
+ // leave a complete-looking artifact pointing at a missing prefix file.
857
+ const fullPrefixPath = path.resolve(repoRoot, briefingPrefixPath);
858
+ await mkdir(path.dirname(fullPrefixPath), { recursive: true });
859
+ await writeFile(fullPrefixPath, rendered.text, "utf8");
860
+ const prefixHash = createHash("sha256").update(rendered.text, "utf8").digest("hex");
861
+
663
862
  await mkdir(path.dirname(fullPath), { recursive: true });
664
863
  await writeFile(fullPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
665
- return { ok: true, path: contextPath, artifact };
864
+
865
+ return { ok: true, path: contextPath, artifact, prefixPath: briefingPrefixPath, prefixHash, prefixMode: rendered.prefixMode };
666
866
  }
667
867
 
668
868
  /**
@@ -685,10 +885,12 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
685
885
  * @param {string[]} [input.touchedFiles]
686
886
  * @param {string|null} [input.acceptanceCriteria]
687
887
  * @param {string|null} [input.validationPosture]
888
+ * @param {string|null} [input.prBody] — PR description text, inlined into the rendered briefing prefix
889
+ * @param {string|null} [input.issueBody] — linked-issue body text, inlined under `acceptanceCriteria`'s label; omitted when absent
688
890
  * @param {number} [input.maxFileBytes] — per-file cap for the adjacent-code bundle (default DEFAULT_MAX_FILE_BYTES)
689
891
  * @param {string} [input.tmpRoot]
690
892
  * @param {{ repoRoot?: string }} [opts]
691
- * @returns {Promise<{ ok: boolean, path: string, artifact: object, resolver: object }>}
893
+ * @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer", resolver: object }>}
692
894
  *
693
895
  * The artifact additionally carries a deterministic, neutral `adjacentCode`
694
896
  * bundle (#895) when changed files are present: 1-hop import in/out-edges of the
@@ -707,7 +909,7 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
707
909
  // Diff-derived scope: persisted FULL diff (scope.diffPath), parsed
708
910
  // scope.changedFiles, and the neutral adjacentCode bundle, all built ONCE by
709
911
  // the shared resolveDiffScope helper (also used by the CLI --base path, #1140).
710
- const { diffPath, changedFiles, adjacentCode } = await resolveDiffScope(
912
+ const { diffPath, changedFiles, adjacentCode, diffOutput } = await resolveDiffScope(
711
913
  { diff: input.diff, repo: input.repo, pr: input.pr, gate: input.gate, headSha: input.headSha, tmpRoot, maxFileBytes: input.maxFileBytes },
712
914
  { repoRoot },
713
915
  );
@@ -724,9 +926,12 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
724
926
  touchedFiles: input.touchedFiles ?? [],
725
927
  changedFiles,
726
928
  diffPath,
929
+ diffOutput,
727
930
  adjacentCode,
728
931
  acceptanceCriteria: input.acceptanceCriteria ?? null,
729
932
  validationPosture: input.validationPosture ?? null,
933
+ prBody: input.prBody ?? null,
934
+ issueBody: input.issueBody ?? null,
730
935
  tmpRoot,
731
936
  },
732
937
  { repoRoot },
@@ -800,6 +1005,7 @@ export async function main(argv = process.argv.slice(2), { repoRoot = process.cw
800
1005
  options.changedFiles = scope.changedFiles;
801
1006
  options.diffPath = scope.diffPath;
802
1007
  options.adjacentCode = scope.adjacentCode;
1008
+ options.diffOutput = scope.diffOutput;
803
1009
  options.diffSource = "base";
804
1010
  } else {
805
1011
  process.stderr.write("[write-gate-context] warning: no --base given; emitting a THIN briefing (scope.diffPath=null, scope.changedFiles=[], no adjacentCode). Pass --base <ref> for the full build-once bundle.\n");
@@ -5,7 +5,8 @@ import { parseArgs } from "node:util";
5
5
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
6
6
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
7
7
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
- import { provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
8
+ import { checkFanoutAngleCoverage, provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
9
+ import { loadDevLoopConfig, resolveGateAngleContract, resolveRejectForeignAngles } from "@dev-loops/core/config";
9
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>]
10
11
  Write a durable <gate>-<headSha>.json log under deterministic tmp/ paths.
11
12
  Required:
@@ -30,6 +31,7 @@ function normalizeGate(value) {
30
31
  const normalized = String(value).trim().toLowerCase();
31
32
  return gates.has(normalized) ? normalized : null;
32
33
  }
34
+ const GATE_CONFIG_KEY = { draft_gate: "draft", pre_approval_gate: "preApproval" };
33
35
  function normalizeVerdict(value) {
34
36
  const verdicts = new Set(["clean", "findings_present", "blocked"]);
35
37
  const normalized = String(value).trim().toLowerCase();
@@ -148,6 +150,42 @@ export function parseProvenanceJson(raw) {
148
150
  }
149
151
  return normalized;
150
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
+ }
151
189
  export function parseWriteGateFindingsLogCliArgs(argv) {
152
190
  const { tokens } = parseArgs({
153
191
  args: [...argv],
@@ -250,6 +288,12 @@ export function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
250
288
  export async function writeGateFindingsLog(options, { repoRoot = process.cwd() } = {}) {
251
289
  const findings = parseFindingsJson(options.findings);
252
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 };
253
297
  const logPath = buildLogPath({
254
298
  repo: options.repo,
255
299
  pr: options.pr,
@@ -276,7 +320,9 @@ export async function writeGateFindingsLog(options, { repoRoot = process.cwd() }
276
320
  }
277
321
  await mkdir(path.dirname(fullPath), { recursive: true });
278
322
  await writeFile(fullPath, JSON.stringify(log, null, 2) + "\n", "utf8");
279
- 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 };
280
326
  }
281
327
  async function main() {
282
328
  let options;
@@ -293,6 +339,12 @@ async function main() {
293
339
  }
294
340
  try {
295
341
  const result = await writeGateFindingsLog(options);
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
+ }
296
348
  process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
297
349
  } catch (error) {
298
350
  process.stderr.write(JSON.stringify({
@@ -4,11 +4,12 @@ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.m
4
4
  import { detectPostConvergenceSignificantChange } from "./_post-convergence-change.mjs";
5
5
  import { detectRepoSlug, parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { resolveRunId } from "@dev-loops/core/loop/run-context";
7
- import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
7
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
8
8
  import { autoDetectSnapshot } from "./detect-copilot-loop-state.mjs";
9
9
  import { performCopilotReviewRequest } from "../github/request-copilot-review.mjs";
10
10
  import { detectInternalOnly as detectPrInternalOnly } from "./detect-internal-only-pr.mjs";
11
11
  import { applyConfirmedReviewRequest, interpretLoopState, NEXT_ACTIONS, STATE, summarizeLoopInterpretation, TRANSITIONS } from "@dev-loops/core/loop/copilot-loop-state";
12
+ import { PR_LIFECYCLE_STATE } from "@dev-loops/core/loop/pr-lifecycle";
12
13
  import { ensureAsyncRunnerOwnership, releaseAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
13
14
  import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
14
15
 
@@ -38,6 +39,13 @@ Optional:
38
39
  --watch-status <status> Refresh deterministic loop state after a prior
39
40
  watcher result (changed|timeout|idle). This mode
40
41
  never requests review; it only re-detects state.
42
+ --lightweight This PR is light-dispatched (#1210): compose the
43
+ Copilot round cap with localImplementation.lightMode.
44
+ maxCopilotRounds (default 1) via
45
+ min(lightMode.maxCopilotRounds, refinement.maxCopilotRounds)
46
+ instead of using refinement.maxCopilotRounds alone.
47
+ refinement.maxCopilotRounds: 0 still disables Copilot
48
+ rounds even with --lightweight.
41
49
  Output (stdout, JSON):
42
50
  { "ok": true, "action": "watch"|"fix"|"stop", "state": "...",
43
51
  "allowedTransitions": [...], "nextAction": "...", "snapshot": {...},
@@ -100,7 +108,7 @@ function summarizeRequestWatchContract({
100
108
  || interpretation.state === STATE.READY_TO_REREQUEST_REVIEW
101
109
  && interpretation.sameHeadCleanConverged !== true
102
110
  ) {
103
- routingState = "ready_state_needs_copilot_request";
111
+ routingState = PR_LIFECYCLE_STATE.READY_STATE_NEEDS_COPILOT_REQUEST;
104
112
  } else if (interpretation.state === STATE.INTERNAL_TOOLING_DIRECT_GATE) {
105
113
  routingState = "internal_tooling_skip_copilot";
106
114
  }
@@ -138,6 +146,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
138
146
  repo: undefined,
139
147
  pr: undefined,
140
148
  watchStatus: undefined,
149
+ lightweight: false,
141
150
  jq: undefined,
142
151
  silent: false,
143
152
  };
@@ -148,6 +157,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
148
157
  repo: { type: "string" },
149
158
  pr: { type: "string" },
150
159
  "watch-status": { type: "string" },
160
+ lightweight: { type: "boolean" },
151
161
  ...JQ_OUTPUT_PARSE_OPTIONS,
152
162
  },
153
163
  allowPositionals: true,
@@ -184,6 +194,10 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
184
194
  options.watchStatus = watchStatus;
185
195
  continue;
186
196
  }
197
+ if (token.name === "lightweight") {
198
+ options.lightweight = true;
199
+ continue;
200
+ }
187
201
  if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
188
202
  throw parseError(`Unknown argument: ${token.rawName}`);
189
203
  }
@@ -358,6 +372,15 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
358
372
  const refinementConfig = config.errors?.length > 0
359
373
  ? resolveRefinement({ version: 1 })
360
374
  : resolveRefinement(config.config);
375
+ if (options.lightweight) {
376
+ // Compose (not replace) the round cap for light-dispatched PRs (#1210):
377
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
378
+ // maxCopilotRounds: 0 still disables Copilot rounds everywhere.
379
+ refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
380
+ config.errors?.length > 0 ? { version: 1 } : config.config,
381
+ { lightweight: true },
382
+ );
383
+ }
361
384
  let interpretation = interpretLoopState(snapshot, refinementConfig);
362
385
 
363
386
  // Check for human comments since last subagent action
@@ -545,6 +568,10 @@ export async function runHandoff(options, { env = process.env, ghCommand = "gh"
545
568
  // still refuses unless the head actually advanced past the last review
546
569
  // (its hasNewCommits guard), so this cannot force an over-cap same-head request.
547
570
  forceRerequestReview: reopenedCapCycle,
571
+ // Thread the light-dispatch signal so the requester enforces the same
572
+ // composed round cap this handoff resolved (#1210) — the enforcement
573
+ // backstop must never permit rounds the interpreter already forbids.
574
+ lightweight: options.lightweight,
548
575
  },
549
576
  { env, ghCommand },
550
577
  );
@@ -75,7 +75,7 @@ export function parseGitDiffStat(output) {
75
75
  }
76
76
  return { filesChanged: fileCount, linesChanged: insertions + deletions };
77
77
  }
78
- function detectScope({ base, head } = {}) {
78
+ function detectScope({ base, head, cwd } = {}) {
79
79
  let diffArgs = ["diff", "--stat"];
80
80
  if (base && head) {
81
81
  diffArgs.push(`${base}..${head}`);
@@ -86,7 +86,7 @@ function detectScope({ base, head } = {}) {
86
86
  }
87
87
  let output;
88
88
  try {
89
- output = execFileSync("git", diffArgs, { encoding: "utf8", maxBuffer: 1_000_000 });
89
+ output = execFileSync("git", diffArgs, { encoding: "utf8", maxBuffer: 1_000_000, cwd: cwd || undefined });
90
90
  } catch (err) {
91
91
  return { ok: false, filesChanged: 0, linesChanged: 0, error: err instanceof Error ? err.message : String(err) };
92
92
  }
@@ -14,7 +14,7 @@ import {
14
14
  } from "../_core-helpers.mjs";
15
15
  import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
16
16
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
- import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
17
+ import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
18
18
  import {
19
19
  buildSnapshotFromPrFacts,
20
20
  interpretLoopState,
@@ -43,8 +43,13 @@ Required (auto-detect mode):
43
43
  --pr <number> Pull request number
44
44
  Required (snapshot mode):
45
45
  --input <path> Path to snapshot JSON file
46
- Optional (auto-detect mode only):
47
- Optional (auto-detect mode only):
46
+ Optional (either mode):
47
+ --lightweight This PR is light-dispatched (#1210): compose the Copilot
48
+ round cap with localImplementation.lightMode.maxCopilotRounds
49
+ (default 1) via min(lightMode.maxCopilotRounds,
50
+ refinement.maxCopilotRounds) instead of using
51
+ refinement.maxCopilotRounds alone. refinement.maxCopilotRounds:
52
+ 0 still disables Copilot rounds even with --lightweight.
48
53
  Output (stdout, JSON):
49
54
  { "ok": true, "snapshot": {..., "copilotReviewRoundCount": N}, "state": "...", "allowedTransitions": [...], "nextAction": "...",
50
55
  "autoRerequestEligible": true|false, "sameHeadCleanConverged": true|false,
@@ -67,6 +72,7 @@ export function parseDetectCliArgs(argv) {
67
72
  inputPath: undefined,
68
73
  repo: undefined,
69
74
  pr: undefined,
75
+ lightweight: false,
70
76
  };
71
77
  const { tokens } = parseArgs({
72
78
  args: [...argv],
@@ -75,6 +81,7 @@ export function parseDetectCliArgs(argv) {
75
81
  input: { type: "string" },
76
82
  repo: { type: "string" },
77
83
  pr: { type: "string" },
84
+ lightweight: { type: "boolean" },
78
85
  ...JQ_OUTPUT_PARSE_OPTIONS,
79
86
  },
80
87
  allowPositionals: true,
@@ -104,6 +111,10 @@ export function parseDetectCliArgs(argv) {
104
111
  options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
105
112
  continue;
106
113
  }
114
+ if (token.name === "lightweight") {
115
+ options.lightweight = true;
116
+ continue;
117
+ }
107
118
  if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
108
119
  throw parseError(`Unknown argument: ${token.rawName}`);
109
120
  }
@@ -455,6 +466,15 @@ export async function runCli(
455
466
  const refinementConfig = config.errors.length > 0
456
467
  ? resolveRefinement({ version: 1 })
457
468
  : resolveRefinement(config.config);
469
+ if (options.lightweight) {
470
+ // Compose (not replace) the round cap for light-dispatched PRs (#1210):
471
+ // min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds), so
472
+ // maxCopilotRounds: 0 still disables Copilot rounds everywhere.
473
+ refinementConfig.maxCopilotRounds = resolveEffectiveCopilotRoundCap(
474
+ config.errors.length > 0 ? { version: 1 } : config.config,
475
+ { lightweight: true },
476
+ );
477
+ }
458
478
  interpretation = interpretLoopState(interpretationInput, refinementConfig);
459
479
  const interpretationSummary = summarizeLoopInterpretation(interpretation);
460
480
  process.exitCode = emitResult({