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
@@ -17,7 +17,9 @@
17
17
  * This module maps that resolver's output into the persisted artifact:
18
18
  * resolvedAngles = resolver.recommendedAngles
19
19
  * rationale = resolver.skippedAngles (action 'dropped', reason from
20
- * resolver.reasons) + the rest as action 'kept'
20
+ * resolver.reasons) + the rest as action 'kept', except
21
+ * entries present in resolver.addedAngles are recorded as
22
+ * action 'added' (reason from resolver.addedReasons) — see #1048
21
23
  *
22
24
  * The artifact records the resolved angle set + rationale + change scope
23
25
  * (branch, head SHA, touched files, acceptance-criteria pointer, validation
@@ -26,6 +28,8 @@
26
28
  * Path scheme mirrors write-gate-findings-log.mjs `buildLogPath`:
27
29
  * <tmpRoot>/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json
28
30
  */
31
+ import { execFileSync } from "node:child_process";
32
+ import { createHash } from "node:crypto";
29
33
  import { mkdir, readFile, writeFile } from "node:fs/promises";
30
34
  import path from "node:path";
31
35
  import { parseArgs } from "node:util";
@@ -35,6 +39,7 @@ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
35
39
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
36
40
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
37
41
  import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
42
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
38
43
 
39
44
  /**
40
45
  * Map the artifact gate name (draft_gate | pre_approval_gate) to the config
@@ -52,8 +57,12 @@ export function mapGateToConfigKey(gate) {
52
57
  * Map a resolveGateAnglesDynamic result into the persisted artifact fields.
53
58
  * Does NOT re-derive angles — it only reshapes the resolver's output.
54
59
  *
55
- * @param {{ recommendedAngles: string[]|null, skippedAngles?: string[], reasons?: Record<string,string> }} resolverResult
56
- * @returns {{ resolvedAngles: string[], rationale: Array<{angle: string, action: "kept"|"dropped", reason: string}> }}
60
+ * Angles present in `resolverResult.addedAngles` (additive selection, #1048)
61
+ * are recorded with action 'added' (reason from `resolverResult.addedReasons`)
62
+ * instead of 'kept'.
63
+ *
64
+ * @param {{ recommendedAngles: string[]|null, skippedAngles?: string[], reasons?: Record<string,string>, addedAngles?: string[], addedReasons?: Record<string,string>, dynamicAnglesActive?: boolean }} resolverResult
65
+ * @returns {{ resolvedAngles: string[], rationale: Array<{angle: string, action: "kept"|"added"|"dropped", reason: string}> }}
57
66
  */
58
67
  export function rationaleFromResolver(resolverResult) {
59
68
  const recommended = Array.isArray(resolverResult?.recommendedAngles)
@@ -63,6 +72,8 @@ export function rationaleFromResolver(resolverResult) {
63
72
  ? resolverResult.skippedAngles
64
73
  : [];
65
74
  const reasons = resolverResult?.reasons ?? {};
75
+ const added = new Set(Array.isArray(resolverResult?.addedAngles) ? resolverResult.addedAngles : []);
76
+ const addedReasons = resolverResult?.addedReasons ?? {};
66
77
  const dynamicActive = resolverResult?.dynamicAnglesActive === true;
67
78
  const keptReason = dynamicActive
68
79
  ? "selected by dynamic angle resolver"
@@ -70,6 +81,16 @@ export function rationaleFromResolver(resolverResult) {
70
81
 
71
82
  const rationale = [];
72
83
  for (const angle of recommended) {
84
+ if (added.has(angle) && dynamicActive) {
85
+ rationale.push({
86
+ angle,
87
+ action: "added",
88
+ reason: typeof addedReasons[angle] === "string" && addedReasons[angle].length > 0
89
+ ? addedReasons[angle]
90
+ : "added by dynamic angle resolver (catalog addition)",
91
+ });
92
+ continue;
93
+ }
73
94
  rationale.push({ angle, action: "kept", reason: keptReason });
74
95
  }
75
96
  for (const angle of skipped) {
@@ -84,7 +105,7 @@ export function rationaleFromResolver(resolverResult) {
84
105
  return { resolvedAngles: [...recommended], rationale };
85
106
  }
86
107
 
87
- const USAGE = `Usage: write-gate-context.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --angles <json> [--rationale <json>] [--branch <name>] [--touched-files <json>] [--acceptance-criteria <pointer>] [--validation-posture <text>] [--tmp-root <path>]
108
+ const USAGE = `Usage: write-gate-context.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --angles <json> [--rationale <json>] [--branch <name>] [--touched-files <json>] [--base <ref>] [--acceptance-criteria <pointer>] [--validation-posture <text>] [--tmp-root <path>]
88
109
  Write a deterministic gate-review context-builder handoff artifact under tmp/ paths.
89
110
  Required:
90
111
  --repo <owner/name>
@@ -95,10 +116,15 @@ Required:
95
116
  Optional:
96
117
  --rationale <json> JSON array of {angle, action, reason} entries
97
118
  --branch <name> Source branch name
98
- --touched-files <json> JSON array of changed file path strings
99
- --acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL)
119
+ --touched-files <json> JSON array of changed file path strings (separate from the diff-derived scope.changedFiles)
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.
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
100
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
101
125
  --tmp-root <path> Root tmp directory (default: tmp/)
126
+
127
+ ${JQ_OUTPUT_USAGE}
102
128
  `.trim();
103
129
 
104
130
  function parseError(message) {
@@ -116,6 +142,22 @@ function normalizeHeadSha(value) {
116
142
  return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
117
143
  }
118
144
 
145
+ // ponytail: a DENYLIST, not an allowlist — the `git diff` call runs via
146
+ // execFileSync's argv array (no shell), so `base` cannot inject shell syntax
147
+ // and we don't need to enumerate every valid git revision grammar. We reject
148
+ // only what is genuinely unsafe or malformed for OUR use, and let `git diff`
149
+ // itself resolve validity (a syntactically-allowed but nonexistent ref fails
150
+ // closed via the unresolvable-base path). Rejected: empty/whitespace-only; a
151
+ // leading "-" (flag-injection shape, never a valid ref); and ".." (ambiguous
152
+ // with our own "<base>...HEAD" triple-dot construction). Everything else —
153
+ // including HEAD@{upstream}, main@{1}, tag-peel v1.0.0^{commit}, HEAD~3 — is
154
+ // accepted.
155
+ function normalizeBaseRef(value) {
156
+ const trimmed = String(value).trim();
157
+ if (trimmed.length === 0 || trimmed.startsWith("-") || trimmed.includes("..")) return null;
158
+ return trimmed;
159
+ }
160
+
119
161
  const VALID_ACTIONS = new Set(["kept", "added", "dropped", "joined"]);
120
162
 
121
163
  function parseAnglesJson(raw) {
@@ -189,9 +231,13 @@ export function parseWriteGateContextCliArgs(argv) {
189
231
  rationale: { type: "string" },
190
232
  branch: { type: "string" },
191
233
  "touched-files": { type: "string" },
234
+ base: { type: "string" },
192
235
  "acceptance-criteria": { type: "string" },
193
236
  "validation-posture": { type: "string" },
237
+ "pr-body": { type: "string" },
238
+ "issue-body": { type: "string" },
194
239
  "tmp-root": { type: "string" },
240
+ ...JQ_OUTPUT_PARSE_OPTIONS,
195
241
  },
196
242
  allowPositionals: true,
197
243
  strict: false,
@@ -206,8 +252,11 @@ export function parseWriteGateContextCliArgs(argv) {
206
252
  rationale: [],
207
253
  branch: null,
208
254
  touchedFiles: [],
255
+ base: null,
209
256
  acceptanceCriteria: null,
210
257
  validationPosture: null,
258
+ prBody: null,
259
+ issueBody: null,
211
260
  tmpRoot: "tmp",
212
261
  };
213
262
  for (const token of tokens) {
@@ -256,6 +305,12 @@ export function parseWriteGateContextCliArgs(argv) {
256
305
  options.touchedFiles = parseStringArrayJson(requireTokenValue(token, parseError), "--touched-files");
257
306
  continue;
258
307
  }
308
+ if (token.name === "base") {
309
+ const base = normalizeBaseRef(requireTokenValue(token, parseError));
310
+ if (!base) throw parseError("--base must be a plausible git ref (no leading '-', no '..')");
311
+ options.base = base;
312
+ continue;
313
+ }
259
314
  if (token.name === "acceptance-criteria") {
260
315
  options.acceptanceCriteria = requireTokenValue(token, parseError).trim();
261
316
  continue;
@@ -264,10 +319,19 @@ export function parseWriteGateContextCliArgs(argv) {
264
319
  options.validationPosture = requireTokenValue(token, parseError).trim();
265
320
  continue;
266
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
+ }
267
330
  if (token.name === "tmp-root") {
268
331
  options.tmpRoot = requireTokenValue(token, parseError).trim();
269
332
  continue;
270
333
  }
334
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
271
335
  throw parseError(`Unknown argument: ${token.rawName}`);
272
336
  }
273
337
  const missing = ["repo", "pr", "gate", "headSha", "angles"]
@@ -377,6 +441,137 @@ export function buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot = "tmp" })
377
441
  return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.diff`);
378
442
  }
379
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
+
380
575
  /**
381
576
  * Parse `git diff --name-status` output into full repo-relative changed file
382
577
  * paths. Handles rename/copy entries (R100 old new, C75 old new) by recording
@@ -437,15 +632,186 @@ export function buildGateContextArtifact(options) {
437
632
  validationPosture: options.validationPosture ?? null,
438
633
  },
439
634
  };
635
+ // ADD (#1140): an explicit posture marker distinguishing a full build-once
636
+ // bundle from a thin briefing. Only set by the CLI (`--base` present → "base";
637
+ // absent → "none"); programmatic `buildGateContext`/`writeGateContext` callers
638
+ // that never pass `diffSource` leave the field out entirely, so this stays
639
+ // backward compatible with the artifact shape they already assert on.
640
+ if (typeof options.diffSource === "string" && options.diffSource.length > 0) {
641
+ artifact.scope.diffSource = options.diffSource;
642
+ }
440
643
  // ADD (#895): the deterministic, neutral adjacent-code bundle. Only present
441
644
  // when the context-builder computed it — keeps the artifact shape backward
442
645
  // compatible for callers that build the artifact without an adjacency pass.
443
646
  if (options.adjacentCode && typeof options.adjacentCode === "object") {
444
647
  artifact.adjacentCode = options.adjacentCode;
445
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
+ }
446
655
  return artifact;
447
656
  }
448
657
 
658
+ /**
659
+ * Resolve the diff-derived scope fields shared by `buildGateContext` (the
660
+ * programmatic `{ diff }` path) and the CLI `--base` path (#1140): the FULL
661
+ * diff persisted to a deterministic `.diff` file, the parsed `changedFiles`,
662
+ * and the neutral `adjacentCode` bundle built once from those changed files.
663
+ * Extracted to a single function so both callers stay in sync — see the
664
+ * doc comment on {@link buildGateContext} for the field semantics.
665
+ *
666
+ * @param {object} input
667
+ * @param {{ nameStatusOutput: string, diffOutput?: string }} [input.diff]
668
+ * @param {string} input.repo
669
+ * @param {number|string} input.pr
670
+ * @param {string} input.gate
671
+ * @param {string} input.headSha
672
+ * @param {string} input.tmpRoot
673
+ * @param {number} [input.maxFileBytes]
674
+ * @param {{ repoRoot: string }} opts
675
+ * @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null, diffOutput: string|null }>}
676
+ */
677
+ async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFileBytes }, { repoRoot }) {
678
+ const diffOutput = diff?.diffOutput;
679
+ let diffPath = null;
680
+ const changedFiles = parseChangedFiles(diff?.nameStatusOutput);
681
+ if (typeof diffOutput === "string" && diffOutput.length > 0) {
682
+ diffPath = buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot });
683
+ const fullDiffPath = path.resolve(repoRoot, diffPath);
684
+ try {
685
+ await mkdir(path.dirname(fullDiffPath), { recursive: true });
686
+ await writeFile(fullDiffPath, diffOutput.endsWith("\n") ? diffOutput : diffOutput + "\n", "utf8");
687
+ } catch (err) {
688
+ // Best-effort: a diff-file write failure (disk, permissions) must not block
689
+ // the context artifact. Degrade to diffPath=null; reviewers reconstruct the
690
+ // diff with `git diff`. changedFiles (from nameStatusOutput) is unaffected.
691
+ process.stderr.write(`[gate-context] full-diff capture failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
692
+ diffPath = null;
693
+ }
694
+ }
695
+
696
+ // Build the deterministic, neutral adjacent-code bundle ONCE (#895): for each
697
+ // changed source file, include its 1-hop import out-edges (files it imports)
698
+ // and in-edges (files that import it), with size guards (skip
699
+ // lockfiles/generated/binary/minified; cap per-file bytes; truncate the long
700
+ // tail) recorded in a stripped/truncated manifest. Every independent reviewer
701
+ // is seeded with this identical bundle instead of re-deriving it — work-dedup.
702
+ // Best-effort: bundle computation must never block the context artifact.
703
+ let adjacentCode = null;
704
+ if (changedFiles.length > 0) {
705
+ try {
706
+ adjacentCode = await buildAdjacentBundle({
707
+ changedFiles,
708
+ repoRoot,
709
+ maxFileBytes: typeof maxFileBytes === "number" && maxFileBytes > 0 ? maxFileBytes : DEFAULT_MAX_FILE_BYTES,
710
+ });
711
+ } catch (err) {
712
+ process.stderr.write(`[gate-context] adjacent-code bundle failed (continuing without adjacentCode): ${err?.message ?? err}\n`);
713
+ adjacentCode = null;
714
+ }
715
+ }
716
+
717
+ return { diffPath, changedFiles, adjacentCode, diffOutput: typeof diffOutput === "string" ? diffOutput : null };
718
+ }
719
+
720
+ /**
721
+ * Capture a diff against `base` for the CLI `--base <ref>` flag: `git diff
722
+ * --name-status <base>...HEAD` and `git diff <base>...HEAD`, shaped as the
723
+ * `{ nameStatusOutput, diffOutput }` input `resolveDiffScope`/`buildGateContext`
724
+ * expect. Uses execFileSync with an argv array (no shell), so `base` cannot
725
+ * inject shell syntax; `parseWriteGateContextCliArgs` additionally validates it
726
+ * looks like a plausible ref before this runs.
727
+ *
728
+ * Split posture:
729
+ * - the `--name-status` capture is FAIL-CLOSED: it drives scope.changedFiles +
730
+ * the adjacentCode bundle, so an unresolvable base / non-git-repo throws and
731
+ * the CLI writes NO artifact (never a silent thin briefing).
732
+ * - the FULL diff capture is BEST-EFFORT: if it fails (output exceeds maxBuffer,
733
+ * a rendering error, etc.) we warn and return an EMPTY diffOutput. downstream
734
+ * resolveDiffScope then leaves scope.diffPath null while still populating
735
+ * changedFiles + adjacentCode; reviewers fall back to re-deriving the diff
736
+ * (the existing safety net). scope.diffSource stays "base" — it IS a
737
+ * base-derived bundle, just without the persisted full diff.
738
+ *
739
+ * @param {string} base
740
+ * @param {{ repoRoot: string, maxBuffer?: number }} opts — maxBuffer overridable for tests
741
+ * @returns {{ nameStatusOutput: string, diffOutput: string }}
742
+ */
743
+ export function captureDiffFromBase(base, { repoRoot, maxBuffer = 64 * 1024 * 1024 }) {
744
+ const range = `${base}...HEAD`;
745
+ // Isolate the persisted .diff BYTES from ambient global/system gitconfig so
746
+ // every reviewer is seeded with an IDENTICAL neutral bundle (the whole point
747
+ // of build-once). Without this isolation, an operator/CI with
748
+ // color.diff=always, a configured diff.external/difftool, or non-default
749
+ // prefix settings would make scope.diffPath environment-dependent.
750
+ // color.ui=false + color.diff=false strip ANSI; core.pager=cat neutralizes a
751
+ // configured pager; diff.noprefix=false + diff.mnemonicPrefix=false pin the
752
+ // a/ b/ prefixes; the --no-ext-diff flag (below) disables any external diff
753
+ // driver (NOT `-c diff.external=`, which makes git try to exec the empty
754
+ // string and die).
755
+ // CROSS-environment reproducibility (#1168): the overrides above only pin
756
+ // bytes WITHIN a single run/machine — an operator/CI box with a contrary
757
+ // local diff.renames/diff.algorithm/diff.context/core.abbrev/core.autocrlf
758
+ // would still produce different bytes than another box on the SAME base and
759
+ // HEAD. diff.algorithm=myers + diff.context=3 + core.abbrev=12 +
760
+ // core.autocrlf=false pin the diff body/hunk headers/blob-id length/line
761
+ // endings. diff.renames=true additionally pins rename DETECTION itself: with
762
+ // it off, a moved-and-edited file shows as a straight D+A pair in
763
+ // `--name-status` instead of an R### pair, which changes the SET of names in
764
+ // scope.changedFiles (and therefore adjacentCode's membership) across
765
+ // environments, not just the diff body's bytes.
766
+ const isolation = [
767
+ "-c", "color.ui=false",
768
+ "-c", "color.diff=false",
769
+ "-c", "core.pager=cat",
770
+ "-c", "diff.noprefix=false",
771
+ "-c", "diff.mnemonicPrefix=false",
772
+ "-c", "diff.renames=true",
773
+ "-c", "diff.algorithm=myers",
774
+ "-c", "diff.context=3",
775
+ "-c", "core.abbrev=12",
776
+ "-c", "core.autocrlf=false",
777
+ ];
778
+ const runGit = (args) => execFileSync("git", [...isolation, ...args], {
779
+ cwd: repoRoot,
780
+ encoding: "utf8",
781
+ maxBuffer,
782
+ });
783
+ // FAIL-CLOSED: --name-status feeds changedFiles + adjacentCode (the bundle's core).
784
+ let nameStatusOutput;
785
+ try {
786
+ nameStatusOutput = runGit(["diff", "--no-ext-diff", "--name-status", range]);
787
+ } catch (err) {
788
+ throw new Error(`git diff against --base ${JSON.stringify(base)} failed: ${err?.message ?? err}`);
789
+ }
790
+ // BEST-EFFORT: the full diff only feeds the persisted scope.diffPath; on
791
+ // failure degrade to an empty diffOutput (diffPath becomes null downstream).
792
+ let diffOutput = "";
793
+ try {
794
+ diffOutput = runGit(["diff", "--no-ext-diff", range]);
795
+ } catch (err) {
796
+ process.stderr.write(`[gate-context] full-diff capture against --base ${JSON.stringify(base)} failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
797
+ diffOutput = "";
798
+ }
799
+ return { nameStatusOutput, diffOutput };
800
+ }
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
+ */
449
815
  export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
