dev-loops 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/{info.md → loop-info.md} +2 -2
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/{start-spike.md → loop-start-spike.md} +1 -1
  11. package/.claude/commands/{start.md → loop-start.md} +1 -1
  12. package/.claude/commands/{status.md → loop-status.md} +1 -1
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  16. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  17. package/.claude/skills/dev-loop/SKILL.md +15 -11
  18. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  19. package/.claude/skills/docs/anti-patterns.md +2 -1
  20. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  21. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  22. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  23. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  24. package/.claude/skills/docs/local-planning-flow.md +1 -1
  25. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  26. package/.claude/skills/docs/merge-preconditions.md +17 -1
  27. package/.claude/skills/docs/plan-file-contract.md +1 -1
  28. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  29. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  30. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  31. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  32. package/.claude/skills/local-implementation/SKILL.md +25 -14
  33. package/.claude/skills/loop-grill/SKILL.md +163 -0
  34. package/AGENTS.md +1 -0
  35. package/CHANGELOG.md +95 -0
  36. package/README.md +9 -9
  37. package/agents/dev-loop.agent.md +3 -2
  38. package/agents/developer.agent.md +1 -1
  39. package/agents/docs.agent.md +1 -1
  40. package/agents/fixer.agent.md +1 -1
  41. package/agents/quality.agent.md +1 -1
  42. package/agents/refiner.agent.md +1 -1
  43. package/agents/review.agent.md +3 -2
  44. package/cli/index.mjs +35 -42
  45. package/extension/README.md +4 -4
  46. package/extension/checks.ts +1 -5
  47. package/extension/harness-types.ts +1 -0
  48. package/extension/index.ts +6 -0
  49. package/extension/pi-extension-adapter.ts +2 -0
  50. package/extension/presentation.ts +7 -19
  51. package/package.json +9 -10
  52. package/scripts/github/capture-review-threads.mjs +2 -9
  53. package/scripts/github/comment-issue.mjs +2 -9
  54. package/scripts/github/create-label.mjs +133 -0
  55. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  56. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  57. package/scripts/github/edit-pr.mjs +259 -0
  58. package/scripts/github/fetch-ci-logs.mjs +2 -9
  59. package/scripts/github/list-issues.mjs +2 -9
  60. package/scripts/github/manage-sub-issues.mjs +46 -10
  61. package/scripts/github/offer-human-handoff.mjs +8 -5
  62. package/scripts/github/post-gate-findings.mjs +14 -2
  63. package/scripts/github/probe-ci-status.mjs +8 -2
  64. package/scripts/github/probe-copilot-review.mjs +21 -14
  65. package/scripts/github/ready-for-review.mjs +26 -8
  66. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  67. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  68. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  69. package/scripts/github/request-copilot-review.mjs +8 -2
  70. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  71. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  72. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  73. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  74. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  75. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  76. package/scripts/github/view-pr.mjs +150 -0
  77. package/scripts/github/write-gate-context.mjs +248 -61
  78. package/scripts/github/write-gate-findings-log.mjs +75 -1
  79. package/scripts/lib/jq-output.mjs +18 -0
  80. package/scripts/loop/_post-convergence-change.mjs +211 -0
  81. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  82. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  83. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  84. package/scripts/loop/check-retro-tooling.mjs +14 -3
  85. package/scripts/loop/checkpoint-contract.mjs +7 -4
  86. package/scripts/loop/cleanup-worktree.mjs +12 -3
  87. package/scripts/loop/conductor-monitor.mjs +12 -18
  88. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  89. package/scripts/loop/debt-remediate.mjs +24 -12
  90. package/scripts/loop/detect-change-scope.mjs +36 -7
  91. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  92. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  93. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  94. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  95. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  96. package/scripts/loop/detect-pr-gate-coordination-state.mjs +149 -66
  97. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  98. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  99. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  100. package/scripts/loop/ensure-worktree.mjs +8 -3
  101. package/scripts/loop/info.mjs +12 -4
  102. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  103. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  104. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  105. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  106. package/scripts/loop/inspect-run.mjs +8 -2
  107. package/scripts/loop/outer-loop.mjs +8 -4
  108. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  109. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  110. package/scripts/loop/pre-flight-gate.mjs +9 -9
  111. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  112. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  113. package/scripts/loop/provision-worktree.mjs +74 -3
  114. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  115. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  116. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  117. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  118. package/scripts/loop/run-queue.mjs +18 -9
  119. package/scripts/loop/run-refinement-audit.mjs +8 -9
  120. package/scripts/loop/run-watch-cycle.mjs +2 -9
  121. package/scripts/loop/sanctioned-commands.mjs +104 -0
  122. package/scripts/loop/steer-loop.mjs +29 -16
  123. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  124. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  125. package/scripts/pages/build-site.mjs +59 -8
  126. package/scripts/pages/build-state-atlas.mjs +441 -0
  127. package/scripts/projects/_resolve-project.mjs +148 -0
  128. package/scripts/projects/add-queue-item.mjs +60 -54
  129. package/scripts/projects/archive-done-items.mjs +49 -84
  130. package/scripts/projects/ensure-queue-board.mjs +10 -36
  131. package/scripts/projects/list-queue-items.mjs +116 -65
  132. package/scripts/projects/move-queue-item.mjs +28 -49
  133. package/scripts/projects/reconcile-queue.mjs +253 -0
  134. package/scripts/projects/reorder-queue-item.mjs +41 -47
  135. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  136. package/scripts/projects/sync-item-status.mjs +15 -10
  137. package/scripts/refine/_refine-helpers.mjs +21 -5
  138. package/scripts/refine/exit-spike.mjs +18 -8
  139. package/scripts/refine/promote-plan.mjs +22 -16
  140. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  141. package/scripts/refine/refine-plan-file.mjs +13 -4
  142. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  143. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  144. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  145. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  146. package/scripts/refine/validate-plan-file.mjs +1 -1
  147. package/scripts/refine/validate-spike-file.mjs +1 -1
  148. package/scripts/refine/verify.mjs +11 -6
  149. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  150. package/scripts/release/extract-changelog-section.mjs +16 -2
  151. package/skills/copilot-pr-followup/SKILL.md +24 -12
  152. package/skills/dev-loop/SKILL.md +10 -6
  153. package/skills/docs/acceptance-criteria-verification.md +6 -2
  154. package/skills/docs/anti-patterns.md +2 -1
  155. package/skills/docs/artifact-authority-contract.md +22 -4
  156. package/skills/docs/copilot-loop-operations.md +1 -1
  157. package/skills/docs/cross-harness-regression-contract.md +60 -0
  158. package/skills/docs/issue-intake-procedure.md +1 -0
  159. package/skills/docs/local-planning-flow.md +1 -1
  160. package/skills/docs/local-planning-worked-example.md +1 -1
  161. package/skills/docs/merge-preconditions.md +17 -1
  162. package/skills/docs/plan-file-contract.md +1 -1
  163. package/skills/docs/public-dev-loop-contract.md +1 -1
  164. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  165. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  166. package/skills/docs/workflow-handoff-contract.md +39 -0
  167. package/skills/local-implementation/SKILL.md +25 -14
  168. package/skills/loop-grill/SKILL.md +165 -0
  169. package/.claude/commands/continue.md +0 -15
  170. package/scripts/loop/conductor.mjs +0 -233
  171. package/scripts/loop/detect-stale-runner.mjs +0 -265
  172. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -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,7 @@
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";
29
32
  import { mkdir, readFile, writeFile } from "node:fs/promises";
