dev-loops 0.4.0 → 0.5.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 (49) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/skills/dev-loop/SKILL.md +16 -6
  5. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  6. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  7. package/.claude/skills/docs/local-planning-flow.md +63 -0
  8. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  9. package/.claude/skills/docs/merge-preconditions.md +35 -0
  10. package/.claude/skills/docs/plan-file-contract.md +37 -0
  11. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  12. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  13. package/CHANGELOG.md +43 -0
  14. package/README.md +1 -0
  15. package/agents/refiner.agent.md +1 -0
  16. package/package.json +5 -3
  17. package/scripts/github/capture-review-threads.mjs +20 -2
  18. package/scripts/github/probe-copilot-review.mjs +69 -3
  19. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  20. package/scripts/lib/jq-output.mjs +297 -0
  21. package/scripts/loop/check-retro-tooling.mjs +246 -0
  22. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  23. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  24. package/scripts/loop/docs-grill-contract.mjs +70 -0
  25. package/scripts/loop/info.mjs +21 -2
  26. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  27. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  28. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  29. package/scripts/loop/run-watch-cycle.mjs +77 -3
  30. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  31. package/scripts/pages/build-site.mjs +136 -0
  32. package/scripts/projects/add-queue-item.mjs +12 -2
  33. package/scripts/projects/list-queue-items.mjs +12 -2
  34. package/scripts/projects/move-queue-item.mjs +12 -2
  35. package/scripts/refine/_refine-helpers.mjs +20 -0
  36. package/scripts/refine/exit-spike.mjs +186 -0
  37. package/scripts/refine/promote-plan.mjs +387 -0
  38. package/scripts/refine/refine-plan-file.mjs +165 -0
  39. package/scripts/refine/validate-plan-file.mjs +64 -0
  40. package/scripts/refine/validate-spike-file.mjs +87 -0
  41. package/skills/dev-loop/SKILL.md +11 -1
  42. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  43. package/skills/docs/artifact-authority-contract.md +86 -31
  44. package/skills/docs/local-planning-flow.md +63 -0
  45. package/skills/docs/local-planning-worked-example.md +139 -0
  46. package/skills/docs/merge-preconditions.md +35 -0
  47. package/skills/docs/plan-file-contract.md +37 -0
  48. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  49. package/skills/docs/spike-mode-contract.md +237 -0
@@ -23,18 +23,37 @@ import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
23
23
  import { isCopilotLogin } from "@dev-loops/core/github/copilot-helpers";
24
24
  import { loadDevLoopConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
25
25
  import { createPiAdapter } from "@dev-loops/core/harness";
26
+ import { validatePlanFile } from "../refine/validate-plan-file.mjs";
27
+ import {
28
+ validateSpikeExplorationSections,
29
+ SPIKE_FILE_EXIT_MARKER_SECTION,
30
+ } from "../refine/validate-spike-file.mjs";
31
+ import { extractSection } from "../refine/_refine-helpers.mjs";
32
+ import {
33
+ evaluatePlanFileIntakeState,
34
+ PLAN_FILE_REFINEMENT_SECTIONS,
35
+ } from "@dev-loops/core/loop/plan-file-intake-contract";
36
+ import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
26
37
  import { parseArgs } from "node:util";
27
38
  const USAGE = `Usage:
28
39
  resolve-dev-loop-startup.mjs --issue <number>
29
40
  resolve-dev-loop-startup.mjs --pr <number>
30
41
  resolve-dev-loop-startup.mjs --input <path>
42
+ resolve-dev-loop-startup.mjs --plan-file <path>
43
+ resolve-dev-loop-startup.mjs --spike <path>
31
44
  Resolve the authoritative public dev-loop startup/resume bundle.
32
45
  Auto-resolves state from GitHub API, git remote, and settings when
33
46
  --issue or --pr is used. Use --input for non-standard states.
47
+ Use --plan-file to start local planning from a phase-doc-format plan
48
+ (read-only: no tracker mutation, no issue/PR number).
49
+ Use --spike to start a time-boxed exploratory loop from a local spike
50
+ artifact (read-only: no tracker mutation, no issue/PR number).
34
51
  Required (exactly one):
35
52
  --issue <n> Target an issue by number (auto-resolves all state)
36
53
  --pr <n> Target a PR by number (auto-resolves all state)
37
54
  --input <path> Path to a JSON file with canonical-state payload
55
+ --plan-file <path> Path to a phase-doc-format plan to start locally
56
+ --spike <path> Path to a spike artifact to start a spike loop locally
38
57
  Exit codes:
39
58
  0 Success
40
59
  1 Argument error, runtime failure, or async-start contract rejection`.trim();
@@ -102,6 +121,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
102
121
  inputPath: undefined,
103
122
  issue: undefined,
104
123
  pr: undefined,
124
+ planFile: undefined,
125
+ spike: undefined,
105
126
  };
