dev-loops 0.2.7 → 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 (48) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -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 +43 -0
  13. package/agents/developer.agent.md +1 -0
  14. package/agents/fixer.agent.md +1 -0
  15. package/agents/review.agent.md +30 -0
  16. package/cli/index.mjs +39 -6
  17. package/package.json +2 -2
  18. package/scripts/README.md +6 -5
  19. package/scripts/_core-helpers.mjs +1 -0
  20. package/scripts/claude/headless-dev-loop.mjs +53 -13
  21. package/scripts/claude/headless-info-smoke.mjs +45 -11
  22. package/scripts/github/build-adjacent-bundle.mjs +448 -0
  23. package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
  24. package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
  25. package/scripts/github/post-gate-findings.mjs +392 -0
  26. package/scripts/github/reconcile-draft-gate.mjs +2 -2
  27. package/scripts/github/request-copilot-review.mjs +69 -8
  28. package/scripts/github/upsert-checkpoint-verdict.mjs +597 -15
  29. package/scripts/github/verify-fresh-review-context.mjs +12 -1
  30. package/scripts/github/write-gate-context.mjs +634 -0
  31. package/scripts/github/write-gate-findings-log.mjs +1 -1
  32. package/scripts/loop/detect-change-scope.mjs +36 -11
  33. package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
  34. package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
  35. package/scripts/loop/run-queue.mjs +87 -15
  36. package/scripts/projects/add-queue-item.mjs +60 -43
  37. package/scripts/projects/archive-done-items.mjs +135 -39
  38. package/scripts/projects/ensure-queue-board.mjs +65 -64
  39. package/scripts/projects/list-queue-items.mjs +57 -56
  40. package/scripts/projects/move-queue-item.mjs +123 -124
  41. package/scripts/projects/reorder-queue-item.mjs +62 -44
  42. package/scripts/projects/sync-item-status.mjs +198 -0
  43. package/skills/copilot-pr-followup/SKILL.md +57 -3
  44. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
  45. package/skills/dev-loop/scripts/phase-files.mjs +2 -2
  46. package/skills/docs/anti-patterns.md +2 -0
  47. package/skills/docs/copilot-loop-operations.md +2 -2
  48. package/skills/local-implementation/SKILL.md +17 -3
package/scripts/README.md CHANGED
@@ -68,16 +68,17 @@ Failure behavior:
68
68
  - malformed arguments, invalid JSON, and `gh` failures emit `{ "ok": false, "error": "..." }` on stderr and exit non-zero
69
69
  - live capture is only allowed when both `--repo` and `--pr` are present
70
70
 
71
- ### `scripts/github/create-draft-pr.mjs`
71
+ ### `scripts/github/create-pr.mjs`
72
72
 
73
- Thin wrapper around `gh pr create` for draft-first PR creation.
73
+ Canonical wrapper around `gh pr create`. Every PR opened through this tool is ALWAYS a draft and is always assigned — self-assigned by default (`--assignee @me` when none is given), while still honoring an explicit `--assignee <login>`. Never call raw `gh pr create` to open a PR. (`dev-loops pr create` dispatches here; the legacy `dev-loops pr create-draft` subcommand still works as a deprecated alias.)
74
74
 
75
75
  Usage:
76
- - `node scripts/github/create-draft-pr.mjs [gh pr create args...]`
77
- - `node <resolved-skill-scripts>/github/create-draft-pr.mjs [gh pr create args...]`
76
+ - `node scripts/github/create-pr.mjs [gh pr create args...]`
77
+ - `node <resolved-skill-scripts>/github/create-pr.mjs [gh pr create args...]`
78
78
 
79
79
  Contract:
80
- - injects exactly one `--draft` when the caller did not already supply it
80
+ - injects exactly one `--draft` when the caller did not already supply it (draft is the only mode)
81
+ - self-assigns by default (defaults `--assignee @me` when no `--assignee` is provided); honors an explicit `--assignee <login>` when one is given
81
82
  - rejects `--ready` before invoking `gh`; use `gh pr ready` later after draft-gate approval
