dev-loops 0.7.1 → 0.8.0

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 (107) 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 +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
+ import { parseNonNegativeInteger, parsePositiveInteger, parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
+ import {
8
+ DEFAULT_POLL_INTERVAL_MS,
9
+ COPILOT_REVIEW_WAIT_TIMEOUT_MS,
10
+ } from "@dev-loops/core/loop/policy-constants";
11
+ // Reuse probe-ci-status.mjs's watch engine verbatim (polling, heartbeats, the
12
+ // not-yet-registered-check grace/race guard, head-change detection) instead of
13
+ // re-implementing it. This wrapper only adds seconds-based flags and a direct
14
+ // process-exit-code contract for shell/scripted callers.
15
+ import { watchCiStatus } from "./probe-ci-status.mjs";
16
+
17
+ const DEFAULT_TIMEOUT_SECONDS = Math.floor(COPILOT_REVIEW_WAIT_TIMEOUT_MS / 1000);
18
+ const DEFAULT_POLL_SECONDS = Math.floor(DEFAULT_POLL_INTERVAL_MS / 1000);
19
+
20
+ const USAGE = `Usage: wait-pr-checks.mjs --repo <owner/name> --pr <number> [--timeout <seconds>] [--poll <seconds>]
21
+ Block until the current head SHA's CI checks/statuses settle, or the wait
22
+ budget elapses. Replaces hand-rolled \`until [ "$(gh pr checks | grep -c pending)" = 0 ]\`
23
+ shell loops with a single deterministic call.
24
+ Required:
25
+ --repo <owner/name> Repository slug (e.g. owner/repo)
26
+ --pr <number> Pull request number
27
+ Optional:
28
+ --timeout <seconds> Total wait budget (default ${DEFAULT_TIMEOUT_SECONDS}; 0 = single
29
+ immediate check, no wait)
30
+ --poll <seconds> Delay between polls (default ${DEFAULT_POLL_SECONDS})
31
+ Not-yet-registered-check race guard:
32
+ A freshly pushed head with zero registered checks does NOT settle green on
33
+ the first poll — a provider may post its first check a beat after the push.
34
+ The watcher requires either an observed check/status, or a stable empty
35
+ result across two consecutive polls, before treating the head as genuinely
36
+ check-less. A repo with no CI at all still settles after that grace instead
37
+ of hanging to timeout.
38
+ Output (stdout, JSON, the final per-check summary):
39
+ { "ok": true, "status": "success"|"failure"|"pending"|"timeout"|"changed",
40
+ "settled": bool, "ciStatus": "success"|"failure"|"pending"|"none",
41
+ "failedChecks": [{ "name": "...", "conclusion"?: "..." }], "headSha": "...", "attempts": N }
42
+ "changed" means the head SHA advanced during the wait; re-baseline and re-run.
43
+ Diagnostic output (stderr):
44
+ { "ok": true, "type": "watch_heartbeat", "elapsedMs": N, "totalBudgetMs": N, "poll": N, "maxPolls": N }
45
+ { "ok": false, "error": "...", "usage"?: "..." }
46
+ ${JQ_OUTPUT_USAGE}
47
+ Exit codes (default output, no --jq/--silent):
48
+ 0 Green (status "success")
49
+ 1 Red (status "failure"), or an argument/gh/runtime error
50
+ 2 Not settled (status "timeout"/"changed"/"pending")
51
+ With --jq/--silent, the standard jq-output contract above applies instead
52
+ (0/1 by result truthiness; 2 reserved for an invalid --jq filter).`.trim();
53
+
54
+ const parseError = buildParseError(USAGE);
55
+
56
+ export function parseWaitPrChecksCliArgs(argv) {
57
+ const { tokens } = parseArgs({
58
+ args: [...argv],
59
+ options: {
60
+ help: { type: "boolean", short: "h" },
61
+ repo: { type: "string" },
62
+ pr: { type: "string" },
63
+ timeout: { type: "string" },
64
+ poll: { type: "string" },
65
+ ...JQ_OUTPUT_PARSE_OPTIONS,
66
+ },
67
+ allowPositionals: true,
68
+ strict: false,
69
+ tokens: true,
70
+ });
71
+ const options = {
72
+ help: false,
73
+ repo: undefined,
74
+ pr: undefined,
75
+ timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1000,
76
+ pollIntervalMs: DEFAULT_POLL_SECONDS * 1000,
77
+ };
78
+ for (const token of tokens) {
79
+ if (token.kind === "positional") {
80
+ throw parseError(`Unknown argument: ${token.value}`);
81
+ }
82
+ if (token.kind !== "option") {
83
+ continue;
84
+ }
85
+ if (token.name === "help") {
86
+ options.help = true;
87
+ return options;
88
+ }
89
+ if (token.name === "repo") {
90
+ options.repo = requireTokenValue(token, parseError).trim();
91
+ continue;
92
+ }
93
+ if (token.name === "pr") {
94
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
95
+ continue;
96
+ }
97
+ if (token.name === "timeout") {
98
+ // Canonical-digits validation (shared /^\d+$/ primitives): rejects
99
+ // JS-coercible spellings like 1e3, 0x10, "1.0", or " 5 ".
100
+ options.timeoutMs = parseNonNegativeInteger(requireTokenValue(token, parseError), "--timeout", parseError) * 1000;
101
+ continue;
102
+ }
103
+ if (token.name === "poll") {
104
+ options.pollIntervalMs = parsePositiveInteger(requireTokenValue(token, parseError), "--poll", parseError) * 1000;
105
+ continue;
106
+ }
107
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
108
+ throw parseError(`Unknown argument: ${token.rawName}`);
109
+ }
110
+ if (options.repo === undefined || options.pr === undefined) {
111
+ throw parseError("Waiting on PR checks requires both --repo <owner/name> and --pr <number>");
112
+ }
113
+ try {
114
+ parseRepoSlug(options.repo);
115
+ } catch (error) {
116
+ throw parseError(error instanceof Error ? error.message : String(error));
117
+ }
118
+ return options;
119
+ }
120
+
121
+ // Direct process-exit-code contract for shell/scripted callers — this is the
122
+ // tool's reason to exist alongside `dev-loops loop watch-ci`, whose CLI always
123
+ // exits 0 and expects callers to branch on the JSON `status` field instead.
124
+ export function exitCodeForWaitResult(result) {
125
+ if (result.status === "success") return 0;
126
+ if (result.status === "failure") return 1;
127
+ return 2;
128
+ }
129
+
130
+ export async function runCli(
131
+ argv = process.argv.slice(2),
132
+ {
133
+ stdout = process.stdout,
134
+ stderr = process.stderr,
135
+ env = process.env,
136
+ ghCommand = "gh",
137
+ delayImpl = undefined,
138
+ now = undefined,
139
+ } = {},
140
+ ) {
141
+ const options = parseWaitPrChecksCliArgs(argv);
142
+ if (options.help) {
143
+ stdout.write(`${USAGE}\n`);
144
+ return 0;
145
+ }
146
+ const result = await watchCiStatus(
147
+ { repo: options.repo, pr: options.pr, timeoutMs: options.timeoutMs, pollIntervalMs: options.pollIntervalMs },
148
+ {
149
+ env,
150
+ ghCommand,
151
+ ...(delayImpl ? { delayImpl } : {}),
152
+ ...(now ? { now } : {}),
153
+ },
154
+ );
155
+ if (options.jq !== undefined || options.silent) {
156
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: result.status === "success" });
157
+ }
158
+ stdout.write(`${JSON.stringify(result)}\n`);
159
+ return exitCodeForWaitResult(result);
160
+ }
161
+
162
+ if (isDirectCliRun(import.meta.url)) {
163
+ runCli().then((code) => {
164
+ if (typeof code === "number") {
165
+ process.exitCode = code;
166
+ }
167
+ }).catch((error) => {
168
+ process.stderr.write(`${formatCliError(error)}\n`);
169
+ process.exitCode = 1;
170
+ });
171
+ }
@@ -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";
@@ -38,6 +39,7 @@ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
38
39
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
39
40
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
40
41
  import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
