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
@@ -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
+ }
@@ -8,7 +8,8 @@ import {
8
8
  parseReviewThreads,
9
9
  readInput,
10
10
  } from "../_core-helpers.mjs";
11
- import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
11
+ import { parseArgs } from "node:util";
12
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
12
13
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
13
14
  export const REVIEW_THREADS_QUERY = [
14
15
  "query($owner: String!, $name: String!, $pr: Int!) {",
@@ -49,7 +50,19 @@ Exit codes:
49
50
  1 Error
50
51
  `;
51
52
  export function parseCaptureCliArgs(argv) {
52
- const args = [...argv];
53
+ const { tokens } = parseArgs({
54
+ args: [...argv],
55
+ options: {
56
+ help: { type: "boolean", short: "h" },
57
+ input: { type: "string" },
58
+ output: { type: "string" },
59
+ repo: { type: "string" },
60
+ pr: { type: "string" },
61
+ },
62
+ allowPositionals: true,
63
+ strict: false,
64
+ tokens: true,
65
+ });
53
66
  const options = {
54
67
  inputPath: undefined,
55
68
  outputPath: undefined,
@@ -57,29 +70,34 @@ export function parseCaptureCliArgs(argv) {
57
70
  pr: undefined,
58
71
  help: false,
59
72
  };
60
- while (args.length > 0) {
61
- const token = args.shift();
62
- if (token === "--help" || token === "-h") {
73
+ for (const token of tokens) {
74
+ if (token.kind === "positional") {
75
+ throw new Error(`Unknown argument: ${token.value}`);
76
+ }
77
+ if (token.kind !== "option") {
78
+ continue;
79
+ }
80
+ if (token.name === "help") {
63
81
  options.help = true;
64
82
  return options;
65
83
  }
66
- if (token === "--input") {
67
- options.inputPath = requireOptionValue(args, "--input");
84
+ if (token.name === "input") {
85
+ options.inputPath = requireTokenValue(token);
68
86
  continue;
69
87
  }
70
- if (token === "--output") {
71
- options.outputPath = requireOptionValue(args, "--output");
88
+ if (token.name === "output") {
89
+ options.outputPath = requireTokenValue(token);
72
90
  continue;
73
91
  }
74
- if (token === "--repo") {
75
- options.repo = requireOptionValue(args, "--repo");
92
+ if (token.name === "repo") {
93
+ options.repo = requireTokenValue(token);
76
94
  continue;
77
95
  }
78
- if (token === "--pr") {
79
- options.pr = parsePrNumber(requireOptionValue(args, "--pr"));
96
+ if (token.name === "pr") {
97
+ options.pr = parsePrNumber(requireTokenValue(token));
80
98
  continue;
81
99
  }
82
- throw new Error(`Unknown argument: ${token}`);
100
+ throw new Error(`Unknown argument: ${token.rawName}`);
83
101
  }
84
102
  const hasLiveArgs = options.repo !== undefined || options.pr !== undefined;
85
103
  const hasCompleteLiveArgs = options.repo !== undefined && options.pr !== undefined;
@@ -2,17 +2,20 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
- const USAGE = `Usage: create-draft-pr.mjs [gh pr create args...]
6
- Thin wrapper around \`gh pr create\` that enforces draft-first PR creation.
5
+ const USAGE = `Usage: create-pr.mjs [gh pr create args...]
6
+ Canonical PR-creation wrapper around \`gh pr create\`. Every PR opened through this
7
+ tool is ALWAYS a draft and is self-assigned by default. Never call raw \`gh pr create\`.
7
8
  Behavior:
8
- - injects exactly one \`--draft\` when absent
9
+ - injects exactly one \`--draft\` when absent (draft is the only mode)
10
+ - defaults \`--assignee @me\` when no assignee is given (self-assigned by default)
11
+ - honors an explicit \`--assignee <login>\` / \`-a <login>\` when supplied (no default injected)
9
12
  - rejects \`--ready\` before invoking \`gh\`
10
13
  - detects missing \`Closes #N\` / \`Fixes #N\` in \`--body\` or \`--body-file\` content (non-fatal stderr warning)
11
14
  - forwards every other argument to \`gh pr create\` unchanged
12
15
  - preserves the underlying \`gh pr create\` stdout, stderr, and exit code
13
16
  Examples:
14
- node scripts/github/create-draft-pr.mjs --repo owner/repo --assignee @me --base main --head feature --title "..." --body-file pr.md
15
- node <resolved-skill-scripts>/github/create-draft-pr.mjs --repo owner/repo --assignee @me --base main --head feature --title "..." --body-file pr.md
17
+ node scripts/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
18
+ node <resolved-skill-scripts>/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
16
19
  Notes:
17
20
  - Use \`gh pr ready\` later to leave draft state; this wrapper never opens a ready PR.
18
21
  - Wrapper-owned validation is limited to \`--ready\`; all other argument validation is left to \`gh pr create\`.
@@ -25,6 +28,12 @@ const parseError = buildParseError(USAGE);
25
28
  const READY_FLAG_PATTERN = /^--ready(?:$|=)/u;
26
29
  const DRAFT_FLAG_PATTERN = /^--draft(?:=(.*))?$/iu;
27
30
  const DRAFT_TRUE_VALUE_PATTERN = /^(?:true|1)$/iu;
31
+ // Detect both the long `--assignee`/`--assignee=<login>` forms and the `-a`
32
+ // short flag that `gh pr create` documents, so an explicit assignee in either
33
+ // form suppresses the `--assignee @me` default (otherwise a caller passing
34
+ // `-a <login>` would get a conflicting `--assignee @me` injected). (#894)
35
+ const ASSIGNEE_FLAG_PATTERN = /^(?:--assignee(?:$|=)|-a$)/u;
36
+ const DEFAULT_ASSIGNEE = "@me";
28
37
  const CLOSING_KEYWORD_PATTERN = /Closes\s+#\d+|Fixes\s+#\d+/i;
29
38
  const MAX_BODY_SCAN_BYTES = 16 * 1024;
30
39
  export function detectClosingKeyword(body) {
@@ -52,12 +61,12 @@ async function warnMissingClosingKeyword(args) {
52
61
  if (body === null) return; // no --body or --body-file, skip
53
62
  if (!detectClosingKeyword(body)) {
54
63
  process.stderr.write(
55
- "[create-draft-pr] Warning: PR body missing `Closes #N` or `Fixes #N`. " +
64
+ "[create-pr] Warning: PR body missing `Closes #N` or `Fixes #N`. " +
56
65
  "GitHub will not auto-close the linked issue on merge.\n",
57
66
  );