106
127
  const { tokens } = parseArgs({
107
128
  args: [...argv],
@@ -110,6 +131,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
110
131
  input: { type: "string" },
111
132
  issue: { type: "string" },
112
133
  pr: { type: "string" },
134
+ "plan-file": { type: "string" },
135
+ spike: { type: "string" },
113
136
  },
114
137
  allowPositionals: true,
115
138
  strict: false,
@@ -138,14 +161,22 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
138
161
  options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
139
162
  continue;
140
163
  }
164
+ if (token.name === "plan-file") {
165
+ options.planFile = requireTokenValue(token, parseError);
166
+ continue;
167
+ }
168
+ if (token.name === "spike") {
169
+ options.spike = requireTokenValue(token, parseError);
170
+ continue;
171
+ }
141
172
  throw parseError(`Unknown argument: ${token.rawName}`);
142
173
  }
143
- const modeCount = [options.inputPath, options.issue, options.pr].filter(v => v !== undefined).length;
174
+ const modeCount = [options.inputPath, options.issue, options.pr, options.planFile, options.spike].filter(v => v !== undefined).length;
144
175
  if (modeCount > 1) {
145
- throw parseError("--issue, --pr, and --input are mutually exclusive; provide exactly one");
176
+ throw parseError("--issue, --pr, --input, --plan-file, and --spike are mutually exclusive; provide exactly one");
146
177
  }
147
178
  if (modeCount === 0) {
148
- throw parseError("--input <path>, --issue <n>, or --pr <n> is required");
179
+ throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, or --spike <path> is required");
149
180
  }
150
181
  return options;
151
182
  }
@@ -368,6 +399,118 @@ export function buildAutoResolvedInput({ issue, pr, cwd, targetPreference, input
368
399
  },
369
400
  };
370
401
  }