30
33
  import path from "node:path";
31
34
  import { parseArgs } from "node:util";
@@ -35,6 +38,7 @@ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
35
38
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
36
39
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
37
40
  import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
41
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
38
42
 
39
43
  /**
40
44
  * Map the artifact gate name (draft_gate | pre_approval_gate) to the config
@@ -52,8 +56,12 @@ export function mapGateToConfigKey(gate) {
52
56
  * Map a resolveGateAnglesDynamic result into the persisted artifact fields.
53
57
  * Does NOT re-derive angles — it only reshapes the resolver's output.
54
58
  *
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}> }}
59
+ * Angles present in `resolverResult.addedAngles` (additive selection, #1048)
60
+ * are recorded with action 'added' (reason from `resolverResult.addedReasons`)
61
+ * instead of 'kept'.
62
+ *
63
+ * @param {{ recommendedAngles: string[]|null, skippedAngles?: string[], reasons?: Record<string,string>, addedAngles?: string[], addedReasons?: Record<string,string>, dynamicAnglesActive?: boolean }} resolverResult
64
+ * @returns {{ resolvedAngles: string[], rationale: Array<{angle: string, action: "kept"|"added"|"dropped", reason: string}> }}
57
65
  */
58
66
  export function rationaleFromResolver(resolverResult) {
59
67
  const recommended = Array.isArray(resolverResult?.recommendedAngles)
@@ -63,6 +71,8 @@ export function rationaleFromResolver(resolverResult) {
63
71
  ? resolverResult.skippedAngles
64
72
  : [];
65
73
  const reasons = resolverResult?.reasons ?? {};
74
+ const added = new Set(Array.isArray(resolverResult?.addedAngles) ? resolverResult.addedAngles : []);
75
+ const addedReasons = resolverResult?.addedReasons ?? {};
66
76
  const dynamicActive = resolverResult?.dynamicAnglesActive === true;
67
77
  const keptReason = dynamicActive
68
78
  ? "selected by dynamic angle resolver"
@@ -70,6 +80,16 @@ export function rationaleFromResolver(resolverResult) {
70
80
 
71
81
  const rationale = [];
72
82
  for (const angle of recommended) {
83
+ if (added.has(angle) && dynamicActive) {
84
+ rationale.push({
85
+ angle,
86
+ action: "added",
87
+ reason: typeof addedReasons[angle] === "string" && addedReasons[angle].length > 0
88
+ ? addedReasons[angle]
89
+ : "added by dynamic angle resolver (catalog addition)",
90
+ });
91
+ continue;
92
+ }
73
93
  rationale.push({ angle, action: "kept", reason: keptReason });
74
94
  }
75
95
  for (const angle of skipped) {
@@ -84,7 +104,7 @@ export function rationaleFromResolver(resolverResult) {
84
104
  return { resolvedAngles: [...recommended], rationale };
85
105
  }
86
106
 
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>]
107
+ 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
108
  Write a deterministic gate-review context-builder handoff artifact under tmp/ paths.
89
109
  Required:
90
110
  --repo <owner/name>
@@ -95,10 +115,13 @@ Required:
95
115
  Optional:
96
116
  --rationale <json> JSON array of {angle, action, reason} entries
97
117
  --branch <name> Source branch name
98
- --touched-files <json> JSON array of changed file path strings
118
+ --touched-files <json> JSON array of changed file path strings (separate from the diff-derived scope.changedFiles)
119
+ --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.
99
120
  --acceptance-criteria <ptr> Pointer to acceptance criteria (issue ref, doc path, URL)
100
121
  --validation-posture <text> Short description of the validation posture
101
122
  --tmp-root <path> Root tmp directory (default: tmp/)
123
+
124
+ ${JQ_OUTPUT_USAGE}
102
125
  `.trim();
103
126
 
104
127
  function parseError(message) {
@@ -116,6 +139,22 @@ function normalizeHeadSha(value) {
116
139
  return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
117
140
  }
118
141
 
142
+ // ponytail: a DENYLIST, not an allowlist — the `git diff` call runs via
143
+ // execFileSync's argv array (no shell), so `base` cannot inject shell syntax
144
+ // and we don't need to enumerate every valid git revision grammar. We reject
145
+ // only what is genuinely unsafe or malformed for OUR use, and let `git diff`
146
+ // itself resolve validity (a syntactically-allowed but nonexistent ref fails
147
+ // closed via the unresolvable-base path). Rejected: empty/whitespace-only; a
148
+ // leading "-" (flag-injection shape, never a valid ref); and ".." (ambiguous
149
+ // with our own "<base>...HEAD" triple-dot construction). Everything else —
150
+ // including HEAD@{upstream}, main@{1}, tag-peel v1.0.0^{commit}, HEAD~3 — is
151
+ // accepted.
152
+ function normalizeBaseRef(value) {
153
+ const trimmed = String(value).trim();
154
+ if (trimmed.length === 0 || trimmed.startsWith("-") || trimmed.includes("..")) return null;
155
+ return trimmed;
156
+ }
157
+
119
158
  const VALID_ACTIONS = new Set(["kept", "added", "dropped", "joined"]);
120
159
 
121
160
  function parseAnglesJson(raw) {
@@ -189,9 +228,11 @@ export function parseWriteGateContextCliArgs(argv) {
189
228
  rationale: { type: "string" },
190
229
  branch: { type: "string" },
191
230
  "touched-files": { type: "string" },
231
+ base: { type: "string" },
192
232
  "acceptance-criteria": { type: "string" },
193
233
  "validation-posture": { type: "string" },
194
234
  "tmp-root": { type: "string" },
235
+ ...JQ_OUTPUT_PARSE_OPTIONS,
195
236
  },
196
237
  allowPositionals: true,
197
238
  strict: false,
@@ -206,6 +247,7 @@ export function parseWriteGateContextCliArgs(argv) {
206
247
  rationale: [],
207
248
  branch: null,
208
249
  touchedFiles: [],
250
+ base: null,
209
251
  acceptanceCriteria: null,
210
252
  validationPosture: null,
211
253
  tmpRoot: "tmp",
@@ -256,6 +298,12 @@ export function parseWriteGateContextCliArgs(argv) {
256
298
  options.touchedFiles = parseStringArrayJson(requireTokenValue(token, parseError), "--touched-files");
257
299
  continue;
258
300
  }
301
+ if (token.name === "base") {
302
+ const base = normalizeBaseRef(requireTokenValue(token, parseError));
303
+ if (!base) throw parseError("--base must be a plausible git ref (no leading '-', no '..')");
304
+ options.base = base;
305
+ continue;
306
+ }
259
307
  if (token.name === "acceptance-criteria") {
260
308
  options.acceptanceCriteria = requireTokenValue(token, parseError).trim();
261
309
  continue;
@@ -268,6 +316,7 @@ export function parseWriteGateContextCliArgs(argv) {
268
316
  options.tmpRoot = requireTokenValue(token, parseError).trim();
269
317
  continue;
270
318
  }
319
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
271
320
  throw parseError(`Unknown argument: ${token.rawName}`);
272
321
  }
273
322
  const missing = ["repo", "pr", "gate", "headSha", "angles"]
@@ -437,6 +486,14 @@ export function buildGateContextArtifact(options) {
437
486
  validationPosture: options.validationPosture ?? null,
438
487
  },
439
488
  };
489
+ // ADD (#1140): an explicit posture marker distinguishing a full build-once
490
+ // bundle from a thin briefing. Only set by the CLI (`--base` present → "base";
491
+ // absent → "none"); programmatic `buildGateContext`/`writeGateContext` callers
492
+ // that never pass `diffSource` leave the field out entirely, so this stays
493
+ // backward compatible with the artifact shape they already assert on.
494
+ if (typeof options.diffSource === "string" && options.diffSource.length > 0) {
495
+ artifact.scope.diffSource = options.diffSource;
496
+ }
440
497
  // ADD (#895): the deterministic, neutral adjacent-code bundle. Only present
441
498
  // when the context-builder computed it — keeps the artifact shape backward
442
499
  // compatible for callers that build the artifact without an adjacency pass.
@@ -446,6 +503,150 @@ export function buildGateContextArtifact(options) {
446
503
  return artifact;
447
504
  }
448
505
 
506
+ /**
507
+ * Resolve the diff-derived scope fields shared by `buildGateContext` (the
508
+ * programmatic `{ diff }` path) and the CLI `--base` path (#1140): the FULL
509
+ * diff persisted to a deterministic `.diff` file, the parsed `changedFiles`,
510
+ * and the neutral `adjacentCode` bundle built once from those changed files.
511
+ * Extracted to a single function so both callers stay in sync — see the
512
+ * doc comment on {@link buildGateContext} for the field semantics.
513
+ *
514
+ * @param {object} input
515
+ * @param {{ nameStatusOutput: string, diffOutput?: string }} [input.diff]
516
+ * @param {string} input.repo
517
+ * @param {number|string} input.pr
518
+ * @param {string} input.gate
519
+ * @param {string} input.headSha
520
+ * @param {string} input.tmpRoot
521
+ * @param {number} [input.maxFileBytes]
522
+ * @param {{ repoRoot: string }} opts
523
+ * @returns {Promise<{ diffPath: string|null, changedFiles: string[], adjacentCode: object|null }>}
524
+ */
525
+ async function resolveDiffScope({ diff, repo, pr, gate, headSha, tmpRoot, maxFileBytes }, { repoRoot }) {
526
+ const diffOutput = diff?.diffOutput;
527
+ let diffPath = null;
528
+ const changedFiles = parseChangedFiles(diff?.nameStatusOutput);
529
+ if (typeof diffOutput === "string" && diffOutput.length > 0) {
530
+ diffPath = buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot });
531
+ const fullDiffPath = path.resolve(repoRoot, diffPath);
532
+ try {
533
+ await mkdir(path.dirname(fullDiffPath), { recursive: true });
534
+ await writeFile(fullDiffPath, diffOutput.endsWith("\n") ? diffOutput : diffOutput + "\n", "utf8");
535
+ } catch (err) {
536
+ // Best-effort: a diff-file write failure (disk, permissions) must not block
537
+ // the context artifact. Degrade to diffPath=null; reviewers reconstruct the
538
+ // diff with `git diff`. changedFiles (from nameStatusOutput) is unaffected.
539
+ process.stderr.write(`[gate-context] full-diff capture failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
540
+ diffPath = null;
541
+ }
542
+ }
543
+
544
+ // Build the deterministic, neutral adjacent-code bundle ONCE (#895): for each
545
+ // changed source file, include its 1-hop import out-edges (files it imports)
546
+ // and in-edges (files that import it), with size guards (skip
547
+ // lockfiles/generated/binary/minified; cap per-file bytes; truncate the long
548
+ // tail) recorded in a stripped/truncated manifest. Every independent reviewer
549
+ // is seeded with this identical bundle instead of re-deriving it — work-dedup.
550
+ // Best-effort: bundle computation must never block the context artifact.
551
+ let adjacentCode = null;
552
+ if (changedFiles.length > 0) {
553
+ try {
554
+ adjacentCode = await buildAdjacentBundle({
555
+ changedFiles,
556
+ repoRoot,
557
+ maxFileBytes: typeof maxFileBytes === "number" && maxFileBytes > 0 ? maxFileBytes : DEFAULT_MAX_FILE_BYTES,
558
+ });
559
+ } catch (err) {
560
+ process.stderr.write(`[gate-context] adjacent-code bundle failed (continuing without adjacentCode): ${err?.message ?? err}\n`);
561
+ adjacentCode = null;
562
+ }
563
+ }
564
+
565
+ return { diffPath, changedFiles, adjacentCode };
566
+ }
567
+
568
+ /**
569
+ * Capture a diff against `base` for the CLI `--base <ref>` flag: `git diff
570
+ * --name-status <base>...HEAD` and `git diff <base>...HEAD`, shaped as the
571
+ * `{ nameStatusOutput, diffOutput }` input `resolveDiffScope`/`buildGateContext`
572
+ * expect. Uses execFileSync with an argv array (no shell), so `base` cannot
573
+ * inject shell syntax; `parseWriteGateContextCliArgs` additionally validates it
574
+ * looks like a plausible ref before this runs.
575
+ *
576
+ * Split posture:
577
+ * - the `--name-status` capture is FAIL-CLOSED: it drives scope.changedFiles +
578
+ * the adjacentCode bundle, so an unresolvable base / non-git-repo throws and
579
+ * the CLI writes NO artifact (never a silent thin briefing).
580
+ * - the FULL diff capture is BEST-EFFORT: if it fails (output exceeds maxBuffer,
581
+ * a rendering error, etc.) we warn and return an EMPTY diffOutput. downstream
582
+ * resolveDiffScope then leaves scope.diffPath null while still populating
583
+ * changedFiles + adjacentCode; reviewers fall back to re-deriving the diff
584
+ * (the existing safety net). scope.diffSource stays "base" — it IS a
585
+ * base-derived bundle, just without the persisted full diff.
586
+ *
587
+ * @param {string} base
588
+ * @param {{ repoRoot: string, maxBuffer?: number }} opts — maxBuffer overridable for tests
589
+ * @returns {{ nameStatusOutput: string, diffOutput: string }}
590
+ */
591
+ export function captureDiffFromBase(base, { repoRoot, maxBuffer = 64 * 1024 * 1024 }) {
592
+ const range = `${base}...HEAD`;
593
+ // Isolate the persisted .diff BYTES from ambient global/system gitconfig so
594
+ // every reviewer is seeded with an IDENTICAL neutral bundle (the whole point
595
+ // of build-once). Without this isolation, an operator/CI with
596
+ // color.diff=always, a configured diff.external/difftool, or non-default
597
+ // prefix settings would make scope.diffPath environment-dependent.
598
+ // color.ui=false + color.diff=false strip ANSI; core.pager=cat neutralizes a
599
+ // configured pager; diff.noprefix=false + diff.mnemonicPrefix=false pin the
600
+ // a/ b/ prefixes; the --no-ext-diff flag (below) disables any external diff
601
+ // driver (NOT `-c diff.external=`, which makes git try to exec the empty
602
+ // string and die).
603
+ // CROSS-environment reproducibility (#1168): the overrides above only pin
604
+ // bytes WITHIN a single run/machine — an operator/CI box with a contrary
605
+ // local diff.renames/diff.algorithm/diff.context/core.abbrev/core.autocrlf
606
+ // would still produce different bytes than another box on the SAME base and
607
+ // HEAD. diff.algorithm=myers + diff.context=3 + core.abbrev=12 +
608
+ // core.autocrlf=false pin the diff body/hunk headers/blob-id length/line
609
+ // endings. diff.renames=true additionally pins rename DETECTION itself: with
610
+ // it off, a moved-and-edited file shows as a straight D+A pair in
611
+ // `--name-status` instead of an R### pair, which changes the SET of names in
612
+ // scope.changedFiles (and therefore adjacentCode's membership) across
613
+ // environments, not just the diff body's bytes.
614
+ const isolation = [
615
+ "-c", "color.ui=false",
616
+ "-c", "color.diff=false",
617
+ "-c", "core.pager=cat",
618
+ "-c", "diff.noprefix=false",
619
+ "-c", "diff.mnemonicPrefix=false",
620
+ "-c", "diff.renames=true",
621
+ "-c", "diff.algorithm=myers",
622
+ "-c", "diff.context=3",
623
+ "-c", "core.abbrev=12",
624
+ "-c", "core.autocrlf=false",
625
+ ];
626
+ const runGit = (args) => execFileSync("git", [...isolation, ...args], {
627
+ cwd: repoRoot,
628
+ encoding: "utf8",
629
+ maxBuffer,
630
+ });
631
+ // FAIL-CLOSED: --name-status feeds changedFiles + adjacentCode (the bundle's core).
632
+ let nameStatusOutput;
633
+ try {
634
+ nameStatusOutput = runGit(["diff", "--no-ext-diff", "--name-status", range]);
635
+ } catch (err) {
636
+ throw new Error(`git diff against --base ${JSON.stringify(base)} failed: ${err?.message ?? err}`);
637
+ }
638
+ // BEST-EFFORT: the full diff only feeds the persisted scope.diffPath; on
639
+ // failure degrade to an empty diffOutput (diffPath becomes null downstream).
640
+ let diffOutput = "";
641
+ try {
642
+ diffOutput = runGit(["diff", "--no-ext-diff", range]);
643
+ } catch (err) {
644
+ process.stderr.write(`[gate-context] full-diff capture against --base ${JSON.stringify(base)} failed (continuing without scope.diffPath): ${err?.message ?? err}\n`);
645
+ diffOutput = "";
646
+ }
647
+ return { nameStatusOutput, diffOutput };
648
+ }
649
+
449
650
  export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