42
+ import { GATE_NAMES } from "./_gate-names.mjs";
41
43
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
42
44
 
43
45
  /**
@@ -117,8 +119,10 @@ Optional:
117
119
  --branch <name> Source branch name
118
120
  --touched-files <json> JSON array of changed file path strings (separate from the diff-derived scope.changedFiles)
119
121
  --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)
122
+ --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
123
  --validation-posture <text> Short description of the validation posture
124
+ --pr-body <text> PR description text, inlined into the rendered briefing prefix
125
+ --issue-body <text> Linked-issue body text, inlined into the briefing prefix under --acceptance-criteria's label; omitted entirely when absent
122
126
  --tmp-root <path> Root tmp directory (default: tmp/)
123
127
 
124
128
  ${JQ_OUTPUT_USAGE}
@@ -129,7 +133,7 @@ function parseError(message) {
129
133
  }
130
134
 
131
135
  function normalizeGate(value) {
132
- const gates = new Set(["draft_gate", "pre_approval_gate"]);
136
+ const gates = new Set(GATE_NAMES);
133
137
  const normalized = String(value).trim().toLowerCase();
134
138
  return gates.has(normalized) ? normalized : null;
135
139
  }
@@ -149,6 +153,9 @@ function normalizeHeadSha(value) {
149
153
  // with our own "<base>...HEAD" triple-dot construction). Everything else —
150
154
  // including HEAD@{upstream}, main@{1}, tag-peel v1.0.0^{commit}, HEAD~3 — is
151
155
  // accepted.
156
+ // Revisit toward an explicit allowlist if `base` ever reaches a shell (or any
157
+ // call without execFileSync's no-shell argv guarantee), or if a malformed ref
158
+ // shape is found slipping past these checks into `git diff`.
152
159
  function normalizeBaseRef(value) {
153
160
  const trimmed = String(value).trim();
154
161
  if (trimmed.length === 0 || trimmed.startsWith("-") || trimmed.includes("..")) return null;
@@ -231,6 +238,8 @@ export function parseWriteGateContextCliArgs(argv) {
231
238
  base: { type: "string" },
232
239
  "acceptance-criteria": { type: "string" },
233
240
  "validation-posture": { type: "string" },
241
+ "pr-body": { type: "string" },
242
+ "issue-body": { type: "string" },
234
243
  "tmp-root": { type: "string" },
235
244
  ...JQ_OUTPUT_PARSE_OPTIONS,
236
245
  },
@@ -250,6 +259,8 @@ export function parseWriteGateContextCliArgs(argv) {
250
259
  base: null,
251
260
  acceptanceCriteria: null,
252
261
  validationPosture: null,
262
+ prBody: null,
263
+ issueBody: null,
253
264
  tmpRoot: "tmp",
254
265
  };
255
266
  for (const token of tokens) {
@@ -312,6 +323,14 @@ export function parseWriteGateContextCliArgs(argv) {
312
323
  options.validationPosture = requireTokenValue(token, parseError).trim();
313
324
  continue;
314
325
  }
326
+ if (token.name === "pr-body") {
327
+ options.prBody = requireTokenValue(token, parseError);
328
+ continue;
329
+ }
330
+ if (token.name === "issue-body") {
331
+ options.issueBody = requireTokenValue(token, parseError);
332
+ continue;
333
+ }
315
334
  if (token.name === "tmp-root") {
316
335
  options.tmpRoot = requireTokenValue(token, parseError).trim();
317
336
  continue;
@@ -360,8 +379,8 @@ export function buildGateContextPath({ repo, pr, gate, headSha, tmpRoot = "tmp"
360
379
  * @returns {{ pr: number, gate: string, headSha: string }}
361
380
  */