82
83
  - forwards every other argument to `gh pr create` unchanged and in order
83
84
  - preserves the underlying `gh pr create` stdout, stderr, and exit code without wrapping success output
@@ -19,6 +19,7 @@ export {
19
19
  normalizeTimestamp,
20
20
  parseGateReviewCommentBody,
21
21
  parseGateReviewCommentMarkerBody,
22
+ resolveDraftGateRoundResetMs,
22
23
  summarizeCopilotReviews,
23
24
  summarizeGateReviewCommentMarkers,
24
25
  summarizeGateReviewComments,
@@ -20,6 +20,7 @@
20
20
  import { spawnSync } from "node:child_process";
21
21
  import path from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
+ import { parseArgs } from "node:util";
23
24
 
24
25
  import { ensureRunId } from "@dev-loops/core/loop/run-context";
25
26
  import {
@@ -28,22 +29,61 @@ import {
28
29
  DEFAULT_CLAUDE_BIN,
29
30
  } from "@dev-loops/core/claude/headless-entry";
30
31
 
31
- function parseArgs(argv) {
32
+ function parseCliArgs(argv) {
32
33
  const opts = { dryRun: false, claudeBin: DEFAULT_CLAUDE_BIN };
33
- const requireValue = (name, i) => {
34
- const v = argv[i + 1];
35
- if (v === undefined || v.startsWith("--")) throw new Error(`${name} requires a value`);
34
+ const requireValue = (name, token) => {
35
+ const v = token.value;
36
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("--")) {
37
+ throw new Error(`${name} requires a value`);
38
+ }
36
39
  return v;
37
40
  };
38
- for (let i = 0; i < argv.length; i++) {
39
- const a = argv[i];
40
- if (a === "--dry-run") opts.dryRun = true;
41
- else if (a === "--issue") { opts.issue = requireValue(a, i); i++; }
42
- else if (a === "--pr") { opts.pr = requireValue(a, i); i++; }
43
- else if (a === "--claude-bin") { opts.claudeBin = requireValue(a, i); i++; }
44
- else if (a === "--prompt") { opts.prompt = requireValue(a, i); i++; }
45
- else throw new Error(`unknown argument: ${a}`);
41
+
42
+ const { tokens } = parseArgs({
43
+ args: [...argv],
44
+ options: {
45
+ "dry-run": { type: "boolean" },
46
+ issue: { type: "string" },
47
+ pr: { type: "string" },
48
+ "claude-bin": { type: "string" },
49
+ prompt: { type: "string" },
50
+ },
51
+ allowPositionals: true,
52
+ strict: false,
53
+ tokens: true,
54
+ });
55
+
56
+ for (const token of tokens) {
57
+ if (token.kind === "positional") {
58
+ throw new Error(`unknown argument: ${token.value}`);
59
+ }
60
+ if (token.kind !== "option") {
61
+ continue;
62
+ }
63
+ switch (token.name) {
64
+ case "dry-run":
65
+ if (token.value !== undefined) {
66
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
67
+ }
68
+ opts.dryRun = true;
69
+ break;
70
+ case "issue":
71
+ opts.issue = requireValue("--issue", token);
72
+ break;
73
+ case "pr":
74
+ opts.pr = requireValue("--pr", token);
75
+ break;
76
+ case "claude-bin":
77
+ opts.claudeBin = requireValue("--claude-bin", token);
78
+ break;
79
+ case "prompt":
80
+ opts.prompt = requireValue("--prompt", token);
81
+ break;
82
+ default:
83
+ throw new Error(`unknown argument: ${token.rawName}`);
84
+ }
46
85
  }
86
+
47
87
  if (opts.issue != null && opts.pr != null) {
48
88
  throw new Error("--issue and --pr are mutually exclusive");
49
89
  }
@@ -53,7 +93,7 @@ function parseArgs(argv) {
53
93
  function main(argv) {
54
94
  let opts;
55
95
  try {
56
- opts = parseArgs(argv);
96
+ opts = parseCliArgs(argv);
57
97
  } catch (error) {
58
98
  process.stderr.write(JSON.stringify({ ok: false, error: error.message }) + "\n");
59
99
  return 1;
@@ -18,21 +18,55 @@
18
18
  import { spawnSync } from "node:child_process";
19
19
  import path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
+ import { parseArgs } from "node:util";
21
22
 
22
- function parseArgs(argv) {
23
+ function parseCliArgs(argv) {
23
24
  const opts = { loopInfo: false };
24
- const requireValue = (name, i) => {
25
- const v = argv[i + 1];
26
- if (v === undefined || v.startsWith("--")) throw new Error(`${name} requires a value`);
25
+ const requireValue = (name, token) => {
26
+ const v = token.value;
27
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("--")) {
28
+ throw new Error(`${name} requires a value`);
29
+ }
27
30
  return v;
28
31
  };
29
- for (let i = 0; i < argv.length; i++) {
30
- const a = argv[i];
31
- if (a === "--loop-info") opts.loopInfo = true;
32
- else if (a === "--issue") { opts.issue = requireValue(a, i); i++; }
33
- else if (a === "--pr") { opts.pr = requireValue(a, i); i++; }
34
- else throw new Error(`unknown argument: ${a}`);
32
+
33
+ const { tokens } = parseArgs({
34
+ args: [...argv],
35
+ options: {
36
+ "loop-info": { type: "boolean" },
37
+ issue: { type: "string" },
38
+ pr: { type: "string" },
39
+ },
40
+ allowPositionals: true,
41
+ strict: false,
42
+ tokens: true,
43
+ });
44
+
45
+ for (const token of tokens) {
46
+ if (token.kind === "positional") {
47
+ throw new Error(`unknown argument: ${token.value}`);
48
+ }
49
+ if (token.kind !== "option") {
50
+ continue;
51
+ }
52
+ switch (token.name) {
53
+ case "loop-info":
54
+ if (token.value !== undefined) {
55
+ throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
56
+ }
57
+ opts.loopInfo = true;
58
+ break;
59
+ case "issue":
60
+ opts.issue = requireValue("--issue", token);
61
+ break;
62
+ case "pr":
63
+ opts.pr = requireValue("--pr", token);
64
+ break;
65
+ default:
66
+ throw new Error(`unknown argument: ${token.rawName}`);
67
+ }
35
68
  }
69
+
36
70
  if (opts.issue != null && opts.pr != null) {
37
71
  throw new Error("--issue and --pr are mutually exclusive");
38
72
  }
@@ -46,7 +80,7 @@ function runCli(cliEntry, repoRoot, args) {
46
80
  function main(argv) {
47
81
  let opts;
48
82
  try {
49
- opts = parseArgs(argv);
83
+ opts = parseCliArgs(argv);
50
84
  } catch (error) {
51
85
  process.stderr.write(JSON.stringify({ ok: false, error: error.message }) + "\n");
52
86
  return 1;
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * build-adjacent-bundle.mjs — deterministic, neutral adjacent-code bundle builder.
4
+ *
5
+ * Part of issue #895 (build-once neutral context bundle for the gate fan-out).
6
+ * The gate context-builder runs ONCE and emits a generous, NEUTRAL bundle that
7
+ * every independent reviewer is seeded with verbatim — instead of every reviewer
8
+ * re-deriving the diff + adjacent code from scratch (the actual N× waste this
9
+ * fixes). The primary win is WORK-DEDUP (build once vs. N× re-derivation);
10
+ * prompt-cache of the shared prefix is an opportunistic bonus.
11
+ *
12
+ * Neutrality + determinism are guaranteed because this is a SCRIPT, not an
13
+ * agent: it cannot editorialize, and identical (head + changed files) input
14
+ * always produces an identical bundle (sorted file list, stable JSON shape).
15
+ *
16
+ * Bundle contents: for each changed JS/.mjs/.cjs file we include
17
+ * - the files it imports (1-hop out-edges), and
18
+ * - the files that import it (1-hop in-edges, via a repo-wide import scan)
19
+ * resolved against the repo (deterministic 1-hop import/require graph).
20
+ *
21
+ * Size guards (record, do not silently drop):
22
+ * - skip lockfiles (package-lock.json / *-lock.yaml), generated trees
23
+ * (.claude/, dist/, lib/, node_modules/, coverage/), binary, and minified
24
+ * files (*.min.*) — recorded in `stripped` with a reason.
25
+ * - cap per-file bytes (default ~48KB) and truncate the long tail —
26
+ * recorded in `truncated` with original/included byte counts.
27
+ *
28
+ * The output is purely structural adjacency; it does NOT carry any opinion,
29
+ * verdict, or main-agent state, so seeding a reviewer with it is the INTENDED
30
+ * neutral seed (RFC-2), not contamination.
31
+ */
32
+ import { open, readFile, readdir, stat } from "node:fs/promises";
33
+ import path from "node:path";
34
+
35
+ /** Default per-file byte cap before truncation. */
36
+ export const DEFAULT_MAX_FILE_BYTES = 48 * 1024;
37
+
38
+ /**
39
+ * Binary-sniff window: the NUL-byte heuristic inspects this many leading bytes.
40
+ * The guarded read must cover at least this window even when the per-file cap is
41
+ * configured smaller, so a small cap can't cause a binary (whose first NUL is
42
+ * past the cap) to be misclassified as text.
43
+ */
44
+ const BINARY_SNIFF_BYTES = 8 * 1024;
45
+
46
+ /** Source extensions we resolve import edges for (JS family, this repo). */
47
+ const SOURCE_EXTENSIONS = [".mjs", ".js", ".cjs"];
48
+
49
+ /** Directory prefixes treated as generated / vendored and skipped. */
50
+ const GENERATED_DIR_PREFIXES = [
51
+ ".claude/",
52
+ "dist/",
53
+ "lib/",
54
+ "node_modules/",
55
+ "coverage/",
56
+ ".git/",
57
+ ];
58
+
59
+ /**
60
+ * Classify why a file is skipped from the bundle, or null when it is includable.
61
+ * Deterministic, path-shape based. Exported for tests.
62
+ * @param {string} relPath — repo-relative POSIX path
63
+ * @returns {"lockfile"|"generated"|"binary"|"minified"|null}
64
+ */
65
+ export function classifyStripReason(relPath) {
66
+ const posix = String(relPath).replace(/\\/g, "/");
67
+ const base = posix.split("/").pop() ?? posix;
68
+
69
+ if (base === "package-lock.json" || base === "npm-shrinkwrap.json" || /-lock\.ya?ml$/.test(base) || base === "yarn.lock" || base === "pnpm-lock.yaml") {
70
+ return "lockfile";
71
+ }
72
+ for (const prefix of GENERATED_DIR_PREFIXES) {
73
+ if (posix === prefix.slice(0, -1) || posix.startsWith(prefix) || posix.includes(`/${prefix}`)) {
74
+ return "generated";
75
+ }
76
+ }
77
+ if (/\.min\.[a-z0-9]+$/i.test(base)) return "minified";
78
+ if (isBinaryPath(base)) return "binary";
79
+ return null;
80
+ }
81
+
82
+ const BINARY_EXTENSIONS = new Set([
83
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".pdf",
84
+ ".zip", ".gz", ".tgz", ".tar", ".bz2", ".7z", ".rar",
85
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
86
+ ".mp3", ".mp4", ".mov", ".avi", ".wav", ".ogg", ".webm",
87
+ ".so", ".dylib", ".dll", ".exe", ".bin", ".wasm", ".class", ".o", ".a",
88
+ ".node", ".lockb",
89
+ ]);
90
+
91
+ function isBinaryPath(base) {
92
+ const dot = base.lastIndexOf(".");
93
+ if (dot < 0) return false;
94
+ return BINARY_EXTENSIONS.has(base.slice(dot).toLowerCase());
95
+ }
96
+
97
+ /**
98
+ * Heuristic binary-content sniff: a NUL byte within the first 8KB marks the
99
+ * file as binary regardless of extension (covers extension-less binaries).
100
+ * @param {Buffer} buf
101
+ * @returns {boolean}
102
+ */
103
+ function looksBinaryContent(buf) {
104
+ const limit = Math.min(buf.length, BINARY_SNIFF_BYTES);
105
+ for (let i = 0; i < limit; i++) {
106
+ if (buf[i] === 0) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * Extract the static import/require specifiers from JS source text.
113
+ * Deterministic regex scan (no execution). Captures:
114
+ * - import ... from "x" / export ... from "x"
115
+ * - import("x") (dynamic import)
116
+ * - require("x")
117
+ * Returns specifiers in a deterministic, grouped-by-kind order — each kind is
118
+ * scanned in its own pass (export/import-from → bare side-effect import →
119
+ * dynamic import() → require()), so the output is grouped by form rather than
120
+ * by position in the source. De-duplicated (first occurrence wins).
121
+ * @param {string} source
122
+ * @returns {string[]}
123
+ */
124
+ export function extractImportSpecifiers(source) {
125
+ if (typeof source !== "string" || source.length === 0) return [];
126
+ const specs = [];
127
+ const seen = new Set();
128
+ const push = (s) => {
129
+ if (typeof s === "string" && s.length > 0 && !seen.has(s)) {
130
+ seen.add(s);
131
+ specs.push(s);
132
+ }
133
+ };
134
+ // import ... from "x" | export ... from "x"
135
+ const fromRe = /\b(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/g;
136
+ // bare side-effect import "x"
137
+ const bareRe = /\bimport\s+['"]([^'"]+)['"]/g;
138
+ // dynamic import("x")
139
+ const dynRe = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
140
+ // require("x")
141
+ const reqRe = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
142
+ for (const re of [fromRe, bareRe, dynRe, reqRe]) {
143
+ let m;
144
+ while ((m = re.exec(source)) !== null) push(m[1]);
145
+ }
146
+ return specs;
147
+ }
148
+
149
+ /**
150
+ * Resolve a RELATIVE import specifier (./ or ../) from a source file to a
151
+ * repo-relative POSIX path, trying common extensions and index files.
152
+ * Bare/package specifiers (no leading dot) are NOT resolved (out of repo).
153
+ * Pure path math against the provided existing-files set — deterministic.
154
+ * @param {string} fromRelPath — repo-relative path of the importing file
155
+ * @param {string} specifier
156
+ * @param {Set<string>} existing — set of repo-relative POSIX paths that exist
157
+ * @returns {string|null}
158
+ */
159
+ export function resolveRelativeImport(fromRelPath, specifier, existing) {
160
+ if (typeof specifier !== "string" || !specifier.startsWith(".")) return null;
161
+ const fromDir = path.posix.dirname(String(fromRelPath).replace(/\\/g, "/"));
162
+ const base = path.posix.normalize(path.posix.join(fromDir, specifier));
163
+ if (base.startsWith("..")) return null; // escaped the repo root
164
+ const candidates = [base];
165
+ for (const ext of SOURCE_EXTENSIONS) candidates.push(base + ext);
166
+ for (const ext of SOURCE_EXTENSIONS) candidates.push(path.posix.join(base, "index" + ext));
167
+ for (const c of candidates) {
168
+ if (existing.has(c)) return c;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function isSourceFile(relPath) {
174
+ const ext = path.posix.extname(String(relPath).replace(/\\/g, "/")).toLowerCase();
175
+ return SOURCE_EXTENSIONS.includes(ext);
176
+ }
177
+
178
+ /**
179
+ * Recursively list repo-relative POSIX paths under repoRoot, skipping
180
+ * generated/vendored dirs and the tmp/ scratch tree for determinism+speed.
181
+ * @param {string} repoRoot
182
+ * @returns {Promise<string[]>} sorted repo-relative POSIX paths
183
+ */
184
+ export async function listRepoFiles(repoRoot) {
185
+ const out = [];
186
+ const skipDir = new Set([".git", "node_modules", "dist", "lib", ".claude", "coverage", "tmp"]);
187
+ async function walk(absDir, relDir) {
188
+ let entries;
189
+ try {
190
+ entries = await readdir(absDir, { withFileTypes: true });
191
+ } catch {
192
+ return;
193
+ }
194
+ for (const ent of entries) {
195
+ if (ent.isSymbolicLink()) continue;
196
+ const relPath = relDir ? `${relDir}/${ent.name}` : ent.name;
197
+ if (ent.isDirectory()) {
198
+ if (skipDir.has(ent.name)) continue;
199
+ await walk(path.join(absDir, ent.name), relPath);
200
+ } else if (ent.isFile()) {
201
+ out.push(relPath);
202
+ }
203
+ }
204
+ }
205
+ await walk(repoRoot, "");
206
+ out.sort();
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Build the reverse-import (importers) index: for each source file, which other
212
+ * source files import it (1-hop in-edges). Deterministic given the repo files.
213
+ * @param {string[]} repoFiles — repo-relative POSIX paths
214
+ * @param {string} repoRoot
215
+ * @returns {Promise<Map<string, string[]>>} target → sorted importers
216
+ */
217
+ export async function buildImporterIndex(repoFiles, repoRoot) {
218
+ const existing = new Set(repoFiles);
219
+ const importers = new Map();
220
+ for (const file of repoFiles) {
221
+ if (!isSourceFile(file)) continue;
222
+ // Skip minified artifacts: they are stripped from the final bundle anyway and
223
+ // are not meaningful import sources, so reading them here is wasted I/O/memory
224
+ // on repos with large minified files.
225
+ if (/\.min\.[a-z0-9]+$/i.test(file)) continue;
226
+ let source;
227
+ try {
228
+ source = await readFile(path.resolve(repoRoot, file), "utf8");
229
+ } catch {
230
+ continue;
231
+ }
232
+ for (const spec of extractImportSpecifiers(source)) {
233
+ const target = resolveRelativeImport(file, spec, existing);
234
+ if (!target) continue;
235
+ if (!importers.has(target)) importers.set(target, []);
236
+ const list = importers.get(target);
237
+ if (!list.includes(file)) list.push(file);
238
+ }
239
+ }
240
+ for (const list of importers.values()) list.sort();
241
+ return importers;
242
+ }
243
+
244
+ /**
245
+ * Decide whether a repo-relative path is safe to resolve+read under repoRoot.
246
+ * Fails closed: rejects absolute paths, any `..` segment, and any resolved
247
+ * absolute path that escapes repoRoot. Deterministic, pure path math.
248
+ * @param {string} repoRoot
249
+ * @param {string} relPath — repo-relative POSIX path
250
+ * @returns {{ ok: true, abs: string } | { ok: false }}
251
+ */
252
+ export function resolveSafeRepoPath(repoRoot, relPath) {
253
+ const posix = String(relPath).replace(/\\/g, "/");
254
+ if (posix.length === 0) return { ok: false };
255
+ if (path.posix.isAbsolute(posix) || path.win32.isAbsolute(posix)) return { ok: false };
256
+ if (posix.split("/").includes("..")) return { ok: false };
257
+ const root = path.resolve(repoRoot);
258
+ const abs = path.resolve(root, posix);
259
+ if (abs !== root && !abs.startsWith(root + path.sep)) return { ok: false };
260
+ return { ok: true, abs };
261
+ }
262
+
263
+ /**
264
+ * Read a file with the size guards applied. Returns the (possibly truncated)
265
+ * content plus guard metadata. Never throws on a missing/unreadable file.
266
+ *
267
+ * Path safety: unsafe relPaths (absolute, `..` segments, or escaping repoRoot)
268
+ * are NOT read — they are recorded with strip reason "unsafe-path".
269
+ *
270
+ * Bounded read: when the file is over the cap it is truncated, so we read at
271
+ * most maxFileBytes+1 bytes (the +1 lets us also sniff binary content past the
272
+ * cap without loading the whole file). The full original size is read from
273
+ * stat() for the manifest.
274
+ * @param {string} repoRoot
275
+ * @param {string} relPath
276
+ * @param {number} maxFileBytes
277
+ * @returns {Promise<{ relPath: string, content: string|null, bytes: number, includedBytes: number, truncated: boolean, missing: boolean, strip: string|null }>}
278
+ */
279
+ async function readGuardedFile(repoRoot, relPath, maxFileBytes) {
280
+ const strip = classifyStripReason(relPath);
281
+ if (strip) {
282
+ return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: false, strip };
283
+ }
284
+ const safe = resolveSafeRepoPath(repoRoot, relPath);
285
+ if (!safe.ok) {
286
+ return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: false, strip: "unsafe-path" };
287
+ }
288
+ const abs = safe.abs;
289
+ let handle;
290
+ try {
291
+ const info = await stat(abs);
292
+ if (!info.isFile()) {
293
+ return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: true, strip: null };
294
+ }
295
+ const bytes = info.size;
296
+ // Read enough to (a) detect over-cap and truncate, and (b) always cover the
297
+ // binary-sniff window — so a cap configured below BINARY_SNIFF_BYTES can't let
298
+ // a binary slip through as text. Still bounded (never the whole large file).
299
+ const readCap = Math.min(bytes, Math.max(maxFileBytes + 1, BINARY_SNIFF_BYTES));
300
+ const buf = Buffer.allocUnsafe(readCap);
301
+ handle = await open(abs, "r");
302
+ const { bytesRead } = await handle.read(buf, 0, readCap, 0);
303
+ const data = buf.subarray(0, bytesRead);
304
+ if (looksBinaryContent(data)) {
305
+ return { relPath, content: null, bytes, includedBytes: 0, truncated: false, missing: false, strip: "binary" };
306
+ }
307
+ let truncated = false;
308
+ let slice = data;
309
+ if (bytes > maxFileBytes) {
310
+ slice = data.subarray(0, maxFileBytes);
311
+ truncated = true;
312
+ }
313
+ return {
314
+ relPath,
315
+ content: slice.toString("utf8"),
316
+ bytes,
317
+ includedBytes: slice.length,
318
+ truncated,
319
+ missing: false,
320
+ strip: null,
321
+ };
322
+ } catch {
323
+ return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: true, strip: null };
324
+ } finally {
325
+ if (handle) await handle.close().catch(() => {});
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Build the deterministic, neutral adjacent-code bundle for a set of changed
331
+ * files. For each changed source file, collect its 1-hop import out-edges
332
+ * (resolved relative imports) and in-edges (files that import it) and include
333
+ * each adjacent file's (guarded) content.
334
+ *
335
+ * Output shape (added to the gate-context artifact under `adjacentCode`):
336
+ * {
337
+ * maxFileBytes,
338
+ * files: [{ path, role: "changed"|"imports"|"importedBy", relatedTo: [..],
339
+ * bytes, includedBytes, truncated, content }], // sorted by path
340
+ * stripped: [{ path, reason, role: "changed"|"imports"|"importedBy",
341
+ * relatedTo: [..] }], // sorted by path
342
+ * truncated: [{ path, bytes, includedBytes }], // sorted by path
343
+ * missing: [string], // sorted
344
+ * }
345
+ *
346
+ * Deterministic: same (repoRoot contents + changedFiles) → identical bundle.
347
+ *
348
+ * @param {object} input
349
+ * @param {string[]} input.changedFiles — repo-relative paths from the diff
350
+ * @param {string} [input.repoRoot]
351
+ * @param {number} [input.maxFileBytes]
352
+ * @returns {Promise<object>}
353
+ */
354
+ export async function buildAdjacentBundle({ changedFiles, repoRoot = process.cwd(), maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
355
+ const changed = Array.from(
356
+ new Set((Array.isArray(changedFiles) ? changedFiles : []).map((f) => String(f).replace(/\\/g, "/")).filter((f) => f.length > 0)),
357
+ ).sort();
358
+
359
+ // No changed files → empty bundle. Return early so the empty case is O(1) and
360
+ // skips the whole-repo scan + importer-index build entirely.
361
+ if (changed.length === 0) {
362
+ return { maxFileBytes, files: [], stripped: [], truncated: [], missing: [] };
363
+ }
364
+
365
+ const repoFiles = await listRepoFiles(repoRoot);
366
+ const existing = new Set(repoFiles);
367
+ const importerIndex = await buildImporterIndex(repoFiles, repoRoot);
368
+
369
+ // role per path: changed > imports/importedBy. relatedTo accumulates the
370
+ // changed files an adjacent file is adjacent to (sorted, deduped).
371
+ /** @type {Map<string, { role: string, relatedTo: Set<string> }>} */
372
+ const selected = new Map();
373
+ const note = (relPath, role, relatedTo) => {
374
+ if (!relPath) return;
375
+ let entry = selected.get(relPath);
376
+ if (!entry) {
377
+ entry = { role, relatedTo: new Set() };
378
+ selected.set(relPath, entry);
379
+ } else if (entry.role !== "changed" && role === "changed") {
380
+ entry.role = "changed";
381
+ }
382
+ if (relatedTo) entry.relatedTo.add(relatedTo);
383
+ };
384
+
385
+ for (const file of changed) {
386
+ note(file, "changed", null);
387
+ }
388
+
389
+ for (const file of changed) {
390
+ // Out-edges: imports declared by the changed file (source files only).
391
+ if (isSourceFile(file) && existing.has(file)) {
392
+ try {
393
+ const source = await readFile(path.resolve(repoRoot, file), "utf8");
394
+ for (const spec of extractImportSpecifiers(source)) {
395
+ const target = resolveRelativeImport(file, spec, existing);
396
+ if (target && !changed.includes(target)) note(target, "imports", file);
397
+ }
398
+ } catch {
399
+ // unreadable changed file — diff still carries it; skip out-edges
400
+ }
401
+ }
402
+ // In-edges: files that import the changed file.
403
+ const importers = importerIndex.get(file) ?? [];
404
+ for (const imp of importers) {
405
+ if (!changed.includes(imp)) note(imp, "importedBy", file);
406
+ }
407
+ }
408
+
409
+ const files = [];
410
+ const stripped = [];
411
+ const truncatedList = [];
412
+ const missing = [];
413
+
414
+ for (const relPath of Array.from(selected.keys()).sort()) {
415
+ const entry = selected.get(relPath);
416
+ const relatedTo = Array.from(entry.relatedTo).sort();
417
+ const guarded = await readGuardedFile(repoRoot, relPath, maxFileBytes);
418
+ if (guarded.strip) {
419
+ stripped.push({ path: relPath, reason: guarded.strip, role: entry.role, relatedTo });
420
+ continue;
421
+ }
422
+ if (guarded.missing) {
423
+ // A changed file deleted by the diff legitimately won't exist on disk.
424
+ missing.push(relPath);
425
+ continue;
426
+ }
427
+ if (guarded.truncated) {
428
+ truncatedList.push({ path: relPath, bytes: guarded.bytes, includedBytes: guarded.includedBytes });
429
+ }
430
+ files.push({
431
+ path: relPath,
432
+ role: entry.role,
433
+ relatedTo,
434
+ bytes: guarded.bytes,
435
+ includedBytes: guarded.includedBytes,
436
+ truncated: guarded.truncated,
437
+ content: guarded.content,
438
+ });
439
+ }
440
+
441
+ return {
442
+ maxFileBytes,
443
+ files,
444
+ stripped,
445
+ truncated: truncatedList,
446
+ missing: missing.sort(),
447
+ };
448
+ }