450
651
  const contextPath = buildGateContextPath({
451
652
  repo: options.repo,
@@ -503,58 +704,13 @@ export async function buildGateContext(input, { repoRoot = process.cwd() } = {})
503
704
 
504
705
  const tmpRoot = input.tmpRoot || "tmp";
505
706
 
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
- }
707
+ // Diff-derived scope: persisted FULL diff (scope.diffPath), parsed
708
+ // scope.changedFiles, and the neutral adjacentCode bundle, all built ONCE by
709
+ // the shared resolveDiffScope helper (also used by the CLI --base path, #1140).
710
+ const { diffPath, changedFiles, adjacentCode } = await resolveDiffScope(
711
+ { diff: input.diff, repo: input.repo, pr: input.pr, gate: input.gate, headSha: input.headSha, tmpRoot, maxFileBytes: input.maxFileBytes },
712
+ { repoRoot },
713
+ );
558
714
 
559
715
  const writeResult = await writeGateContext(
560
716
  {
@@ -604,10 +760,17 @@ export async function readGateContext(input, { repoRoot = process.cwd() } = {})
604
760
  }
605
761
  }
606
762
 
607
- async function main() {
763
+ /**
764
+ * CLI entrypoint. Exported (argv + repoRoot both overridable) so tests can
765
+ * drive the `--base` diff-capture path against a throwaway git repo fixture
766
+ * without spawning a subprocess.
767
+ * @param {string[]} [argv]
768
+ * @param {{ repoRoot?: string }} [runtime]
769
+ */
770
+ export async function main(argv = process.argv.slice(2), { repoRoot = process.cwd() } = {}) {
608
771
  let options;
609
772
  try {
610
- options = parseWriteGateContextCliArgs(process.argv.slice(2));
773
+ options = parseWriteGateContextCliArgs(argv);
611
774
  } catch (error) {
612
775
  process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
613
776
  process.exitCode = 1;
@@ -618,8 +781,32 @@ async function main() {
618
781
  return;
619
782
  }
620
783
  try {
621
- const result = await writeGateContext(options);
622
- process.stdout.write(JSON.stringify(result) + "\n");
784
+ // AC3 (#1140): the CLI only produces the full build-once bundle
785
+ // (scope.diffPath + scope.changedFiles + adjacentCode) when it has a
786
+ // resolvable diff source. --base is OPTIONAL rather than required — making
787
+ // it required would break any existing caller that only ever passed
788
+ // --touched-files (angles + scope already resolved elsewhere) — so a run
789
+ // without --base does not fail closed; it explicitly WARNS and records the
790
+ // thin-briefing posture in scope.diffSource so it is never mistaken for a
791
+ // full bundle. A run WITH --base that then fails to resolve (bad ref, not a
792
+ // git repo, etc.) DOES fail closed: the caller opted into the full bundle,
793
+ // so a silent thin degrade there would be a worse surprise than an error.
794
+ if (options.base) {
795
+ const diff = captureDiffFromBase(options.base, { repoRoot });
796
+ const scope = await resolveDiffScope(
797
+ { diff, repo: options.repo, pr: options.pr, gate: options.gate, headSha: options.headSha, tmpRoot: options.tmpRoot || "tmp" },
798
+ { repoRoot },
799
+ );
800
+ options.changedFiles = scope.changedFiles;
801
+ options.diffPath = scope.diffPath;
802
+ options.adjacentCode = scope.adjacentCode;
803
+ options.diffSource = "base";
804
+ } else {
805
+ 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");
806
+ options.diffSource = "none";
807
+ }
808
+ const result = await writeGateContext(options, { repoRoot });
809
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
623
810
  } catch (error) {
624
811
  process.stderr.write(JSON.stringify({
625
812
  ok: false,
@@ -4,6 +4,8 @@ import path from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
6
6
  import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
+ import { provenanceConsistencyError } from "@dev-loops/core/loop/gate-fanin";
7
9
  const USAGE = `Usage: write-gate-findings-log.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --verdict <clean|findings_present|blocked> --findings <json> [--tmp-root <path>]
8
10
  Write a durable <gate>-<headSha>.json log under deterministic tmp/ paths.
9
11
  Required:
@@ -14,7 +16,11 @@ Required:
14
16
  --verdict <clean|findings_present|blocked>
15
17
  --findings <json> JSON array of finding objects with severity, disposition, angle, and summary
16
18
  Optional:
19
+ --provenance <json> Fan-out provenance object: { distinctReviewers: <int>, perAngle: [{ angle, reviewer?, dispatchId?, model? }] }
20
+ distinctReviewers must be <= the distinct reviewers recorded in perAngle (perAngle non-empty when distinctReviewers > 0)
17
21
  --tmp-root <path> Root tmp directory (default: tmp/)
22
+
23
+ ${JQ_OUTPUT_USAGE}
18
24
  `.trim();
19
25
  function parseError(message) {
20
26
  return Object.assign(new Error(message), { usage: USAGE });
@@ -89,6 +95,59 @@ function parseFindingsJson(raw) {
89
95
  return entry;
90
96
  });
91
97
  }
98
+ /**
99
+ * Validate + normalize the fan-out provenance object. Records how many distinct
100
+ * reviewer agents were dispatched (distinctReviewers) and per-angle dispatch
101
+ * provenance (perAngle). Rejects MALFORMED or self-INCONSISTENT provenance (bad
102
+ * shape, or a distinctReviewers claim not backed by recorded dispatch entries).
103
+ * This raises the bar; it does NOT make provenance un-forgeable — a determined
104
+ * single agent can still write an internally-consistent blob. Un-forgeable
105
+ * recording is the Pi-harness bridge (subagent tool at child depth). Returns the
106
+ * normalized object.
107
+ */
108
+ export function parseProvenanceJson(raw) {
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ } catch {
113
+ throw parseError("--provenance must be valid JSON");
114
+ }
115
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
116
+ throw parseError("--provenance must be a JSON object");
117
+ }
118
+ if (!Number.isInteger(parsed.distinctReviewers) || parsed.distinctReviewers < 0) {
119
+ throw parseError("--provenance.distinctReviewers must be a non-negative integer");
120
+ }
121
+ if (!Array.isArray(parsed.perAngle)) {
122
+ throw parseError("--provenance.perAngle must be an array");
123
+ }
124
+ const perAngle = parsed.perAngle.map((a, i) => {
125
+ if (!a || typeof a !== "object" || Array.isArray(a)) {
126
+ throw parseError(`--provenance.perAngle[${i}] must be an object`);
127
+ }
128
+ if (typeof a.angle !== "string" || a.angle.trim().length === 0) {
129
+ throw parseError(`--provenance.perAngle[${i}].angle is required`);
130
+ }
131
+ const entry = { angle: a.angle.trim() };
132
+ for (const key of ["reviewer", "dispatchId", "model"]) {
133
+ if (key in a) {
134
+ if (typeof a[key] !== "string" || a[key].trim().length === 0) {
135
+ throw parseError(`--provenance.perAngle[${i}].${key} must be a non-empty string`);
136
+ }
137
+ entry[key] = a[key].trim();
138
+ }
139
+ }
140
+ return entry;
141
+ });
142
+ const normalized = { distinctReviewers: parsed.distinctReviewers, perAngle };
143
+ // Internal-consistency gate: a distinctReviewers claim must be backed by that
144
+ // many distinct recorded reviewer identities (closes the {n, perAngle:[]} loophole).
145
+ const consistencyError = provenanceConsistencyError(normalized);
146
+ if (consistencyError) {
147
+ throw parseError(`--${consistencyError}`);
148
+ }
149
+ return normalized;
150
+ }
92
151
  export function parseWriteGateFindingsLogCliArgs(argv) {
93
152
  const { tokens } = parseArgs({
94
153
  args: [...argv],
@@ -100,7 +159,9 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
100
159
  "head-sha": { type: "string" },
101
160
  verdict: { type: "string" },
102
161
  findings: { type: "string" },
162
+ provenance: { type: "string" },
103
163
  "tmp-root": { type: "string" },
164
+ ...JQ_OUTPUT_PARSE_OPTIONS,
104
165
  },
105
166
  allowPositionals: true,
106
167
  strict: false,
@@ -155,10 +216,15 @@ export function parseWriteGateFindingsLogCliArgs(argv) {
155
216
  options.findings = requireTokenValue(token, parseError);
156
217
  continue;
157
218
  }
219
+ if (token.name === "provenance") {
220
+ options.provenance = requireTokenValue(token, parseError);
221
+ continue;
222
+ }
158
223
  if (token.name === "tmp-root") {
159
224
  options.tmpRoot = requireTokenValue(token, parseError).trim();
160
225
  continue;
161
226
  }
227
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
162
228
  throw parseError(`Unknown argument: ${token.rawName}`);
163
229
  }
164
230
  const missing = ["repo", "pr", "gate", "headSha", "verdict", "findings"]
@@ -183,6 +249,7 @@ export function buildLogPath({ repo, pr, gate, headSha, tmpRoot }) {
183
249
  }
184
250
  export async function writeGateFindingsLog(options, { repoRoot = process.cwd() } = {}) {
185
251
  const findings = parseFindingsJson(options.findings);
252
+ const provenance = options.provenance === undefined ? undefined : parseProvenanceJson(options.provenance);
186
253
  const logPath = buildLogPath({
187
254
  repo: options.repo,
188
255
  pr: options.pr,
@@ -200,6 +267,13 @@ export async function writeGateFindingsLog(options, { repoRoot = process.cwd() }
200
267
  loggedAt: new Date().toISOString(),
201
268
  findings,
202
269
  };
270
+ // Provenance is optional and additive: when absent the ledger writes exactly
271
+ // as before (no provenance key), preserving byte-identical output for the
272
+ // default / Claude-Code path. When present it records fan-out provenance for
273
+ // gates.requireFanoutProvenance enforcement.
274
+ if (provenance !== undefined) {
275
+ log.provenance = provenance;
276
+ }
203
277
  await mkdir(path.dirname(fullPath), { recursive: true });
204
278
  await writeFile(fullPath, JSON.stringify(log, null, 2) + "\n", "utf8");
205
279
  return { ok: true, path: logPath, log };
@@ -219,7 +293,7 @@ async function main() {
219
293
  }
220
294
  try {
221
295
  const result = await writeGateFindingsLog(options);
222
- process.stdout.write(JSON.stringify(result) + "\n");
296
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
223
297
  } catch (error) {
224
298
  process.stderr.write(JSON.stringify({
225
299
  ok: false,
@@ -256,6 +256,24 @@ export const JQ_OUTPUT_USAGE = `Output filtering:
256
256
  --silent, -s Suppress stdout; map result to exit code only
257
257
  (0 = pass/truthy, 1 = fail/falsy). Composes with --jq as a predicate.`;
258
258
 
259
+ // Shared token-matcher for scripts that hand-roll a `parseArgs({ tokens: true })`
260
+ // loop (rather than reading `values.jq`/`values.silent` off a strict parse).
261
+ // Call once per `option` token: `if (matchJqOutputToken(token, options)) continue;`.
262
+ // `requireValue` extracts the `--jq <value>` argument; pass the script's own
263
+ // value-extraction function (e.g. `(t) => requireTokenValue(t, parseError)`) to
264
+ // preserve its existing error semantics. Defaults to the raw token value.
265
+ export function matchJqOutputToken(token, options, requireValue = (t) => t.value) {
266
+ if (token.name === "jq") {
267
+ options.jq = requireValue(token);
268
+ return true;
269
+ }
270
+ if (token.name === "silent") {
271
+ options.silent = true;
272
+ return true;
273
+ }
274
+ return false;
275
+ }
276
+
259
277
  // Apply --jq / --silent to a result object and emit. Returns the exit code the
260
278
  // CLI should use (0 success / truthy, 1 falsy or non-ok, 2 invalid filter).
261
279
  // Without jq/silent the result is printed verbatim as JSON (unchanged shape).