362
381
  function validatePathSegments({ pr, gate, headSha }) {
363
- if (gate !== "draft_gate" && gate !== "pre_approval_gate") {
364
- throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected draft_gate or pre_approval_gate)`);
382
+ if (!GATE_NAMES.includes(gate)) {
383
+ throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected ${GATE_NAMES.join(" or ")})`);
365
384
  }
366
385
  // Require a CANONICAL positive integer: the trimmed string must be all digits
367
386
  // (`/^\d+$/`) and > 0. This mirrors the CLI's parsePrNumber rule so the path
@@ -426,6 +445,137 @@ export function buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot = "tmp" })
426
445
  return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.diff`);
427
446
  }
428
447
 
448
+ /**
449
+ * Build the deterministic path for the rendered invariant briefing prefix
450
+ * (GATE-EXEC-BRIEFING-PREFIX): the byte-identical block every per-angle
451
+ * reviewer of this gate pass is seeded with, before their angle-specific
452
+ * suffix. Mirrors buildGateContextPath/buildGateDiffPath. Exported so the
453
+ * fan-out reviewers and `verify-fresh-review-context.mjs --prefix-file` agree
454
+ * on the path with the context-builder.
455
+ *
456
+ * @param {object} input
457
+ * @param {string} input.repo — owner/name
458
+ * @param {number|string} input.pr
459
+ * @param {string} input.gate — draft_gate | pre_approval_gate
460
+ * @param {string} input.headSha
461
+ * @param {string} [input.tmpRoot] — default "tmp"
462
+ * @returns {string} relative briefing-prefix path
463
+ */
464
+ export function buildGateBriefingPrefixPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
465
+ const repoSlug = repoSlugFor(repo);
466
+ const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
467
+ return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.briefing-prefix.txt`);
468
+ }
469
+
470
+ /**
471
+ * Size cap (bytes) above which the rendered briefing prefix falls back to
472
+ * pointer mode for the diff section (a scope.diffPath reference when present,
473
+ * else an explicit unavailable-pointer disclosure) instead of inlining the
474
+ * diff text in a fenced block. No `gates.*` config knob exists for this
475
+ * yet — a named constant is the right size for a single fixed threshold;
476
+ * promote to config only if a real need for tuning it emerges.
477
+ */
478
+ export const BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES = 200 * 1024;
479
+
480
+ /**
481
+ * Render the invariant briefing-prefix text (GATE-EXEC-BRIEFING-PREFIX):
482
+ * header (repo/PR/head/gate/worktree + the mandatory verify-fresh-review-context.mjs
483
+ * instruction), PR body, linked-issue body (when present), the full diff at the
484
+ * reviewed head (inlined up to `capBytes`, else a pointer to `diffPath`), and a
485
+ * changed-files/adjacent-code summary — in that fixed order. Pure and
486
+ * deterministic: identical input always renders identical bytes, so two builds
487
+ * at the same head produce a byte-identical prefix (the fan-out's shared-prefix
488
+ * requirement).
489
+ *
490
+ * @param {object} input
491
+ * @param {string} input.repo
492
+ * @param {number|string} input.pr
493
+ * @param {string} input.gate
494
+ * @param {string} input.headSha
495
+ * @param {string} input.worktreeRoot — absolute path reviewers run in
496
+ * @param {string} input.contextPath — the sibling JSON artifact path
497
+ * @param {string} input.briefingPrefixPath — this rendered file's own path
498
+ * @param {string|null} [input.prBody]
499
+ * @param {string|null} [input.issueRef] — label for the linked-issue section (e.g. "#877")
500
+ * @param {string|null} [input.issueBody] — omit the whole section when null/empty
501
+ * @param {string|null} [input.diffOutput] — full diff text, when captured
502
+ * @param {string|null} [input.diffPath] — persisted `.diff` pointer (pointer-mode fallback)
503
+ * @param {string[]} [input.changedFiles]
504
+ * @param {object|null} [input.adjacentCode] — buildAdjacentBundle output
505
+ * @param {number} [input.capBytes] — default BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES
506
+ * @returns {{ text: string, prefixMode: "inline"|"pointer", diffBytes: number }}
507
+ */
508
+ export function renderBriefingPrefix({
509
+ repo, pr, gate, headSha, worktreeRoot, contextPath, briefingPrefixPath,
510
+ prBody = null, issueRef = null, issueBody = null,
511
+ diffOutput = null, diffPath = null, changedFiles = [], adjacentCode = null,
512
+ capBytes = BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES,
513
+ }) {
514
+ const hasDiffText = typeof diffOutput === "string" && diffOutput.length > 0;
515
+ const diffBytes = hasDiffText ? Buffer.byteLength(diffOutput, "utf8") : 0;
516
+ const prefixMode = hasDiffText && diffBytes > capBytes ? "pointer" : "inline";
517
+
518
+ const lines = [];
519
+ lines.push("# Gate Review Briefing — invariant prefix (GATE-EXEC-BRIEFING-PREFIX)");
520
+ lines.push("");
521
+ lines.push(`repo: ${repo}`);
522
+ lines.push(`pr: #${pr}`);
523
+ lines.push(`gate: ${gate}`);
524
+ lines.push(`head: ${headSha}`);
525
+ lines.push(`worktree: ${worktreeRoot}`);
526
+ lines.push(`prefixMode: ${prefixMode}`);
527
+ lines.push("");
528
+ lines.push(
529
+ `Mandatory: before doing any angle-specific work, run \`node scripts/github/verify-fresh-review-context.mjs --scope ${gate.replace(/_/g, "-")}-<your-angle> --context-path ${contextPath} --prefix-file ${briefingPrefixPath}\`. Refuse to proceed on contamination or a missing artifact.`,
530
+ );
531
+ lines.push("");
532
+ lines.push("## PR body");
533
+ lines.push("");
534
+ lines.push(typeof prBody === "string" && prBody.trim().length > 0 ? prBody.trim() : "(no PR body provided)");
535
+ lines.push("");
536
+ const hasIssueBody = typeof issueBody === "string" && issueBody.trim().length > 0;
537
+ if (hasIssueBody) {
538
+ lines.push(`## Linked issue${issueRef ? ` ${issueRef}` : ""}`);
539
+ lines.push("");
540
+ lines.push(issueBody.trim());
541
+ lines.push("");
542
+ }
543
+ lines.push(`## Diff at reviewed head (${headSha})`);
544
+ lines.push("");
545
+ if (!hasDiffText) {
546
+ lines.push("(no diff text captured for this bundle)");
547
+ } else if (prefixMode === "inline") {
548
+ lines.push("```diff");
549
+ lines.push(diffOutput.endsWith("\n") ? diffOutput.slice(0, -1) : diffOutput);
550
+ lines.push("```");
551
+ } else {
552
+ lines.push(
553
+ `Diff exceeds the ${capBytes}-byte inline cap (${diffBytes} bytes) — pointer mode. Read the full diff from:`,
554
+ );
555
+ lines.push(` ${diffPath ?? "(diff pointer unavailable — re-derive with git diff)"}`);
556
+ }
557
+ lines.push("");
558
+ lines.push("## Changed files + adjacent-code summary");
559
+ lines.push("");
560
+ const files = Array.isArray(changedFiles) ? changedFiles : [];
561
+ lines.push(`Changed files (${files.length}):`);
562
+ for (const f of files) lines.push(`- ${f}`);
563
+ const adjacentFiles = adjacentCode && Array.isArray(adjacentCode.files)
564
+ ? adjacentCode.files.filter((f) => f.role !== "changed")
565
+ : [];
566
+ if (adjacentCode) {
567
+ lines.push(`Adjacent files (${adjacentFiles.length}):`);
568
+ for (const f of adjacentFiles) lines.push(`- ${f.path} (${f.role})`);
569
+ lines.push(
570
+ `Stripped: ${adjacentCode.stripped?.length ?? 0}, Truncated: ${adjacentCode.truncated?.length ?? 0}, Missing: ${adjacentCode.missing?.length ?? 0}`,
571
+ );
572
+ } else {
573
+ lines.push("Adjacent files (0): (no adjacent-code bundle for this briefing)");
574
+ }
575
+
576
+ return { text: lines.join("\n") + "\n", prefixMode, diffBytes };
577
+ }
578
+
429
579
  /**
430
580
  * Parse `git diff --name-status` output into full repo-relative changed file
431
581
  * paths. Handles rename/copy entries (R100 old new, C75 old new) by recording
@@ -500,6 +650,12 @@ export function buildGateContextArtifact(options) {
500
650
  if (options.adjacentCode && typeof options.adjacentCode === "object") {
501
651
  artifact.adjacentCode = options.adjacentCode;
502
652
  }
653
+ // Whether the rendered briefing prefix inlined the reviewed-head diff (in a
654
+ // fenced block) or fell back to the diffPath pointer (size cap). Only set
655
+ // when a caller actually rendered a prefix (writeGateContext does, always).
656
+ if (typeof options.prefixMode === "string" && options.prefixMode.length > 0) {
657
+ artifact.prefixMode = options.prefixMode;
658
+ }
503
659
  return artifact;
504
660
  }
505
661
 
@@ -520,7 +676,7 @@ export function buildGateContextArtifact(options) {
520
676
  * @param {string} input.tmpRoot
521
677
  * @param {number} [input.maxFileBytes]
522
678
  * @param {{ repoRoot: string }} opts
523
- * @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null }>}
679
+ * @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null, diffOutput: string|null }>}
524
680
  */