58
67
  }
59
68
  }
60
- export function buildCreateDraftPrArgs(argv) {
69
+ export function buildCreatePrArgs(argv) {
61
70
  const args = [...argv];
62
71
  if (args.includes("--help") || args.includes("-h")) {
63
72
  return {
@@ -66,17 +75,24 @@ export function buildCreateDraftPrArgs(argv) {
66
75
  };
67
76
  }
68
77
  if (args.some((token) => READY_FLAG_PATTERN.test(token))) {
69
- throw parseError("create-draft-pr rejects --ready; open the PR as draft first, then run `gh pr ready` after the draft gate is satisfied");
78
+ throw parseError("create-pr rejects --ready; open the PR as draft first, then run `gh pr ready` after the draft gate is satisfied");
70
79
  }
71
80
  const draftTokens = args.filter((token) => DRAFT_FLAG_PATTERN.test(token));
72
81
  const lastDraftToken = draftTokens.length > 0 ? draftTokens.at(-1) : null;
73
82
  const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && DRAFT_TRUE_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
83
+ const hasAssignee = args.some((token) => ASSIGNEE_FLAG_PATTERN.test(token));
74
84
  return {
75
85
  help: false,
76
- ghArgs: ["pr", "create", ...args, ...(lastDraftSuppliesDraft ? [] : ["--draft"])],
86
+ ghArgs: [
87
+ "pr",
88
+ "create",
89
+ ...args,
90
+ ...(hasAssignee ? [] : ["--assignee", DEFAULT_ASSIGNEE]),
91
+ ...(lastDraftSuppliesDraft ? [] : ["--draft"]),
92
+ ],
77
93
  };
78
94
  }
79
- export function spawnCreateDraftPr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
95
+ export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
80
96
  return new Promise((resolve, reject) => {
81
97
  const child = spawn(ghCommand, ghArgs, {
82
98
  env,
@@ -89,13 +105,13 @@ export function spawnCreateDraftPr(ghArgs, { ghCommand = "gh", env = process.env
89
105
  });
90
106
  }
91
107
  export async function main(argv = process.argv.slice(2), runtime = {}) {
92
- const { help, ghArgs } = buildCreateDraftPrArgs(argv);
108
+ const { help, ghArgs } = buildCreatePrArgs(argv);
93
109
  if (help) {
94
110
  process.stdout.write(`${USAGE}\n`);
95
111
  return 0;
96
112
  }
97
113
  await warnMissingClosingKeyword(argv);
98
- return spawnCreateDraftPr(ghArgs, runtime);
114
+ return spawnCreatePr(ghArgs, runtime);
99
115
  }
100
116
  if (isDirectCliRun(import.meta.url)) {
101
117
  try {