dev-loops 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/developer.md +1 -0
  4. package/.claude/agents/fixer.md +1 -0
  5. package/.claude/agents/review.md +30 -0
  6. package/.claude/skills/copilot-pr-followup/SKILL.md +57 -3
  7. package/.claude/skills/dev-loop/SKILL.md +5 -5
  8. package/.claude/skills/docs/anti-patterns.md +2 -0
  9. package/.claude/skills/docs/copilot-loop-operations.md +2 -2
  10. package/.claude/skills/local-implementation/SKILL.md +17 -3
  11. package/AGENTS.md +1 -1
  12. package/CHANGELOG.md +60 -0
  13. package/agents/dev-loop.agent.md +5 -1
  14. package/agents/developer.agent.md +1 -0
  15. package/agents/fixer.agent.md +1 -0
  16. package/agents/review.agent.md +30 -0
  17. package/cli/index.mjs +42 -7
  18. package/package.json +2 -2
  19. package/scripts/README.md +6 -5
  20. package/scripts/_cli-primitives.mjs +2 -0
  21. package/scripts/_core-helpers.mjs +1 -0
  22. package/scripts/claude/generate-claude-assets.mjs +12 -2
  23. package/scripts/claude/headless-dev-loop.mjs +53 -13
  24. package/scripts/claude/headless-info-smoke.mjs +45 -11
  25. package/scripts/docs/validate-links.mjs +20 -11
  26. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  27. package/scripts/github/capture-review-threads.mjs +32 -14
  28. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  29. package/scripts/github/detect-checkpoint-evidence.mjs +123 -15
  30. package/scripts/github/detect-linked-issue-pr.mjs +22 -10
  31. package/scripts/github/manage-sub-issues.mjs +37 -16
  32. package/scripts/github/post-gate-findings.mjs +392 -0
  33. package/scripts/github/probe-copilot-review.mjs +24 -12
  34. package/scripts/github/ready-for-review.mjs +17 -8
  35. package/scripts/github/reconcile-draft-gate.mjs +24 -12
  36. package/scripts/github/reply-resolve-review-threads.mjs +34 -15
  37. package/scripts/github/request-copilot-review.mjs +97 -19
  38. package/scripts/github/resolve-tracker-local-spec.mjs +29 -12
  39. package/scripts/github/stage-reviewer-draft.mjs +32 -14
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +646 -41
  41. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  42. package/scripts/github/write-gate-context.mjs +634 -0
  43. package/scripts/github/write-gate-findings-log.mjs +42 -21
  44. package/scripts/loop/build-handoff-envelope.mjs +32 -14
  45. package/scripts/loop/conductor-monitor.mjs +25 -9
  46. package/scripts/loop/conductor.mjs +31 -12
  47. package/scripts/loop/copilot-pr-handoff.mjs +31 -14
  48. package/scripts/loop/debt-remediate.mjs +28 -11
  49. package/scripts/loop/detect-change-scope.mjs +36 -11
  50. package/scripts/loop/detect-copilot-loop-state.mjs +29 -12
  51. package/scripts/loop/detect-copilot-session-activity.mjs +29 -12
  52. package/scripts/loop/detect-initial-copilot-pr-state.mjs +26 -10
  53. package/scripts/loop/detect-internal-only-pr.mjs +31 -13
  54. package/scripts/loop/detect-issue-refinement-artifact.mjs +29 -12
  55. package/scripts/loop/detect-pr-gate-coordination-state.mjs +56 -28
  56. package/scripts/loop/detect-reviewer-loop-state.mjs +35 -16
  57. package/scripts/loop/detect-stale-runner.mjs +32 -14
  58. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  59. package/scripts/loop/detect-tracker-pr-state.mjs +23 -8
  60. package/scripts/loop/info.mjs +28 -10
  61. package/scripts/loop/inspect-run-viewer/cli.mjs +44 -21
  62. package/scripts/loop/inspect-run.mjs +35 -16
  63. package/scripts/loop/outer-loop.mjs +35 -16
  64. package/scripts/loop/pr-runner-coordination.mjs +31 -12
  65. package/scripts/loop/pre-commit-branch-guard.mjs +26 -9
  66. package/scripts/loop/pre-flight-gate.mjs +25 -9
  67. package/scripts/loop/pre-pr-ready-gate.mjs +24 -8
  68. package/scripts/loop/pre-push-main-guard.mjs +19 -5
  69. package/scripts/loop/pre-write-remote-freshness-guard.mjs +23 -7
  70. package/scripts/loop/resolve-dev-loop-startup.mjs +29 -12
  71. package/scripts/loop/run-conductor-cycle.mjs +23 -8
  72. package/scripts/loop/run-queue.mjs +87 -15
  73. package/scripts/loop/run-refinement-audit.mjs +44 -22
  74. package/scripts/loop/run-watch-cycle.mjs +28 -12
  75. package/scripts/loop/steer-loop.mjs +122 -62
  76. package/scripts/loop/watch-initial-copilot-pr.mjs +28 -12
  77. package/scripts/projects/add-queue-item.mjs +60 -43
  78. package/scripts/projects/archive-done-items.mjs +506 -0
  79. package/scripts/projects/ensure-queue-board.mjs +65 -64
  80. package/scripts/projects/list-queue-items.mjs +57 -56
  81. package/scripts/projects/move-queue-item.mjs +123 -124
  82. package/scripts/projects/reorder-queue-item.mjs +384 -108
  83. package/scripts/projects/sync-item-status.mjs +198 -0
  84. package/scripts/refine/_refine-helpers.mjs +21 -9
  85. package/scripts/refine/verify.mjs +31 -13
  86. package/skills/copilot-pr-followup/SKILL.md +57 -3
  87. package/skills/dev-loop/SKILL.md +9 -5
  88. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  89. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  90. package/skills/docs/anti-patterns.md +2 -0
  91. package/skills/docs/copilot-loop-operations.md +2 -2
  92. package/skills/local-implementation/SKILL.md +17 -3