525
681
  async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFileBytes }, { repoRoot }) {
526
682
  const diffOutput = diff?.diffOutput;
@@ -562,7 +718,7 @@ async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFil
562
718
  }
563
719
  }
564
720
 
565
- return { diffPath, changedFiles, adjacentCode };
721
+ return { diffPath, changedFiles, adjacentCode, diffOutput: typeof diffOutput === "string" ? diffOutput : null };
566
722
  }
567
723
 
568
724
  /**
@@ -647,6 +803,19 @@ export function captureDiffFromBase(base, { repoRoot, maxBuffer = 64 * 1024 * 10
647
803
  return { nameStatusOutput, diffOutput };
648
804
  }
649
805
 
806
+ /**
807
+ * Write both the JSON context artifact AND its sibling rendered briefing
808
+ * prefix (GATE-EXEC-BRIEFING-PREFIX): the byte-identical invariant block every
809
+ * per-angle reviewer of this gate pass is seeded with. The prefix's
810
+ * `prefixMode` (inline|pointer, from the diff-size cap) is recorded on the
811
+ * JSON artifact so both files stay in sync.
812
+ *
813
+ * @param {object} options — parsed CLI options shape, optionally carrying
814
+ * `diffOutput`, `prBody`, `issueBody` (all null-safe; a caller with none of
815
+ * these still gets a valid — if thin — prefix).
816
+ * @param {{ repoRoot?: string }} [runtime]
817
+ * @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer" }>}
818
+ */
650
819
  export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