450
816
  const contextPath = buildGateContextPath({
451
817
  repo: options.repo,
@@ -454,14 +820,49 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
454
820
  headSha: options.headSha,
455
821
  tmpRoot: options.tmpRoot || "tmp",
456
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
+
457
847
  const fullPath = path.resolve(repoRoot, contextPath);
458
848
  const artifact = {
459
- ...buildGateContextArtifact(options),
849
+ ...buildGateContextArtifact({ ...options, prefixMode: rendered.prefixMode }),
460
850
  loggedAt: new Date().toISOString(),
461
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
+
462
862
  await mkdir(path.dirname(fullPath), { recursive: true });
463
863
  await writeFile(fullPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
464
- return { ok: true, path: contextPath, artifact };
864
+
865
+ return { ok: true, path: contextPath, artifact, prefixPath: briefingPrefixPath, prefixHash, prefixMode: rendered.prefixMode };
465
866
  }
466
867
 
467
868
  /**
@@ -484,10 +885,12 @@ export async function writeGateContext(options, { repoRoot = process.cwd() } = {
484
885
  * @param {string[]} [input.touchedFiles]
485
886
  * @param {string|null} [input.acceptanceCriteria]
486
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
487
890
  * @param {number} [input.maxFileBytes] — per-file cap for the adjacent-code bundle (default DEFAULT_MAX_FILE_BYTES)
488
891
  * @param {string} [input.tmpRoot]
489
892
  * @param {{ repoRoot?: string }} [opts]
490
- * @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 }>}
491
894
  *
492
895
  * The artifact additionally carries a deterministic, neutral `adjacentCode`
493
896
  * bundle (#895) when changed files are present: 1-hop import in/out-edges of the
@@ -503,58 +906,13 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
503
906
 
504
907
  const tmpRoot = input.tmpRoot || "tmp";
505
908
 
506
- // Best-effort full-diff capture: when the resolver was handed a diff with
507
- // `diffOutput`, persist the ENTIRE diff to a deterministic `.diff` file next
508
- // to the context artifact so scoped reviewers can read the full change set
509
- // (not just hunks) and trace adjacent code. Reference it from `scope.diffPath`
510
- // (relative) and record full repo-relative `scope.changedFiles` parsed from
511
- // the diff's `nameStatusOutput`. We do NOT inline the diff in the JSON.
512
- const diffOutput = input.diff?.diffOutput;
513
- let diffPath = null;
514
- let changedFiles = parseChangedFiles(input.diff?.nameStatusOutput);
515
- if (typeof diffOutput === "string" && diffOutput.length > 0) {
516
- diffPath = buildGateDiffPath({
517
- repo: input.repo,
518
- pr: input.pr,
519
- gate: input.gate,
520
- headSha: input.headSha,
521
- tmpRoot,
522
- });
523
- const fullDiffPath = path.resolve(repoRoot, diffPath);
524
- try {
525
- await mkdir(path.dirname(fullDiffPath), { recursive: true });
526
- await writeFile(fullDiffPath, diffOutput.endsWith("\n") ? diffOutput : diffOutput + "\n", "utf8");
527
- } catch (err) {
528
- // Best-effort: a diff-file write failure (disk, permissions) must not block
529
- // the context artifact. Degrade to diffPath=null; reviewers reconstruct the
530
- // diff with `git diff`. changedFiles (from nameStatusOutput) is unaffected.
531
- process.stderr.write(`[gate-context] full-diff capture failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
532
- diffPath = null;
533
- }
534
- }
535
-
536
- // Build the deterministic, neutral adjacent-code bundle ONCE (#895): for each
537
- // changed source file, include its 1-hop import out-edges (files it imports)
538
- // and in-edges (files that import it), with size guards (skip
539
- // lockfiles/generated/binary/minified; cap per-file bytes; truncate the long
540
- // tail) recorded in a stripped/truncated manifest. Every independent reviewer
541
- // is seeded with this identical bundle instead of re-deriving it — work-dedup.
542
- // Best-effort: bundle computation must never block the context artifact.
543
- let adjacentCode = null;
544
- if (changedFiles.length > 0) {
545
- try {
546
- adjacentCode = await buildAdjacentBundle({
547
- changedFiles,
548
- repoRoot,
549
- maxFileBytes: typeof input.maxFileBytes === "number" && input.maxFileBytes > 0
550
- ? input.maxFileBytes
551
- : DEFAULT_MAX_FILE_BYTES,
552
- });
553
- } catch (err) {
554
- process.stderr.write(`[gate-context] adjacent-code bundle failed (continuing without adjacentCode): ${err?.message ?? err}\n`);
555
- adjacentCode = null;
556
- }
557
- }
909
+ // Diff-derived scope: persisted FULL diff (scope.diffPath), parsed
910
+ // scope.changedFiles, and the neutral adjacentCode bundle, all built ONCE by
911
+ // the shared resolveDiffScope helper (also used by the CLI --base path, #1140).
912
+ const { diffPath, changedFiles, adjacentCode, diffOutput } = await resolveDiffScope(
913
+ { diff: input.diff, repo: input.repo, pr: input.pr, gate: input.gate, headSha: input.headSha, tmpRoot, maxFileBytes: input.maxFileBytes },
914
+ { repoRoot },
915
+ );
558
916
 
559
917
  const writeResult = await writeGateContext(
560
918
  {
@@ -568,9 +926,12 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
568
926
  touchedFiles: input.touchedFiles ?? [],
569
927
  changedFiles,
570
928
  diffPath,
929
+ diffOutput,
571
930
  adjacentCode,
572
931
  acceptanceCriteria: input.acceptanceCriteria ?? null,
573
932
  validationPosture: input.validationPosture ?? null,
933
+ prBody: input.prBody ?? null,
934
+ issueBody: input.issueBody ?? null,
574
935
  tmpRoot,
575
936
  },
576
937
  { repoRoot },
@@ -604,10 +965,17 @@ export async function readGateContext(input, { repoRoot = process.cwd() } = {})
604
965
  }
605
966
  }
606
967
 
607
- async function main() {
968
+ /**
969
+ * CLI entrypoint. Exported (argv + repoRoot both overridable) so tests can
970
+ * drive the `--base` diff-capture path against a throwaway git repo fixture
971
+ * without spawning a subprocess.
972
+ * @param {string[]} [argv]
973
+ * @param {{ repoRoot?: string }} [runtime]
974
+ */
975
+ export async function main(argv = process.argv.slice(2), { repoRoot = process.cwd() } = {}) {
608
976
  let options;
609
977
  try {
610
- options = parseWriteGateContextCliArgs(process.argv.slice(2));
978
+ options = parseWriteGateContextCliArgs(argv);
611
979
  } catch (error) {
612
980
  process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
613
981
  process.exitCode = 1;
@@ -618,8 +986,33 @@ async function main() {
618
986
  return;
619
987
  }
620
988
  try {
621
- const result = await writeGateContext(options);
622
- process.stdout.write(JSON.stringify(result) + "\n");
989
+ // AC3 (#1140): the CLI only produces the full build-once bundle
990
+ // (scope.diffPath + scope.changedFiles + adjacentCode) when it has a
991
+ // resolvable diff source. --base is OPTIONAL rather than required — making
992
+ // it required would break any existing caller that only ever passed
993
+ // --touched-files (angles + scope already resolved elsewhere) — so a run
994
+ // without --base does not fail closed; it explicitly WARNS and records the
995
+ // thin-briefing posture in scope.diffSource so it is never mistaken for a
996
+ // full bundle. A run WITH --base that then fails to resolve (bad ref, not a
997
+ // git repo, etc.) DOES fail closed: the caller opted into the full bundle,
998
+ // so a silent thin degrade there would be a worse surprise than an error.
999
+ if (options.base) {
1000
+ const diff = captureDiffFromBase(options.base, { repoRoot });
1001
+ const scope = await resolveDiffScope(
1002
+ { diff, repo: options.repo, pr: options.pr, gate: options.gate, headSha: options.headSha, tmpRoot: options.tmpRoot || "tmp" },
1003
+ { repoRoot },
1004
+ );
1005
+ options.changedFiles = scope.changedFiles;
1006
+ options.diffPath = scope.diffPath;
1007
+ options.adjacentCode = scope.adjacentCode;
1008
+ options.diffOutput = scope.diffOutput;
1009
+ options.diffSource = "base";
1010
+ } else {
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");
1012
+ options.diffSource = "none";
1013
+ }
1014
+ const result = await writeGateContext(options, { repoRoot });
1015
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
623
1016
  } catch (error) {
624
1017
  process.stderr.write(JSON.stringify({
625
1018
  ok: false,