@@ -3,7 +3,18 @@ import { mkdir, stat, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { buildParseError, isDirectCliRun, formatCliError } from "../_core-helpers.mjs";
5
5
  const USAGE = `Usage: verify-fresh-review-context.mjs [--help] [--scope <name>]
6
- Verify that the current subagent session has fresh context.
6
+ Verify that the current scoped-reviewer session has fresh context.
7
+
8
+ "Fresh" means the reviewer's context is the neutral gate-context builder
9
+ artifact (the build-once diff + adjacent-code bundle) plus its single review
10
+ angle, and explicitly NOT the main (orchestrating) agent's conversation/state
11
+ or a prior reviewer session's state. The injected neutral bundle is the
12
+ INTENDED seed and is NOT contamination; this guard detects main-agent /
13
+ cross-session state bleed by way of a per-(cwd, scope) sentinel: a first run in
14
+ a fresh session creates the sentinel and passes, a re-entry that finds an
15
+ existing sentinel fails closed. Seeding a reviewer with the neutral bundle (a
16
+ path/prompt, not a sentinel) never creates a sentinel, so it never
17
+ false-positives as contaminated.
7
18
  Options:
8
19
  --scope <name> Unique reviewer scope (e.g. "draft-gate-coverage").
9
20
  Must be non-empty, containing only alphanumeric
@@ -0,0 +1,634 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * write-gate-context.mjs — context-builder handoff artifact writer.
4
+ *
5
+ * The gate-review context-builder (Phase 1 of the gate-review sub-loop) resolves
6
+ * the dynamic review-angle set and writes a deterministic JSON handoff artifact
7
+ * that the downstream fork fan-out reviewers consume. This module owns that
8
+ * artifact: a deterministic path builder, a writer, and a reader for round-trip
9
+ * use, plus a thin context-builder entrypoint (`buildGateContext`) that derives
10
+ * the angle set + rationale directly from the canonical resolver.
11
+ *
12
+ * Angle resolution is NOT re-implemented here. The single source of truth is
13
+ * `resolveGateAnglesDynamic(config, gate, { diff })` from @dev-loops/core/config:
14
+ * it honors the mandatory-angle floor (mandatory angles are always merged back
15
+ * after dynamic selection, filtered by excludeAngles) and falls back to the
16
+ * static configured pool when `dynamicAngles` is off or no diff is available.
17
+ * This module maps that resolver's output into the persisted artifact:
18
+ * resolvedAngles = resolver.recommendedAngles
19
+ * rationale = resolver.skippedAngles (action 'dropped', reason from
20
+ * resolver.reasons) + the rest as action 'kept'
21
+ *
22
+ * The artifact records the resolved angle set + rationale + change scope
23
+ * (branch, head SHA, touched files, acceptance-criteria pointer, validation
24
+ * posture) so reviewers receive a stable, auditable briefing per head SHA.
25
+ *
26
+ * Path scheme mirrors write-gate-findings-log.mjs `buildLogPath`:
27
+ * <tmpRoot>/gate-context/<repo-slug>/pr-<N>/<gate>-<headSha>.json
28
+ */
29
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
30
+ import path from "node:path";
31
+ import { parseArgs } from "node:util";
32
+
33
+ import { resolveGateAnglesDynamic } from "@dev-loops/core/config";
34
+
35
+ import { parsePrNumber, requireTokenValue } from "../_cli-primitives.mjs";
36
+ import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
37
+ import { buildAdjacentBundle, DEFAULT_MAX_FILE_BYTES } from "./build-adjacent-bundle.mjs";
38
+
39
+ /**
40
+ * Map the artifact gate name (draft_gate | pre_approval_gate) to the config
41
+ * gate key understood by resolveGateAnglesDynamic (draft | preApproval).
42
+ * @param {string} gate
43
+ * @returns {"draft"|"preApproval"}
44
+ */
45
+ export function mapGateToConfigKey(gate) {
46
+ if (gate === "draft_gate") return "draft";
47
+ if (gate === "pre_approval_gate") return "preApproval";
48
+ throw new Error(`Unknown gate: ${JSON.stringify(gate)} (expected draft_gate or pre_approval_gate)`);
49
+ }
50
+
51
+ /**
52
+ * Map a resolveGateAnglesDynamic result into the persisted artifact fields.
53
+ * Does NOT re-derive angles — it only reshapes the resolver's output.
54
+ *
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}> }}
57
+ */
58
+ export function rationaleFromResolver(resolverResult) {
59
+ const recommended = Array.isArray(resolverResult?.recommendedAngles)
60
+ ? resolverResult.recommendedAngles
61
+ : [];
62
+ const skipped = Array.isArray(resolverResult?.skippedAngles)
63
+ ? resolverResult.skippedAngles
64
+ : [];
65
+ const reasons = resolverResult?.reasons ?? {};
66
+ const dynamicActive = resolverResult?.dynamicAnglesActive === true;
67
+ const keptReason = dynamicActive
68
+ ? "selected by dynamic angle resolver"
69
+ : "static pool (dynamic angle resolution inactive)";
70
+
71
+ const rationale = [];
72
+ for (const angle of recommended) {
73
+ rationale.push({ angle, action: "kept", reason: keptReason });
74
+ }
75
+ for (const angle of skipped) {
76
+ rationale.push({
77
+ angle,
78
+ action: "dropped",
79
+ reason: typeof reasons[angle] === "string" && reasons[angle].length > 0
80
+ ? reasons[angle]
81
+ : "not relevant to the change set",
82
+ });
83
+ }
84
+ return { resolvedAngles: [...recommended], rationale };
85
+ }
86
+
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>]
88
+ Write a deterministic gate-review context-builder handoff artifact under tmp/ paths.
89
+ Required:
90
+ --repo <owner/name>
91
+ --pr <number>
92
+ --gate <draft_gate|pre_approval_gate>
93
+ --head-sha <sha>
94
+ --angles <json> JSON array of resolved review-angle name strings
95
+ Optional:
96
+ --rationale <json> JSON array of {angle, action, reason} entries
97
+ --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)
100
+ --validation-posture <text> Short description of the validation posture
101
+ --tmp-root <path> Root tmp directory (default: tmp/)
102
+ `.trim();
103
+
104
+ function parseError(message) {
105
+ return Object.assign(new Error(message), { usage: USAGE });
106
+ }
107
+
108
+ function normalizeGate(value) {
109
+ const gates = new Set(["draft_gate", "pre_approval_gate"]);
110
+ const normalized = String(value).trim().toLowerCase();
111
+ return gates.has(normalized) ? normalized : null;
112
+ }
113
+
114
+ function normalizeHeadSha(value) {
115
+ const normalized = String(value).trim().toLowerCase();
116
+ return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
117
+ }
118
+
119
+ const VALID_ACTIONS = new Set(["kept", "added", "dropped", "joined"]);
120
+
121
+ function parseAnglesJson(raw) {
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(raw);
125
+ } catch {
126
+ throw parseError("--angles must be valid JSON");
127
+ }
128
+ if (!Array.isArray(parsed)) {
129
+ throw parseError("--angles must be a JSON array");
130
+ }
131
+ return parsed.map((a, i) => {
132
+ if (typeof a !== "string" || a.trim().length === 0) {
133
+ throw parseError(`--angles[${i}] must be a non-empty string`);
134
+ }
135
+ return a.trim();
136
+ });
137
+ }
138
+
139
+ function parseRationaleJson(raw) {
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(raw);
143
+ } catch {
144
+ throw parseError("--rationale must be valid JSON");
145
+ }
146
+ if (!Array.isArray(parsed)) {
147
+ throw parseError("--rationale must be a JSON array");
148
+ }
149
+ return parsed.map((r, i) => {
150
+ if (!r || typeof r !== "object" || Array.isArray(r)) {
151
+ throw parseError(`--rationale[${i}] must be an object`);
152
+ }
153
+ if (!r.angle || typeof r.angle !== "string" || r.angle.trim().length === 0) {
154
+ throw parseError(`--rationale[${i}].angle is required`);
155
+ }
156
+ if (!r.action || !VALID_ACTIONS.has(r.action)) {
157
+ throw parseError(`--rationale[${i}].action must be one of: kept, added, dropped, joined`);
158
+ }
159
+ if (!r.reason || typeof r.reason !== "string" || r.reason.trim().length === 0) {
160
+ throw parseError(`--rationale[${i}].reason is required`);
161
+ }
162
+ return { angle: r.angle.trim(), action: r.action, reason: r.reason.trim() };
163
+ });
164
+ }
165
+
166
+ function parseStringArrayJson(raw, label) {
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(raw);
170
+ } catch {
171
+ throw parseError(`${label} must be valid JSON`);
172
+ }
173
+ if (!Array.isArray(parsed)) {
174
+ throw parseError(`${label} must be a JSON array`);
175
+ }
176
+ return parsed.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
177
+ }
178
+
179
+ export function parseWriteGateContextCliArgs(argv) {
180
+ const { tokens } = parseArgs({
181
+ args: [...argv],
182
+ options: {
183
+ help: { type: "boolean", short: "h" },
184
+ repo: { type: "string" },
185
+ pr: { type: "string" },
186
+ gate: { type: "string" },
187
+ "head-sha": { type: "string" },
188
+ angles: { type: "string" },
189
+ rationale: { type: "string" },
190
+ branch: { type: "string" },
191
+ "touched-files": { type: "string" },
192
+ "acceptance-criteria": { type: "string" },
193
+ "validation-posture": { type: "string" },
194
+ "tmp-root": { type: "string" },
195
+ },
196
+ allowPositionals: true,
197
+ strict: false,
198
+ tokens: true,
199
+ });
200
+ const options = {
201
+ repo: undefined,
202
+ pr: undefined,
203
+ gate: undefined,
204
+ headSha: undefined,
205
+ angles: undefined,
206
+ rationale: [],
207
+ branch: null,
208
+ touchedFiles: [],
209
+ acceptanceCriteria: null,
210
+ validationPosture: null,
211
+ tmpRoot: "tmp",
212
+ };
213
+ for (const token of tokens) {
214
+ if (token.kind === "positional") {
215
+ throw parseError(`Unknown argument: ${token.value}`);
216
+ }
217
+ if (token.kind !== "option") {
218
+ continue;
219
+ }
220
+ if (token.name === "help") {
221
+ return { help: true };
222
+ }
223
+ if (token.name === "repo") {
224
+ options.repo = requireTokenValue(token, parseError).trim();
225
+ continue;
226
+ }
227
+ if (token.name === "pr") {
228
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
229
+ continue;
230
+ }
231
+ if (token.name === "gate") {
232
+ const gate = normalizeGate(requireTokenValue(token, parseError));
233
+ if (!gate) throw parseError("--gate must be draft_gate or pre_approval_gate");
234
+ options.gate = gate;
235
+ continue;
236
+ }
237
+ if (token.name === "head-sha") {
238
+ const sha = normalizeHeadSha(requireTokenValue(token, parseError));
239
+ if (!sha) throw parseError("--head-sha must be a 7-64 character hex SHA");
240
+ options.headSha = sha;
241
+ continue;
242
+ }
243
+ if (token.name === "angles") {
244
+ options.angles = parseAnglesJson(requireTokenValue(token, parseError));
245
+ continue;
246
+ }
247
+ if (token.name === "rationale") {
248
+ options.rationale = parseRationaleJson(requireTokenValue(token, parseError));
249
+ continue;
250
+ }
251
+ if (token.name === "branch") {
252
+ options.branch = requireTokenValue(token, parseError).trim();
253
+ continue;
254
+ }
255
+ if (token.name === "touched-files") {
256
+ options.touchedFiles = parseStringArrayJson(requireTokenValue(token, parseError), "--touched-files");
257
+ continue;
258
+ }
259
+ if (token.name === "acceptance-criteria") {
260
+ options.acceptanceCriteria = requireTokenValue(token, parseError).trim();
261
+ continue;
262
+ }
263
+ if (token.name === "validation-posture") {
264
+ options.validationPosture = requireTokenValue(token, parseError).trim();
265
+ continue;
266
+ }
267
+ if (token.name === "tmp-root") {
268
+ options.tmpRoot = requireTokenValue(token, parseError).trim();
269
+ continue;
270
+ }
271
+ throw parseError(`Unknown argument: ${token.rawName}`);
272
+ }
273
+ const missing = ["repo", "pr", "gate", "headSha", "angles"]
274
+ .filter((k) => options[k] === undefined);
275
+ if (missing.length > 0) {
276
+ throw parseError(`Missing required arguments: ${missing.join(", ")}`);
277
+ }
278
+ return options;
279
+ }
280
+
281
+ /**
282
+ * Build the deterministic artifact path for a gate-review context handoff.
283
+ * Mirrors write-gate-findings-log.mjs buildLogPath. Exported for reuse by the
284
+ * fork fan-out reviewers so producer and consumer agree on the path.
285
+ *
286
+ * @param {object} input
287
+ * @param {string} input.repo — owner/name
288
+ * @param {number|string} input.pr
289
+ * @param {string} input.gate — draft_gate | pre_approval_gate
290
+ * @param {string} input.headSha
291
+ * @param {string} [input.tmpRoot] — default "tmp"
292
+ * @returns {string} relative artifact path
293
+ */
294
+ export function buildGateContextPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
295
+ const repoSlug = repoSlugFor(repo);
296
+ const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
297
+ return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.json`);
298
+ }
299
+
300
+ /**
301
+ * Validate the non-repo path components (gate, pr, headSha) that are
302
+ * interpolated into a filesystem path which is later `path.resolve()`d and
303
+ * read/written. Mirrors the repo-segment safety check in {@link repoSlugFor} so
304
+ * both path builders reject traversal sequences and odd filenames coming from
305
+ * untrusted inputs. Returns sanitized values for interpolation.
306
+ *
307
+ * @param {object} input
308
+ * @param {number|string} input.pr — must coerce to a positive integer
309
+ * @param {string} input.gate — draft_gate | pre_approval_gate
310
+ * @param {string} input.headSha — 7-64 char hex SHA
311
+ * @returns {{ pr: number, gate: string, headSha: string }}
312
+ */
313
+ function validatePathSegments({ pr, gate, headSha }) {
314
+ if (gate !== "draft_gate" && gate !== "pre_approval_gate") {
315
+ throw new Error(`--gate segment ${JSON.stringify(gate)} is unsafe (expected draft_gate or pre_approval_gate)`);
316
+ }
317
+ // Require a CANONICAL positive integer: the trimmed string must be all digits
318
+ // (`/^\d+$/`) and > 0. This mirrors the CLI's parsePrNumber rule so the path
319
+ // builder cannot accept non-canonical numeric forms ("1e3" → 1000, "0x10" →
320
+ // 16, "1.5") that Number() would coerce to a DIFFERENT pr-<N> segment than the
321
+ // operator/CLI intended, breaking the deterministic producer/consumer
322
+ // round-trip. " 9 " trims to "9" and stays valid; numbers are stringified first.
323
+ const prStr = String(pr).trim();
324
+ const prNum = Number(prStr);
325
+ if (!/^\d+$/.test(prStr) || !Number.isInteger(prNum) || prNum <= 0) {
326
+ throw new Error(`--pr segment ${JSON.stringify(pr)} is unsafe (expected a positive integer)`);
327
+ }
328
+ // Lowercase the validated SHA so the path segment is case-canonical regardless
329
+ // of caller casing, matching the CLI's normalizeHeadSha. A mixed-case
330
+ // headRefOid (e.g. ABC123) must compute the SAME filename as its lowercase
331
+ // form (abc123) or readGateContext / the .diff lookup would miss it — a
332
+ // determinism bug.
333
+ const sha = String(headSha).trim().toLowerCase();
334
+ if (!/^[0-9a-f]{7,64}$/i.test(sha)) {
335
+ throw new Error(`--head-sha segment ${JSON.stringify(headSha)} is unsafe (expected a 7-64 character hex SHA)`);
336
+ }
337
+ return { pr: prNum, gate, headSha: sha };
338
+ }
339
+
340
+ /**
341
+ * Validate the repo string and return its `owner-name` slug, applying the same
342
+ * safety checks (no `.`/`..` segments, no whitespace/backslashes) shared by the
343
+ * artifact and diff path builders.
344
+ * @param {string} repo — owner/name
345
+ * @returns {string} repo slug
346
+ */
347
+ function repoSlugFor(repo) {
348
+ const parts = String(repo).split("/");
349
+ if (parts.length !== 2 || parts.some((p) => p.length === 0)) {
350
+ throw new Error(`--repo must be in owner/name format, got: ${JSON.stringify(repo)}`);
351
+ }
352
+ for (const p of parts) {
353
+ if (p === "." || p === ".." || /[\s\\]/.test(p)) {
354
+ throw new Error(`--repo segment ${JSON.stringify(p)} is unsafe (a "." or ".." path segment, or contains whitespace/backslashes)`);
355
+ }
356
+ }
357
+ return parts.join("-");
358
+ }
359
+
360
+ /**
361
+ * Build the deterministic path for the FULL diff captured alongside the gate
362
+ * context artifact. Mirrors buildGateContextPath but with a `.diff` extension so
363
+ * scoped reviewers can read the entire change set (not just hunks) from a stable
364
+ * location. Exported for reuse by the fork fan-out reviewers.
365
+ *
366
+ * @param {object} input
367
+ * @param {string} input.repo — owner/name
368
+ * @param {number|string} input.pr
369
+ * @param {string} input.gate — draft_gate | pre_approval_gate
370
+ * @param {string} input.headSha
371
+ * @param {string} [input.tmpRoot] — default "tmp"
372
+ * @returns {string} relative diff path
373
+ */
374
+ export function buildGateDiffPath({ repo, pr, gate, headSha, tmpRoot = "tmp" }) {
375
+ const repoSlug = repoSlugFor(repo);
376
+ const { pr: safePr, gate: safeGate, headSha: safeSha } = validatePathSegments({ pr, gate, headSha });
377
+ return path.join(tmpRoot, "gate-context", repoSlug, `pr-${safePr}`, `${safeGate}-${safeSha}.diff`);
378
+ }
379
+
380
+ /**
381
+ * Parse `git diff --name-status` output into full repo-relative changed file
382
+ * paths. Handles rename/copy entries (R100 old new, C75 old new) by recording
383
+ * the destination path. Tolerates blank lines and malformed rows.
384
+ * @param {string} nameStatusOutput
385
+ * @returns {string[]}
386
+ */
387
+ export function parseChangedFiles(nameStatusOutput) {
388
+ if (typeof nameStatusOutput !== "string" || nameStatusOutput.length === 0) {
389
+ return [];
390
+ }
391
+ const files = [];
392
+ for (const line of nameStatusOutput.split("\n")) {
393
+ const trimmed = line.replace(/\r$/, "");
394
+ if (trimmed.trim().length === 0) continue;
395
+ const cols = trimmed.split("\t");
396
+ if (cols.length < 2) continue;
397
+ const status = cols[0].trim();
398
+ let dest;
399
+ if (/^[RC]\d*$/i.test(status)) {
400
+ // Rename (Rxxx) / copy (Cxxx) entries carry status + old + new paths and
401
+ // must have >= 3 columns; record the new (last) path. A malformed 2-column
402
+ // rename/copy row (e.g. "R100\told-path", missing the new path) is skipped
403
+ // rather than misrecording the OLD path as the changed file.
404
+ if (cols.length < 3) continue;
405
+ dest = cols[cols.length - 1];
406
+ } else {
407
+ dest = cols[1];
408
+ }
409
+ const file = (dest ?? "").trim();
410
+ if (file.length > 0) files.push(file);
411
+ }
412
+ return files;
413
+ }
414
+
415
+ /**
416
+ * Build the deterministic artifact object (no I/O). Exported for callers that
417
+ * want the artifact shape without writing it.
418
+ *
419
+ * @param {object} options — parsed CLI options shape
420
+ * @returns {object}
421
+ */
422
+ export function buildGateContextArtifact(options) {
423
+ const artifact = {
424
+ repo: options.repo,
425
+ pr: options.pr,
426
+ gate: options.gate,
427
+ headSha: options.headSha,
428
+ resolvedAngles: [...options.angles],
429
+ rationale: Array.isArray(options.rationale) ? options.rationale : [],
430
+ scope: {
431
+ branch: options.branch ?? null,
432
+ headSha: options.headSha,
433
+ touchedFiles: Array.isArray(options.touchedFiles) ? options.touchedFiles : [],
434
+ changedFiles: Array.isArray(options.changedFiles) ? options.changedFiles : [],
435
+ diffPath: options.diffPath ?? null,
436
+ acceptanceCriteria: options.acceptanceCriteria ?? null,
437
+ validationPosture: options.validationPosture ?? null,
438
+ },
439
+ };
440
+ // ADD (#895): the deterministic, neutral adjacent-code bundle. Only present
441
+ // when the context-builder computed it — keeps the artifact shape backward
442
+ // compatible for callers that build the artifact without an adjacency pass.
443
+ if (options.adjacentCode && typeof options.adjacentCode === "object") {
444
+ artifact.adjacentCode = options.adjacentCode;
445
+ }
446
+ return artifact;
447
+ }
448
+
449
+ export async function writeGateContext(options, { repoRoot = process.cwd() } = {}) {
450
+ const contextPath = buildGateContextPath({
451
+ repo: options.repo,
452
+ pr: options.pr,
453
+ gate: options.gate,
454
+ headSha: options.headSha,
455
+ tmpRoot: options.tmpRoot || "tmp",
456
+ });
457
+ const fullPath = path.resolve(repoRoot, contextPath);
458
+ const artifact = {
459
+ ...buildGateContextArtifact(options),
460
+ loggedAt: new Date().toISOString(),
461
+ };
462
+ await mkdir(path.dirname(fullPath), { recursive: true });
463
+ await writeFile(fullPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
464
+ return { ok: true, path: contextPath, artifact };
465
+ }
466
+
467
+ /**
468
+ * Context-builder entrypoint: resolve the dynamic review-angle set via the
469
+ * canonical resolver and persist the handoff artifact.
470
+ *
471
+ * Angle selection is delegated entirely to `resolveGateAnglesDynamic` (the
472
+ * single source of truth, which honors the mandatory-angle floor and falls back
473
+ * to the static configured pool when `dynamicAngles` is off or no diff is
474
+ * given). This function only maps that result into the artifact and writes it.
475
+ *
476
+ * @param {object} input
477
+ * @param {import("@dev-loops/core/config").DevLoopConfig} input.config — merged dev-loop config
478
+ * @param {string} input.gate — draft_gate | pre_approval_gate
479
+ * @param {{ nameStatusOutput: string, diffOutput?: string }} [input.diff] — diff for dynamic resolution; when `diffOutput` is present it is also persisted to `scope.diffPath` and parsed into `scope.changedFiles`
480
+ * @param {string} input.repo — owner/name
481
+ * @param {number|string} input.pr
482
+ * @param {string} input.headSha
483
+ * @param {string|null} [input.branch]
484
+ * @param {string[]} [input.touchedFiles]
485
+ * @param {string|null} [input.acceptanceCriteria]
486
+ * @param {string|null} [input.validationPosture]
487
+ * @param {number} [input.maxFileBytes] — per-file cap for the adjacent-code bundle (default DEFAULT_MAX_FILE_BYTES)
488
+ * @param {string} [input.tmpRoot]
489
+ * @param {{ repoRoot?: string }} [opts]
490
+ * @returns {Promise<{ ok: boolean, path: string, artifact: object, resolver: object }>}
491
+ *
492
+ * The artifact additionally carries a deterministic, neutral `adjacentCode`
493
+ * bundle (#895) when changed files are present: 1-hop import in/out-edges of the
494
+ * changed files with size guards + a stripped/truncated manifest. Reviewers are
495
+ * seeded with this verbatim instead of re-deriving the diff + adjacent code.
496
+ */
497
+ export async function buildGateContext(input, { repoRoot = process.cwd() } = {}) {
498
+ const configKey = mapGateToConfigKey(input.gate);
499
+ const resolverResult = await resolveGateAnglesDynamic(input.config, configKey, {
500
+ diff: input.diff,
501
+ });
502
+ const { resolvedAngles, rationale } = rationaleFromResolver(resolverResult);
503
+
504
+ const tmpRoot = input.tmpRoot || "tmp";
505
+
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
+ }
558
+
559
+ const writeResult = await writeGateContext(
560
+ {
561
+ repo: input.repo,
562
+ pr: input.pr,
563
+ gate: input.gate,
564
+ headSha: input.headSha,
565
+ angles: resolvedAngles,
566
+ rationale,
567
+ branch: input.branch ?? null,
568
+ touchedFiles: input.touchedFiles ?? [],
569
+ changedFiles,
570
+ diffPath,
571
+ adjacentCode,
572
+ acceptanceCriteria: input.acceptanceCriteria ?? null,
573
+ validationPosture: input.validationPosture ?? null,
574
+ tmpRoot,
575
+ },
576
+ { repoRoot },
577
+ );
578
+
579
+ return { ...writeResult, resolver: resolverResult };
580
+ }
581
+
582
+ /**
583
+ * Read a previously-written gate context artifact. Returns null when absent.
584
+ *
585
+ * @param {object} input — { repo, pr, gate, headSha, tmpRoot }
586
+ * @param {{ repoRoot?: string }} [options]
587
+ * @returns {Promise<object|null>}
588
+ */
589
+ export async function readGateContext(input, { repoRoot = process.cwd() } = {}) {
590
+ const contextPath = buildGateContextPath({
591
+ repo: input.repo,
592
+ pr: input.pr,
593
+ gate: input.gate,
594
+ headSha: input.headSha,
595
+ tmpRoot: input.tmpRoot || "tmp",
596
+ });
597
+ const fullPath = path.resolve(repoRoot, contextPath);
598
+ try {
599
+ const raw = await readFile(fullPath, "utf8");
600
+ return JSON.parse(raw);
601
+ } catch (err) {
602
+ if (err && err.code === "ENOENT") return null;
603
+ throw err;
604
+ }
605
+ }
606
+
607
+ async function main() {
608
+ let options;
609
+ try {
610
+ options = parseWriteGateContextCliArgs(process.argv.slice(2));
611
+ } catch (error) {
612
+ process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
613
+ process.exitCode = 1;
614
+ return;
615
+ }
616
+ if (options.help) {
617
+ process.stdout.write(`${USAGE}\n`);
618
+ return;
619
+ }
620
+ try {
621
+ const result = await writeGateContext(options);
622
+ process.stdout.write(JSON.stringify(result) + "\n");
623
+ } catch (error) {
624
+ process.stderr.write(JSON.stringify({
625
+ ok: false,
626
+ error: error instanceof Error ? error.message : String(error),
627
+ }) + "\n");
628
+ process.exitCode = 1;
629
+ }
630
+ }
631
+
632
+ if (isDirectCliRun(import.meta.url)) {
633
+ await main();
634
+ }