651
820
  const contextPath = buildGateContextPath({
652
821
  repo: options.repo,
@@ -655,14 +824,49 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
655
824
  headSha: options.headSha,
656
825
  tmpRoot: options.tmpRoot || "tmp",
657
826
  });
827
+ const briefingPrefixPath = buildGateBriefingPrefixPath({
828
+ repo: options.repo,
829
+ pr: options.pr,
830
+ gate: options.gate,
831
+ headSha: options.headSha,
832
+ tmpRoot: options.tmpRoot || "tmp",
833
+ });
834
+ const rendered = renderBriefingPrefix({
835
+ repo: options.repo,
836
+ pr: options.pr,
837
+ gate: options.gate,
838
+ headSha: options.headSha,
839
+ worktreeRoot: path.resolve(repoRoot),
840
+ contextPath,
841
+ briefingPrefixPath,
842
+ prBody: options.prBody ?? null,
843
+ issueRef: options.acceptanceCriteria ?? null,
844
+ issueBody: options.issueBody ?? null,
845
+ diffOutput: options.diffOutput ?? null,
846
+ diffPath: options.diffPath ?? null,
847
+ changedFiles: options.changedFiles ?? [],
848
+ adjacentCode: options.adjacentCode ?? null,
849
+ });
850
+
658
851
  const fullPath = path.resolve(repoRoot, contextPath);