402
+ /**
403
+ * Read + validate a `--plan-file` path and build a local_phase startup input.
404
+ *
405
+ * Read-only: no tracker mutation, no GitHub calls, no issue/PR number. A
406
+ * missing/unreadable file, or one failing the base-section validator, throws so
407
+ * the CLI fails closed (exit 1, no readiness bundle). The plan-file path is
408
+ * carried as the target `phase` and is exempt from the worktree-isolation guard
409
+ * because there is no issue to key a worktree on before promotion.
410
+ *
411
+ * @returns {object} startup input with a `planFileIntakeState` field threaded onto output
412
+ */
413
+ export function buildPlanFileInput({ planFilePath }) {
414
+ const resolvedPath = path.resolve(planFilePath);
415
+ let markdownText;
416
+ try {
417
+ markdownText = readFileSync(resolvedPath, "utf8");
418
+ } catch (err) {
419
+ throw new Error(`Plan file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
420
+ }
421
+ const validation = validatePlanFile(markdownText);
422
+ if (!validation.ok) {
423
+ const codes = validation.errors.map(e => e.code).join(", ");
424
+ throw new Error(`Plan file failed validation (${resolvedPath}): ${codes}`);
425
+ }
426
+ // Refined-vs-needs-refinement: refinement adds Acceptance criteria + Definition
427
+ // of done on top of the base authoring sections. Detect each via extractSection
428
+ // (non-null trimmed body == present and non-empty), then classify with the pure
429
+ // intake evaluator.
430
+ const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
431
+ const hasAcceptanceCriteria = extractSection(markdownText, acHeading) ? true : false;
432
+ const hasDefinitionOfDone = extractSection(markdownText, dodHeading) ? true : false;
433
+ const { state: planFileIntakeState } = evaluatePlanFileIntakeState({
434
+ baseSectionsValid: true,
435
+ hasAcceptanceCriteria,
436
+ hasDefinitionOfDone,
437
+ });
438
+ return {
439
+ intent: "start_issue_locally",
440
+ mode: "bounded_handoff",
441
+ targetPreference: "prefer_local",
442
+ artifactState: "not_applicable",
443
+ issueLinkageResolution: "not_applicable",
444
+ issueReadiness: "not_applicable",
445
+ issueAssignmentState: "not_applicable",
446
+ loopState: "implementation_pending",
447
+ planFileIntakeState,
448
+ planFileExempt: true,
449
+ currentState: {
450
+ target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
451
+ ownership: "local",
452
+ nextActor: "local",
453
+ status: "active",
454
+ authorization: "authorized",
455
+ },
456
+ };
457
+ }
458
+ /**
459
+ * Read + validate a `--spike` path and build a local_phase startup input for a
460
+ * time-boxed exploratory loop.
461
+ *
462
+ * Read-only: no tracker mutation, no GitHub calls, no issue/PR number — a spike
463
+ * is startable from a local question with no GitHub issue and no production-gate
464
+ * ceremony at entry. A missing/unreadable file, or one missing the exploration
465
+ * scaffold (Question/Approach/Findings), throws so the CLI fails closed (exit 1,
466
+ * no readiness bundle). The Recommendation section is filled in during the spike;
467
+ * its presence flips the intake state to spike_ready_for_exit (the seam phase 2's
468
+ * discard/graduate exits consume). The spike path is carried as the target
469
+ * `phase` and is exempt from the worktree-isolation guard because there is no
470
+ * issue to key a worktree on.
471
+ *
472
+ * @returns {object} startup input with a `spikeIntakeState` field threaded onto output
473
+ */
474
+ export function buildSpikeInput({ spikeFilePath }) {
475
+ const resolvedPath = path.resolve(spikeFilePath);
476
+ let markdownText;
477
+ try {
478
+ markdownText = readFileSync(resolvedPath, "utf8");
479
+ } catch (err) {
480
+ throw new Error(`Spike file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
481
+ }
482
+ // Entry gate: the exploration scaffold must be present and non-empty. A
483
+ // malformed spike artifact fails closed before any intake classification.
484
+ const validation = validateSpikeExplorationSections(markdownText);
485
+ if (!validation.ok) {
486
+ const codes = validation.errors.map(e => e.code).join(", ");
487
+ throw new Error(`Spike file failed validation (${resolvedPath}): ${codes}`);
488
+ }
489
+ const hasRecommendation = extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION) ? true : false;
490
+ const { state: spikeIntakeState } = evaluateSpikeIntakeState({
491
+ baseSectionsValid: true,
492
+ hasRecommendation,
493
+ });
494
+ return {
495
+ intent: "start_issue_locally",
496
+ mode: "bounded_handoff",
497
+ targetPreference: "prefer_local",
498
+ artifactState: "not_applicable",
499
+ issueLinkageResolution: "not_applicable",
500
+ issueReadiness: "not_applicable",
501
+ issueAssignmentState: "not_applicable",
502
+ loopState: "implementation_pending",
503
+ spikeIntakeState,
504
+ planFileExempt: true,
505
+ currentState: {
506
+ target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
507
+ ownership: "local",
508
+ nextActor: "local",
509
+ status: "active",
510
+ authorization: "authorized",
511
+ },
512
+ };
513
+ }
371
514
  export function summarizeCanonicalState(bundle) {
372
515
  return {
373
516
  target: bundle.canonicalState?.target ?? null,
@@ -390,6 +533,16 @@ export function summarizeCanonicalState(bundle) {
390
533
  export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdapter(), env, cwd, asyncStartMode = "required" } = {}) {
391
534
  const effectiveEnv = env ?? adapter.getEnv();
392
535
  const effectiveCwd = cwd ?? adapter.getCwd();
536
+ // Normalize a non-object input (e.g. `--input null`, which parses to a legal
537
+ // JSON null) to {} so the destructure below cannot throw before routing can
538
+ // fail closed with a structured reconcile bundle.
539
+ if (input === null || typeof input !== "object") input = {};
540
+ // Plan-file intake carries two resolver-only fields that the pure routing
541
+ // evaluator does not model. Strip them before evaluation and re-apply them to
542
+ // the result; `planFileExempt` waives the worktree-isolation guard because a
543
+ // pre-promotion plan has no issue to key a worktree on.
544
+ const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, ...routingInput } = input;
545
+ input = routingInput;
393
546
  try {
394
547
  const checkpointText = readFileSync(
395
548
  path.join(effectiveCwd, ".pi", "dev-loop-retrospective-checkpoint.json"),
@@ -440,6 +593,7 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
440
593
  const DEVLOOPS_WORKTREE_BYPASS_VAR = "DEVLOOPS_WORKTREE_BYPASS";
441
594
  if (
442
595
  strategyKey === "local_implementation" &&
596
+ !planFileExempt &&
443
597
  (effectiveEnv[DEVLOOPS_WORKTREE_BYPASS_VAR] ?? "").trim() !== "1"
444
598
  ) {
445
599
  try {
@@ -496,6 +650,8 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
496
650
  requiredReads: STRATEGY_REQUIRED_READS[strategyKey],
497
651
  nextAction: bundle.nextAction,
498
652
  canonicalStateSummary: summarizeCanonicalState(bundle),
653
+ ...(planFileIntakeState !== null ? { planFileIntakeState } : {}),
654
+ ...(spikeIntakeState !== null ? { spikeIntakeState } : {}),
499
655
  bundle,
500
656
  };
501
657
  }
@@ -521,9 +677,24 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
521
677
  ? normalizeConfigInputSource(devLoopConfig?.inputSource?.default)
522
678
  : "tracker";
523
679
  let input;
524
- if (options.inputPath !== undefined) {
680
+ if (options.spike !== undefined) {
681
+ input = buildSpikeInput({ spikeFilePath: options.spike });
682
+ } else if (options.planFile !== undefined) {
683
+ input = buildPlanFileInput({ planFilePath: options.planFile });
684
+ } else if (options.inputPath !== undefined) {
525
685
  const text = await readFile(path.resolve(options.inputPath), "utf8");
526
- input = parseJsonText(text);
686
+ const parsed = parseJsonText(text);
687
+ // `--input` is untrusted external JSON. Strip the resolver-only intake fields
688
+ // so it cannot inject them — `planFileExempt` would otherwise waive the
689
+ // worktree-isolation guard for a normal local_implementation, and the intake
690
+ // state is owned by the internal plan-file path (buildPlanFileInput), not the
691
+ // caller.
692
+ if (parsed && typeof parsed === "object") {
693
+ delete parsed.planFileExempt;
694
+ delete parsed.planFileIntakeState;
695
+ delete parsed.spikeIntakeState;
696
+ }
697
+ input = parsed;
527
698
  } else if (options.issue !== undefined) {
528
699
  input = buildAutoResolvedInput({
529
700
  issue: options.issue,
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { parseArgs } from "node:util";
6
+
7
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
8
+ import { requireTokenValue } from "../_cli-primitives.mjs";
9
+
10
+ const USAGE = `Usage: resolve-pr-conflicts.mjs [--base <branch>] [--repo-root <dir>] [--no-verify] [--push] [--json]
11
+
12
+ Deterministic, conservative auto-resolve for a PR branch that is behind/CONFLICTING
13
+ with its base. Merges \`origin/<base>\` into the current branch and resolves ONLY the
14
+ safe additive case: a CHANGELOG.md conflict where both sides only ADD list/section
15
+ entries (keep BOTH sides, in order). ANY other conflicted path — or a non-additive
16
+ CHANGELOG edit — FAILS CLOSED, naming the conflicted paths. Never guesses.
17
+
18
+ After a clean merge (or an auto-resolved additive CHANGELOG) it runs \`npm run test:docs\`
19
+ (unless --no-verify) and, with --push, pushes the branch.
20
+
21
+ Options:
22
+ --base <branch> Base branch to merge from (default: derived from \`gh pr view\`
23
+ base, falling back to "main").
24
+ --repo-root <dir> Repo working tree to operate in (default: cwd).
25
+ --no-verify Skip \`npm run test:docs\` after resolving.
26
+ --push Push the resolved branch to origin after verify.
27
+ --json Emit machine-readable JSON on stdout.
28
+
29
+ Output (stdout, JSON with --json):
30
+ { "ok": true, "action": "clean_merge"|"resolved",
31
+ "base": "main", "resolvedFiles": ["CHANGELOG.md"], "pushed": false,
32
+ "verified": true }
33
+ ("clean_merge" covers an already-up-to-date branch; "resolved" is the
34
+ auto-resolved additive-CHANGELOG case. "verified" is true when post-resolve
35
+ verification ran; it is absent with --no-verify.)
36
+ Error output:
37
+ { "ok": false, "error": "...", "conflictFiles": ["..."] }
38
+
39
+ Exit codes:
40
+ 0 Clean merge (incl. already up to date) or auto-resolved CHANGELOG
41
+ 1 Argument error, git failure, or UNRESOLVABLE conflict (fail closed)`.trim();
42
+
43
+ const parseError = buildParseError(USAGE);
44
+
45
+ const SAFE_RESOLVABLE_PATH = "CHANGELOG.md";
46
+
47
+ // Identity for merge/resolve commits so auto-resolve works on a clean runner
48
+ // (CI / consumer) where no ambient git user is configured. Per-command via -c
49
+ // so we never mutate the repo's config.
50
+ const BOT_IDENTITY = [
51
+ "-c", "user.name=dev-loops[bot]",
52
+ "-c", "user.email=dev-loops[bot]@users.noreply.github.com",
53
+ ];
54
+
55
+ export function parseResolvePrConflictsCliArgs(argv) {
56
+ const options = {
57
+ help: false,
58
+ base: null,
59
+ repoRoot: process.cwd(),
60
+ verify: true,
61
+ push: false,
62
+ json: false,
63
+ };
64
+ const { tokens } = parseArgs({
65
+ args: [...argv],
66
+ options: {
67
+ help: { type: "boolean", short: "h" },
68
+ base: { type: "string" },
69
+ "repo-root": { type: "string" },
70
+ "no-verify": { type: "boolean" },
71
+ push: { type: "boolean" },
72
+ json: { type: "boolean" },
73
+ },
74
+ allowPositionals: true,
75
+ strict: false,
76
+ tokens: true,
77
+ });
78
+ for (const token of tokens) {
79
+ if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
80
+ if (token.kind !== "option") continue;
81
+ switch (token.name) {
82
+ case "help":
83
+ options.help = true;
84
+ return options;
85
+ case "base": {
86
+ const value = requireTokenValue(token, parseError, { flagPattern: /^-/u }).trim();
87
+ options.base = value.length > 0 ? value : null;
88
+ break;
89
+ }
90
+ case "repo-root": {
91
+ const value = requireTokenValue(token, parseError, { flagPattern: /^-/u }).trim();
92
+ options.repoRoot = value.length > 0 ? value : process.cwd();
93
+ break;
94
+ }
95
+ case "no-verify":
96
+ options.verify = false;
97
+ break;
98
+ case "push":
99
+ options.push = true;
100
+ break;
101
+ case "json":
102
+ options.json = true;
103
+ break;
104
+ default:
105
+ throw parseError(`Unknown argument: ${token.rawName}`);
106
+ }
107
+ }
108
+ return options;
109
+ }
110
+
111
+ function run(command, args, { cwd, env = process.env } = {}) {
112
+ return new Promise((resolve, reject) => {
113
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
114
+ let stdout = "";
115
+ let stderr = "";
116
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
117
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
118
+ child.on("error", reject);
119
+ child.on("close", (code) => { resolve({ code, stdout, stderr }); });
120
+ });
121
+ }
122
+
123
+ async function listUnmergedFiles({ cwd, env }) {
124
+ const result = await run("git", ["diff", "--name-only", "--diff-filter=U"], { cwd, env });
125
+ if (result.code !== 0) {
126
+ return [];
127
+ }
128
+ return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
129
+ }
130
+
131
+ /**
132
+ * Decide whether a single conflicted file is the safe additive CHANGELOG case
133
+ * and, if so, produce the merged content (keep BOTH sides, in order).
134
+ *
135
+ * Requires diff3-style conflict markers so each hunk carries its merge BASE:
136
+ *
137
+ * <<<<<<< ours
138
+ * ...ours...
139
+ * ||||||| base
140
+ * ...base...
141
+ * =======
142
+ * ...theirs...
143
+ * >>>>>>> theirs
144
+ *
145
+ * STRUCTURAL additivity (not lexical): a hunk is safe to keep-both ONLY when its
146
+ * BASE section is EMPTY — a true insertion on both sides at a spot where base had
147
+ * nothing. A non-empty base means a shared line was MODIFIED or DELETED on at
148
+ * least one side → fail closed (a lexical "looks like a list item" check cannot
149
+ * tell an add from a modify, which silently duplicates/resurrects entries).
150
+ *
151
+ * Keep-both order: ours block then theirs block, preserving each side's order.
152
+ */
153
+ export function resolveAdditiveChangelog(content) {
154
+ const lines = content.split("\n");
155
+ const out = [];
156
+ let i = 0;
157
+ let resolvedAnyHunk = false;
158
+
159
+ while (i < lines.length) {
160
+ const line = lines[i];
161
+ if (!line.startsWith("<<<<<<<")) {
162
+ out.push(line);
163
+ i += 1;
164
+ continue;
165
+ }
166
+ // Conflict hunk: <<<<<<< ours ... ||||||| base ... ======= ... >>>>>>> theirs
167
+ const ours = [];
168
+ const base = [];
169
+ const theirs = [];
170
+ i += 1; // skip <<<<<<<
171
+ while (i < lines.length && !lines[i].startsWith("|||||||") && !lines[i].startsWith("=======")) {
172
+ ours.push(lines[i]);
173
+ i += 1;
174
+ }
175
+ if (i >= lines.length) {
176
+ return { safe: false, reason: "malformed conflict markers (no ======= separator)" };
177
+ }
178
+ if (!lines[i].startsWith("|||||||")) {
179
+ // No base section ⇒ not diff3 style; we cannot tell add from modify.
180
+ return { safe: false, reason: "missing diff3 base section (||||||| marker)" };
181
+ }
182
+ i += 1; // skip |||||||
183
+ while (i < lines.length && !lines[i].startsWith("=======")) {
184
+ base.push(lines[i]);
185
+ i += 1;
186
+ }
187
+ if (i >= lines.length) {
188
+ return { safe: false, reason: "malformed conflict markers (no ======= separator)" };
189
+ }
190
+ i += 1; // skip =======
191
+ while (i < lines.length && !lines[i].startsWith(">>>>>>>")) {
192
+ theirs.push(lines[i]);
193
+ i += 1;
194
+ }
195
+ if (i >= lines.length) {
196
+ return { safe: false, reason: "malformed conflict markers (no >>>>>>> terminator)" };
197
+ }
198
+ i += 1; // skip >>>>>>>
199
+
200
+ // Additive ⇔ base had no line here (both sides inserted). Any non-empty base
201
+ // section is a modify/delete of a shared line → fail closed.
202
+ if (base.some((b) => b.trim().length > 0)) {
203
+ return { safe: false, reason: "CHANGELOG conflict modifies or deletes a shared line (non-empty merge base)" };
204
+ }
205
+ // Keep both sides, in order.
206
+ out.push(...ours, ...theirs);
207
+ resolvedAnyHunk = true;
208
+ }
209
+
210
+ if (!resolvedAnyHunk) {
211
+ return { safe: false, reason: "no conflict hunks found in CHANGELOG.md" };
212
+ }
213
+ return { safe: true, content: out.join("\n") };
214
+ }
215
+
216
+ async function abortMerge({ cwd, env }) {
217
+ await run("git", ["merge", "--abort"], { cwd, env });
218
+ }
219
+
220
+ export async function resolvePrConflicts(options, { env = process.env } = {}) {
221
+ const cwd = options.repoRoot;
222
+
223
+ // Resolve base branch: explicit flag, else `gh pr view`, else "main".
224
+ let base = options.base;
225
+ if (!base) {
226
+ const view = await run("gh", ["pr", "view", "--json", "baseRefName"], { cwd, env });
227
+ if (view.code === 0) {
228
+ try {
229
+ const parsed = JSON.parse(view.stdout);
230
+ if (typeof parsed?.baseRefName === "string" && parsed.baseRefName.trim().length > 0) {
231
+ base = parsed.baseRefName.trim();
232
+ }
233
+ } catch {
234
+ // fall through to default
235
+ }
236
+ }
237
+ if (!base) {
238
+ base = "main";
239
+ }
240
+ }
241
+
242
+ const fetch = await run("git", ["fetch", "origin", base], { cwd, env });
243
+ if (fetch.code !== 0) {
244
+ throw new Error(`git fetch origin ${base} failed: ${fetch.stderr.trim() || `exit ${fetch.code}`}`);
245
+ }
246
+
247
+ // diff3 conflict style so conflict hunks carry the merge BASE — the additive
248
+ // CHANGELOG resolver needs it to tell a true insertion from a modify/delete.
249
+ const merge = await run("git", [...BOT_IDENTITY, "-c", "merge.conflictStyle=diff3", "merge", "--no-edit", `origin/${base}`], { cwd, env });
250
+ if (merge.code === 0) {
251
+ const result = { ok: true, action: "clean_merge", base, resolvedFiles: [], pushed: false };
252
+ await afterResolve(result, options, { cwd, env });
253
+ return result;
254
+ }
255
+
256
+ // Merge failed — inspect conflicts.
257
+ const conflictFiles = await listUnmergedFiles({ cwd, env });
258
+ if (conflictFiles.length === 0) {
259
+ await abortMerge({ cwd, env });
260
+ throw new Error(`git merge origin/${base} failed without resolvable conflicts: ${merge.stderr.trim() || `exit ${merge.code}`}`);
261
+ }
262
+
263
+ // Fail closed on ANY conflicted path other than the single safe CHANGELOG case.
264
+ const nonSafe = conflictFiles.filter((file) => file !== SAFE_RESOLVABLE_PATH);
265
+ if (nonSafe.length > 0) {
266
+ await abortMerge({ cwd, env });
267
+ const err = new Error(
268
+ `Unresolvable merge conflict: only additive ${SAFE_RESOLVABLE_PATH} conflicts are auto-resolved. `
269
+ + `Conflicting paths: ${conflictFiles.join(", ")}. Resolve manually.`,
270
+ );
271
+ err.conflictFiles = conflictFiles;
272
+ throw err;
273
+ }
274
+
275
+ // Sole conflict is CHANGELOG.md — attempt the safe additive resolution.
276
+ const changelogPath = path.join(cwd, SAFE_RESOLVABLE_PATH);
277
+ const content = await readFile(changelogPath, "utf8");
278
+ const resolution = resolveAdditiveChangelog(content);
279
+ if (!resolution.safe) {
280
+ await abortMerge({ cwd, env });
281
+ const err = new Error(
282
+ `Unresolvable ${SAFE_RESOLVABLE_PATH} conflict (${resolution.reason}); not purely additive, so it fails closed. `
283
+ + `Conflicting paths: ${SAFE_RESOLVABLE_PATH}. Resolve manually.`,
284
+ );
285
+ err.conflictFiles = [SAFE_RESOLVABLE_PATH];
286
+ throw err;
287
+ }
288
+
289
+ await writeFile(changelogPath, resolution.content, "utf8");
290
+ const add = await run("git", ["add", SAFE_RESOLVABLE_PATH], { cwd, env });
291
+ if (add.code !== 0) {
292
+ await abortMerge({ cwd, env });
293
+ throw new Error(`git add ${SAFE_RESOLVABLE_PATH} failed: ${add.stderr.trim() || `exit ${add.code}`}`);
294
+ }
295
+ const commit = await run("git", [...BOT_IDENTITY, "commit", "--no-edit"], { cwd, env });
296
+ if (commit.code !== 0) {
297
+ await abortMerge({ cwd, env });
298
+ throw new Error(`git commit (merge) failed: ${commit.stderr.trim() || `exit ${commit.code}`}`);
299
+ }
300
+
301
+ const result = { ok: true, action: "resolved", base, resolvedFiles: [SAFE_RESOLVABLE_PATH], pushed: false };
302
+ await afterResolve(result, options, { cwd, env });
303
+ return result;
304
+ }
305
+
306
+ async function afterResolve(result, options, { cwd, env }) {
307
+ if (options.verify) {
308
+ const verify = await run("npm", ["run", "test:docs"], { cwd, env });
309
+ if (verify.code !== 0) {
310
+ const err = new Error(`Post-resolve verification (npm run test:docs) failed: ${verify.stderr.trim() || verify.stdout.trim() || `exit ${verify.code}`}`);
311
+ err.verifyFailed = true;
312
+ throw err;
313
+ }
314
+ result.verified = true;
315
+ }
316
+ if (options.push) {
317
+ const push = await run("git", ["push"], { cwd, env });
318
+ if (push.code !== 0) {
319
+ throw new Error(`git push failed: ${push.stderr.trim() || `exit ${push.code}`}`);
320
+ }
321
+ result.pushed = true;
322
+ }
323
+ }
324
+
325
+ export async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
326
+ let options;
327
+ try {
328
+ options = parseResolvePrConflictsCliArgs(argv);
329
+ } catch (error) {
330
+ stderr.write(`${formatCliError(error)}\n`);
331
+ return 1;
332
+ }
333
+ if (options.help) {
334
+ stdout.write(`${USAGE}\n`);
335
+ return 0;
336
+ }
337
+ try {
338
+ const result = await resolvePrConflicts(options, { env });
339
+ if (options.json) {
340
+ stdout.write(`${JSON.stringify(result)}\n`);
341
+ } else {
342
+ stdout.write(`${result.action} (base ${result.base}${result.resolvedFiles.length ? `, resolved ${result.resolvedFiles.join(", ")}` : ""}${result.pushed ? ", pushed" : ""})\n`);
343
+ }
344
+ return 0;
345
+ } catch (error) {
346
+ const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
347
+ if (Array.isArray(error?.conflictFiles)) {
348
+ payload.conflictFiles = error.conflictFiles;
349
+ }
350
+ stderr.write(`${JSON.stringify(payload)}\n`);
351
+ return 1;
352
+ }
353
+ }
354
+
355
+ if (isDirectCliRun(import.meta.url)) {
356
+ runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; });
357
+ }