659
852
  const artifact = {
660
- ...buildGateContextArtifact(options),
853
+ ...buildGateContextArtifact({ ...options, prefixMode: rendered.prefixMode }),
661
854
  loggedAt: new Date().toISOString(),
662
855
  };
856
+ // Write ORDER matters: the sibling briefing prefix goes first and the JSON
857
+ // artifact last, so the artifact's existence is the completion marker for
858
+ // the whole set. Downstream consumers (readGateContext, the reviewers'
859
+ // --context-path guard) key on the JSON — a prefix-write failure must not
860
+ // leave a complete-looking artifact pointing at a missing prefix file.
861
+ const fullPrefixPath = path.resolve(repoRoot, briefingPrefixPath);
862
+ await mkdir(path.dirname(fullPrefixPath), { recursive: true });
863
+ await writeFile(fullPrefixPath, rendered.text, "utf8");
864
+ const prefixHash = createHash("sha256").update(rendered.text, "utf8").digest("hex");
865
+
663
866
  await mkdir(path.dirname(fullPath), { recursive: true });
664
867
  await writeFile(fullPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
665
- return { ok: true, path: contextPath, artifact };
868
+
869
+ return { ok: true, path: contextPath, artifact, prefixPath: briefingPrefixPath, prefixHash, prefixMode: rendered.prefixMode };
666
870
  }
667
871
 
668
872
  /**
@@ -685,10 +889,12 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
685
889
  * @param {string[]} [input.touchedFiles]
686
890
  * @param {string|null} [input.acceptanceCriteria]
687
891
  * @param {string|null} [input.validationPosture]
892
+ * @param {string|null} [input.prBody] — PR description text, inlined into the rendered briefing prefix
893
+ * @param {string|null} [input.issueBody] — linked-issue body text, inlined under `acceptanceCriteria`'s label; omitted when absent
688
894
  * @param {number} [input.maxFileBytes] — per-file cap for the adjacent-code bundle (default DEFAULT_MAX_FILE_BYTES)
689
895
  * @param {string} [input.tmpRoot]
690
896
  * @param {{ repoRoot?: string }} [opts]
691
- * @returns {Promise<{ ok: boolean, path: string, artifact: object, resolver: object }>}
897
+ * @returns {Promise<{ ok: boolean, path: string, artifact: object, prefixPath: string, prefixHash: string, prefixMode: "inline"|"pointer", resolver: object }>}
692
898
  *
693
899
  * The artifact additionally carries a deterministic, neutral `adjacentCode`
694
900
  * bundle (#895) when changed files are present: 1-hop import in/out-edges of the
@@ -707,7 +913,7 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
707
913
  // Diff-derived scope: persisted FULL diff (scope.diffPath), parsed
708
914
  // scope.changedFiles, and the neutral adjacentCode bundle, all built ONCE by
709
915
  // the shared resolveDiffScope helper (also used by the CLI --base path, #1140).
710
- const { diffPath, changedFiles, adjacentCode } = await resolveDiffScope(
916
+ const { diffPath, changedFiles, adjacentCode, diffOutput } = await resolveDiffScope(
711
917
  { diff: input.diff, repo: input.repo, pr: input.pr, gate: input.gate, headSha: input.headSha, tmpRoot, maxFileBytes: input.maxFileBytes },
712
918
  { repoRoot },
713
919
  );
@@ -724,9 +930,12 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
724
930
  touchedFiles: input.touchedFiles ?? [],
725
931
  changedFiles,
726
932
  diffPath,
933
+ diffOutput,
727
934
  adjacentCode,
728
935
  acceptanceCriteria: input.acceptanceCriteria ?? null,
729
936
  validationPosture: input.validationPosture ?? null,
937
+ prBody: input.prBody ?? null,
938
+ issueBody: input.issueBody ?? null,
730
939
  tmpRoot,
731
940
  },
732
941
  { repoRoot },
@@ -800,6 +1009,7 @@ export async function main(argv = process.argv.slice(2), { repoRoot = process.cw
800
1009
  options.changedFiles = scope.changedFiles;
801
1010
  options.diffPath = scope.diffPath;
802
1011
  options.adjacentCode = scope.adjacentCode;
1012
+ options.diffOutput = scope.diffOutput;
803
1013
  options.diffSource = "base";
804
1014
  } else {
805
1015
  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({
@@ -8,7 +8,7 @@
8
8
  // It lives in scripts/loop/ (not packages/core) because significance is derived
9
9
  // from a `gh api .../compare` diff — gh I/O that does not belong in core.
10
10
  import { extractReviewCommitSha, isCopilotLogin, parseJsonText } from "../_core-helpers.mjs";
11
- import { runChild } from "../_cli-primitives.mjs";
11
+ import { runChild as defaultRunChild } from "../_cli-primitives.mjs";
12
12
 
13
13
  export function getLatestSubmittedCopilotReviewHeadSha(reviews) {
14
14
  const copilotSubmitted = (Array.isArray(reviews) ? reviews : [])
@@ -149,7 +149,7 @@ export function isCommentOnlyFileChange(file) {
149
149
 
150
150
  export async function detectPostConvergenceSignificantChange(
151
151
  { repo, pr, currentHeadSha, reviews, changedFiles, roundCapReached, regularCopilotRounds },
152
- { env = process.env, ghCommand = "gh" } = {},
152
+ { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
153
153
  ) {
154
154
  if (!roundCapReached || !regularCopilotRounds) {
155
155
  return false;