dev-loops 0.4.0 → 0.6.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 (76) 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/commands/auto.md +7 -0
  5. package/.claude/commands/continue.md +15 -0
  6. package/.claude/commands/info.md +7 -0
  7. package/.claude/commands/start-spike.md +16 -0
  8. package/.claude/commands/start.md +7 -0
  9. package/.claude/commands/status.md +6 -0
  10. package/.claude/hooks/_run-context.mjs +11 -4
  11. package/.claude/skills/dev-loop/SKILL.md +21 -6
  12. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  13. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  14. package/.claude/skills/docs/local-planning-flow.md +63 -0
  15. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  16. package/.claude/skills/docs/merge-preconditions.md +35 -0
  17. package/.claude/skills/docs/plan-file-contract.md +37 -0
  18. package/.claude/skills/docs/release-runbook.md +45 -0
  19. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  20. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  21. package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
  22. package/.claude/skills/local-implementation/SKILL.md +1 -1
  23. package/CHANGELOG.md +73 -0
  24. package/README.md +21 -1
  25. package/agents/dev-loop.agent.md +8 -1
  26. package/agents/refiner.agent.md +1 -0
  27. package/cli/index.mjs +2 -0
  28. package/extension/index.ts +10 -1
  29. package/extension/presentation.ts +15 -0
  30. package/lib/dev-loops-core.mjs +141 -0
  31. package/package.json +8 -3
  32. package/scripts/claude/generate-claude-assets.mjs +15 -1
  33. package/scripts/github/capture-review-threads.mjs +20 -2
  34. package/scripts/github/comment-issue.mjs +181 -0
  35. package/scripts/github/fetch-ci-logs.mjs +215 -0
  36. package/scripts/github/list-issues.mjs +191 -0
  37. package/scripts/github/probe-copilot-review.mjs +69 -3
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  39. package/scripts/lib/jq-output.mjs +297 -0
  40. package/scripts/loop/_handoff-contract.mjs +1 -0
  41. package/scripts/loop/check-retro-tooling.mjs +246 -0
  42. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  43. package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
  44. package/scripts/loop/docs-grill-contract.mjs +70 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  47. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  48. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  49. package/scripts/loop/run-conductor-cycle.mjs +5 -0
  50. package/scripts/loop/run-watch-cycle.mjs +77 -3
  51. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  52. package/scripts/pages/build-site.mjs +136 -0
  53. package/scripts/projects/add-queue-item.mjs +12 -2
  54. package/scripts/projects/list-queue-items.mjs +12 -2
  55. package/scripts/projects/move-queue-item.mjs +12 -2
  56. package/scripts/projects/resolve-active-board-item.mjs +193 -0
  57. package/scripts/refine/_refine-helpers.mjs +20 -0
  58. package/scripts/refine/exit-spike.mjs +186 -0
  59. package/scripts/refine/promote-plan.mjs +387 -0
  60. package/scripts/refine/refine-plan-file.mjs +165 -0
  61. package/scripts/refine/scaffold-spike-file.mjs +183 -0
  62. package/scripts/refine/validate-plan-file.mjs +64 -0
  63. package/scripts/refine/validate-spike-file.mjs +87 -0
  64. package/scripts/release/extract-changelog-section.mjs +111 -0
  65. package/skills/dev-loop/SKILL.md +24 -2
  66. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  67. package/skills/docs/artifact-authority-contract.md +86 -31
  68. package/skills/docs/local-planning-flow.md +63 -0
  69. package/skills/docs/local-planning-worked-example.md +139 -0
  70. package/skills/docs/merge-preconditions.md +35 -0
  71. package/skills/docs/plan-file-contract.md +37 -0
  72. package/skills/docs/release-runbook.md +45 -0
  73. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  74. package/skills/docs/spike-mode-contract.md +237 -0
  75. package/skills/docs/ui-e2e-scoping-step.md +102 -0
  76. package/skills/local-implementation/SKILL.md +1 -1
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parseArgs } from "node:util";
5
+
6
+ import { buildParseError, formatCliError } from "../_core-helpers.mjs";
7
+ import { requireTokenValue } from "../_cli-primitives.mjs";
8
+ import { extractSection, isDirectCliRun } from "./_refine-helpers.mjs";
9
+ import {
10
+ validateSpikeExplorationSections,
11
+ SPIKE_FILE_EXIT_MARKER_SECTION,
12
+ } from "./validate-spike-file.mjs";
13
+ import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
14
+ import {
15
+ evaluateSpikeExit,
16
+ buildGraduatedPlanBody,
17
+ SPIKE_EXIT_ACTION,
18
+ SPIKE_EXIT_DISPOSITION,
19
+ } from "@dev-loops/core/loop/spike-exit-contract";
20
+
21
+ const USAGE = `Usage:
22
+ exit-spike.mjs --spike-file <path> --disposition <discard|graduate> [--plan-file <path>] [--json]
23
+ Conclude a spike that has reached a Recommendation (spike_ready_for_exit).
24
+ discard The recommendation is "don't pursue": drop the spike with ZERO
25
+ tracker artifacts. The findings doc is the whole record.
26
+ graduate Emit a #947-consumable local-first plan file (Status/Objective/
27
+ In scope/Explicit non-goals) built from the spike's
28
+ Question/Approach/Findings/Recommendation. The plan then enters the
29
+ existing plan->PR promotion path (scripts/refine/promote-plan.mjs).
30
+ --plan-file is required and graduation is idempotent (re-running
31
+ reproduces the same plan file).
32
+
33
+ Fail-closed: a spike that is not ready for exit (no Recommendation) or an
34
+ unknown disposition makes ZERO tracker mutation and exits 1. No GitHub artifact
35
+ is created on any path; graduation only writes a local plan file.
36
+
37
+ Required:
38
+ --spike-file <path> Path to the spike findings artifact
39
+ --disposition <which> discard | graduate
40
+ Optional:
41
+ --plan-file <path> Output plan-file path (required for graduate)
42
+ --json Machine-readable JSON output
43
+ --help Show this help
44
+ Exit codes:
45
+ 0 Exit succeeded (discard recorded; or plan file written for graduate)
46
+ 1 Argument error, or fail-closed (not ready / unknown disposition)`.trim();
47
+
48
+ const parseError = buildParseError(USAGE);
49
+
50
+ export function parseExitSpikeCliArgs(argv) {
51
+ const { tokens } = parseArgs({
52
+ args: [...argv],
53
+ options: {
54
+ help: { type: "boolean", short: "h" },
55
+ "spike-file": { type: "string" },
56
+ disposition: { type: "string" },
57
+ "plan-file": { type: "string" },
58
+ json: { type: "boolean" },
59
+ },
60
+ allowPositionals: true,
61
+ strict: false,
62
+ tokens: true,
63
+ });
64
+ const options = { help: false, spikeFile: undefined, disposition: undefined, planFile: undefined, json: false };
65
+ for (const token of tokens) {
66
+ if (token.kind === "positional") {
67
+ throw parseError(`Unknown argument: ${token.value}`);
68
+ }
69
+ if (token.kind !== "option") continue;
70
+ if (token.name === "help") {
71
+ options.help = true;
72
+ return options;
73
+ }
74
+ if (token.name === "spike-file") {
75
+ options.spikeFile = requireTokenValue(token, parseError, { flagPattern: /^-/u });
76
+ continue;
77
+ }
78
+ if (token.name === "disposition") {
79
+ options.disposition = requireTokenValue(token, parseError, { flagPattern: /^-/u });
80
+ continue;
81
+ }
82
+ if (token.name === "plan-file") {
83
+ options.planFile = requireTokenValue(token, parseError, { flagPattern: /^-/u });
84
+ continue;
85
+ }
86
+ if (token.name === "json") {
87
+ options.json = true;
88
+ continue;
89
+ }
90
+ throw parseError(`Unknown argument: ${token.rawName}`);
91
+ }
92
+ if (typeof options.spikeFile !== "string" || options.spikeFile.trim().length === 0) {
93
+ throw parseError("exit-spike requires --spike-file <path>");
94
+ }
95
+ if (typeof options.disposition !== "string" || options.disposition.trim().length === 0) {
96
+ throw parseError("exit-spike requires --disposition <discard|graduate>");
97
+ }
98
+ // graduate emits a plan file; the destination must be explicit so we never
99
+ // guess a path. discard writes nothing, so --plan-file is irrelevant there.
100
+ if (options.disposition === SPIKE_EXIT_DISPOSITION.GRADUATE
101
+ && (typeof options.planFile !== "string" || options.planFile.trim().length === 0)) {
102
+ throw parseError("exit-spike --disposition graduate requires --plan-file <path>");
103
+ }
104
+ return options;
105
+ }
106
+
107
+ function emit(stdout, json, summary, humanLines) {
108
+ if (json) {
109
+ stdout.write(`${JSON.stringify(summary)}\n`);
110
+ } else {
111
+ stdout.write(`${humanLines.join("\n")}\n`);
112
+ }
113
+ }
114
+
115
+ export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
116
+ const options = parseExitSpikeCliArgs(argv);
117
+ if (options.help) {
118
+ stdout.write(`${USAGE}\n`);
119
+ return { ok: true, help: true };
120
+ }
121
+
122
+ const spikePath = path.resolve(options.spikeFile);
123
+ const markdownText = await readFile(spikePath, "utf8");
124
+
125
+ // Compute the intake-state facts with the P1 surfaces, then hand them to the
126
+ // pure exit decision. Per spike-intake-contract, `baseSectionsValid` is the
127
+ // exploration scaffold (Question/Approach/Findings); the Recommendation is the
128
+ // separate exit-marker that flips in-progress -> ready-for-exit. So a
129
+ // scaffold-valid spike without a Recommendation classifies as in-progress
130
+ // (fail-closed reason `not_ready_for_exit`), not ambiguous.
131
+ const baseSectionsValid = validateSpikeExplorationSections(markdownText).ok;
132
+ const hasRecommendation = Boolean(extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION));
133
+ const { state: spikeIntakeState } = evaluateSpikeIntakeState({ baseSectionsValid, hasRecommendation });
134
+ const decision = evaluateSpikeExit({ spikeIntakeState, disposition: options.disposition });
135
+
136
+ if (!decision.ok) {
137
+ // Fail closed: no tracker artifact, no plan file — surface the reason.
138
+ const summary = { ok: false, reason: decision.reason, spikeIntakeState: decision.spikeIntakeState ?? spikeIntakeState };
139
+ emit(stdout, options.json, summary, [`exit-spike: FAIL (${decision.reason})`]);
140
+ process.exitCode = 1;
141
+ return summary;
142
+ }
143
+
144
+ // DISCARD: zero artifacts. The findings doc on disk is the whole record.
145
+ if (decision.action === SPIKE_EXIT_ACTION.DISCARD) {
146
+ const summary = { ok: true, action: SPIKE_EXIT_ACTION.DISCARD, spikeFile: spikePath };
147
+ emit(stdout, options.json, summary, [
148
+ "exit-spike: PASS (discard)",
149
+ ` spike: ${spikePath}`,
150
+ " no tracker artifact created (findings doc is the record)",
151
+ ]);
152
+ return summary;
153
+ }
154
+
155
+ // GRADUATE: build a base-valid plan body from the spike sections and write it.
156
+ // Pure builder owns the section mapping + fail-closed on empty sections; this
157
+ // script owns the file I/O only. Overwriting the same path is idempotent.
158
+ const planPath = path.resolve(options.planFile);
159
+ const planBody = buildGraduatedPlanBody({
160
+ question: extractSection(markdownText, "Question"),
161
+ approach: extractSection(markdownText, "Approach"),
162
+ findings: extractSection(markdownText, "Findings"),
163
+ recommendation: extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION),
164
+ });
165
+ await writeFile(planPath, planBody, "utf8");
166
+
167
+ const summary = {
168
+ ok: true,
169
+ action: SPIKE_EXIT_ACTION.GRADUATE,
170
+ spikeFile: spikePath,
171
+ planFile: planPath,
172
+ };
173
+ emit(stdout, options.json, summary, [
174
+ "exit-spike: PASS (graduate)",
175
+ ` spike: ${spikePath}`,
176
+ ` plan: ${planPath} (local-first; promote with scripts/refine/promote-plan.mjs)`,
177
+ ]);
178
+ return summary;
179
+ }
180
+
181
+ if (isDirectCliRun(import.meta.url)) {
182
+ runCli().catch((error) => {
183
+ process.stderr.write(`${formatCliError(error)}\n`);
184
+ process.exitCode = 1;
185
+ });
186
+ }
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { realpathSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+
8
+ import { buildParseError, formatCliError } from "../_core-helpers.mjs";
9
+ import { requireTokenValue, runChild } from "../_cli-primitives.mjs";
10
+ import { validatePlanFile } from "./validate-plan-file.mjs";
11
+ import { extractSection, isDirectCliRun } from "./_refine-helpers.mjs";
12
+ import { PLAN_FILE_REFINEMENT_SECTIONS } from "@dev-loops/core/loop/plan-file-intake-contract";
13
+ import {
14
+ evaluatePromoteEligibility,
15
+ buildPromotionPrBody,
16
+ readLinkedPrNumber,
17
+ writeLinkedPrNumber,
18
+ PLAN_FILE_PROMOTE_ACTION,
19
+ } from "@dev-loops/core/loop/plan-file-promote-contract";
20
+
21
+ const CREATE_PR_PATH = fileURLToPath(new URL("../github/create-pr.mjs", import.meta.url));
22
+
23
+ const USAGE = `Usage:
24
+ promote-plan.mjs --plan-file <path> [--base <branch>] [--branch <name>] [--json]
25
+ PR-FIRST promotion of a refined plan file. When the plan is in P3's ready
26
+ state (plan_refined_ready_for_promotion), commit the plan doc to a branch and
27
+ open EXACTLY ONE draft PR via the canonical create-pr.mjs wrapper. NO GitHub
28
+ issue is ever created: the committed plan doc is the spec-of-record and the PR
29
+ body links it and carries the full Acceptance criteria + Definition of done.
30
+ The PR number is written back into the plan doc's front-matter (prNumber:),
31
+ forming the bidirectional plan<->PR link. The opened draft PR enters the
32
+ existing loop unchanged via \`loop startup --pr <n>\`.
33
+
34
+ Fail-closed: unless the plan is in the ready state this makes ZERO GitHub
35
+ mutation and exits 1. Idempotent: a plan already linked to a PR (prNumber in
36
+ front-matter) opens nothing and reports the existing PR.
37
+
38
+ Required:
39
+ --plan-file <path> Path to the refined plan file to promote
40
+ Optional:
41
+ --base <branch> Base branch for the PR (default: main)
42
+ --branch <name> Branch name to commit the plan on (default: derived from the plan path)
43
+ --json Machine-readable JSON output
44
+ --help Show this help
45
+ Exit codes:
46
+ 0 Promotion succeeded (or idempotent no-op for an already-linked plan)
47
+ 1 Argument error, or fail-closed (plan not ready / git / PR-create failure); no issue ever created`.trim();
48
+
49
+ const parseError = buildParseError(USAGE);
50
+
51
+ export function parsePromotePlanCliArgs(argv) {
52
+ const { tokens } = parseArgs({
53
+ args: [...argv],
54
+ options: {
55
+ help: { type: "boolean", short: "h" },
56
+ "plan-file": { type: "string" },
57
+ base: { type: "string" },
58
+ branch: { type: "string" },
59
+ json: { type: "boolean" },
60
+ },
61
+ allowPositionals: true,
62
+ strict: false,
63
+ tokens: true,
64
+ });
65
+ const options = { help: false, planFile: undefined, base: "main", branch: undefined, json: false };
66
+ for (const token of tokens) {
67
+ if (token.kind === "positional") {
68
+ throw parseError(`Unknown argument: ${token.value}`);
69
+ }
70
+ if (token.kind !== "option") continue;
71
+ if (token.name === "help") {
72
+ options.help = true;
73
+ return options;
74
+ }
75
+ if (token.name === "plan-file") {
76
+ options.planFile = requireTokenValue(token, parseError, { flagPattern: /^-/u });
77
+ continue;
78
+ }
79
+ if (token.name === "base") {
80
+ options.base = requireTokenValue(token, parseError, { flagPattern: /^-/u });
81
+ continue;
82
+ }
83
+ if (token.name === "branch") {
84
+ options.branch = requireTokenValue(token, parseError, { flagPattern: /^-/u });
85
+ continue;
86
+ }
87
+ if (token.name === "json") {
88
+ options.json = true;
89
+ continue;
90
+ }
91
+ throw parseError(`Unknown argument: ${token.rawName}`);
92
+ }
93
+ if (typeof options.planFile !== "string" || options.planFile.trim().length === 0) {
94
+ throw parseError("promote-plan requires --plan-file <path>");
95
+ }
96
+ return options;
97
+ }
98
+
99
+ /** Resolve a path through realpath, falling back to the input when it cannot. */
100
+ function safeRealpath(p) {
101
+ try {
102
+ return realpathSync(p);
103
+ } catch {
104
+ return p;
105
+ }
106
+ }
107
+
108
+ /** Derive a stable branch name from the plan-doc path when --branch is absent. */
109
+ function defaultBranchName(planPath) {
110
+ const base = path.basename(planPath).replace(/\.[^.]+$/u, "");
111
+ const slug = base.replace(/[^a-zA-Z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").toLowerCase() || "plan";
112
+ return `promote-plan/${slug}`;
113
+ }
114
+
115
+ /**
116
+ * Parse the PR number from `gh pr create` stdout, which prints the PR URL.
117
+ * Only the `/pull/<n>` form is trusted; anything else fails closed via the
118
+ * caller's `pr_number_unparseable` path rather than guessing from a trailing
119
+ * number (which could mis-bind an unrelated PR from stdout noise).
120
+ */
121
+ function parsePrNumberFromGhOutput(text) {
122
+ const match = /\/pull\/(\d+)\b/u.exec(String(text ?? ""));
123
+ return match ? Number.parseInt(match[1], 10) : null;
124
+ }
125
+
126
+ function emit(stdout, json, summary, humanLines) {
127
+ if (json) {
128
+ stdout.write(`${JSON.stringify(summary)}\n`);
129
+ } else {
130
+ stdout.write(`${humanLines.join("\n")}\n`);
131
+ }
132
+ }
133
+
134
+ export async function runCli(argv = process.argv.slice(2), {
135
+ stdout = process.stdout,
136
+ runChildFn = runChild,
137
+ env = process.env,
138
+ } = {}) {
139
+ const options = parsePromotePlanCliArgs(argv);
140
+ if (options.help) {
141
+ stdout.write(`${USAGE}\n`);
142
+ return { ok: true, help: true };
143
+ }
144
+
145
+ const planPath = path.resolve(options.planFile);
146
+ const markdownText = await readFile(planPath, "utf8");
147
+
148
+ // Compute the section-presence facts with the P1 surfaces and read the
149
+ // existing plan->PR link from front-matter, then hand them to the pure
150
+ // eligibility decision. The decision owns the ready-state gate; this script
151
+ // owns all I/O (git + create-pr + write-back).
152
+ const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
153
+ const existingPrNumber = readLinkedPrNumber(markdownText);
154
+ const decision = evaluatePromoteEligibility({
155
+ baseSectionsValid: validatePlanFile(markdownText).ok,
156
+ hasAcceptanceCriteria: Boolean(extractSection(markdownText, acHeading)),
157
+ hasDefinitionOfDone: Boolean(extractSection(markdownText, dodHeading)),
158
+ existingPrNumber,
159
+ });
160
+
161
+ if (!decision.ok) {
162
+ // Fail closed: no git, no gh, no issue — surface the reason and stop.
163
+ const summary = { ok: false, reason: decision.reason, planFileIntakeState: decision.planFileIntakeState ?? null };
164
+ emit(stdout, options.json, summary, [`promote-plan: FAIL (${decision.reason})`]);
165
+ process.exitCode = 1;
166
+ return summary;
167
+ }
168
+
169
+ // Idempotent no-op: plan already linked to a PR. Open nothing, report it.
170
+ if (decision.action === PLAN_FILE_PROMOTE_ACTION.ALREADY_PROMOTED) {
171
+ const summary = {
172
+ ok: true,
173
+ action: PLAN_FILE_PROMOTE_ACTION.ALREADY_PROMOTED,
174
+ planFile: planPath,
175
+ prNumber: decision.existingPrNumber,
176
+ };
177
+ emit(stdout, options.json, summary, [
178
+ "promote-plan: PASS (already promoted)",
179
+ ` plan: ${planPath}`,
180
+ ` pr: #${decision.existingPrNumber} (existing; opened nothing)`,
181
+ ]);
182
+ return summary;
183
+ }
184
+
185
+ // --- Promote path: commit the plan doc, open exactly one draft PR. ---
186
+ const repoRoot = (await runChildFn("git", ["rev-parse", "--show-toplevel"], env)).stdout.trim();
187
+ // Normalize both sides through realpath so a /var -> /private/var (or similar)
188
+ // symlink between the resolved plan path and git's toplevel does not yield a
189
+ // spurious `../..` relative path.
190
+ const realRoot = repoRoot ? safeRealpath(repoRoot) : null;
191
+ // Normalize to POSIX separators so the repo-relative path is identical on
192
+ // Windows and stays linkable in the commit messages and PR body.
193
+ const planDocRelPath = (realRoot ? path.relative(realRoot, safeRealpath(planPath)) : path.basename(planPath))
194
+ .split(path.sep)
195
+ .join("/");
196
+ const acceptanceCriteria = extractSection(markdownText, acHeading);
197
+ const definitionOfDone = extractSection(markdownText, dodHeading);
198
+ const prBody = buildPromotionPrBody({
199
+ planDocPath: planDocRelPath,
200
+ acceptanceCriteria,
201
+ definitionOfDone,
202
+ });
203
+
204
+ const branch = options.branch && options.branch.trim().length > 0
205
+ ? options.branch.trim()
206
+ : defaultBranchName(planPath);
207
+
208
+ const fail = (reason, detail) => {
209
+ const summary = { ok: false, reason, detail: detail ?? null, planFile: planPath };
210
+ emit(stdout, options.json, summary, [`promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`]);
211
+ process.exitCode = 1;
212
+ return summary;
213
+ };
214
+
215
+ // Non-destructive branch selection: reuse an existing branch (never `-B`,
216
+ // which resets the ref and would discard unrelated commits on a same-named
217
+ // branch), else create it. Keeps reruns idempotent without rewriting history.
218
+ const branchExists =
219
+ (await runChildFn("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], env)).code === 0;
220
+ const checkout = await runChildFn(
221
+ "git",
222
+ ["checkout", ...(branchExists ? [branch] : ["-b", branch])],
223
+ env,
224
+ );
225
+ if (checkout.code !== 0) {
226
+ return fail("git_checkout_failed", checkout.stderr.trim());
227
+ }
228
+
229
+ // Stage and commit the plan doc as the spec-of-record. The commit is made
230
+ // re-runnable: if a prior partial run already committed the plan (plan at
231
+ // HEAD, no PR), there is nothing to stage, so we skip the commit instead of
232
+ // failing `git_commit_failed` and continue on to push + pr-create. This lets
233
+ // a partial state (plan committed, no prNumber) recover on a plain re-run.
234
+ const add = await runChildFn("git", ["add", "--", planPath], env);
235
+ if (add.code !== 0) {
236
+ return fail("git_add_failed", add.stderr.trim());
237
+ }
238
+ // `git diff --cached --quiet` exits 0 when the index matches HEAD (nothing to
239
+ // commit), 1 when there are staged changes, and >1 (e.g. 128) on a real git
240
+ // error. Treat the codes explicitly: only 1 means "commit"; an error code must
241
+ // fail closed rather than be misread as staged (which would attempt a commit
242
+ // and surface a misleading git_commit_failed).
243
+ const diff = await runChildFn("git", ["diff", "--cached", "--quiet"], env);
244
+ if (diff.code !== 0 && diff.code !== 1) {
245
+ return fail("git_diff_failed", `git diff --cached --quiet exited ${diff.code}${diff.stderr.trim() ? `: ${diff.stderr.trim()}` : ""}`);
246
+ }
247
+ const hasStaged = diff.code === 1;
248
+ if (hasStaged) {
249
+ const commit = await runChildFn(
250
+ "git",
251
+ ["commit", "-m", `docs(plan): promote ${planDocRelPath}`],
252
+ env,
253
+ );
254
+ if (commit.code !== 0) {
255
+ return fail("git_commit_failed", commit.stderr.trim());
256
+ }
257
+ }
258
+
259
+ // Push the head branch to the remote BEFORE opening the PR. A fresh local
260
+ // branch absent on the remote makes `gh pr create --head <branch>` fail, which
261
+ // would commit the plan but open no PR — the unrecoverable partial state this
262
+ // fix targets. Pushing first guarantees the head ref exists for gh.
263
+ const push = await runChildFn("git", ["push", "-u", "origin", branch], env);
264
+ if (push.code !== 0) {
265
+ return fail("git_push_failed", push.stderr.trim() || push.stdout.trim());
266
+ }
267
+
268
+ // Open EXACTLY ONE draft PR via the canonical wrapper (never raw gh).
269
+ // create-pr.mjs injects --draft and --assignee @me; the PR body is passed via
270
+ // --body so it links the plan doc and carries the full AC + DoD.
271
+ const prCreate = await runChildFn(
272
+ process.execPath,
273
+ [
274
+ CREATE_PR_PATH,
275
+ "--base", options.base,
276
+ "--head", branch,
277
+ "--title", `Promote plan: ${planDocRelPath}`,
278
+ "--body", prBody,
279
+ ],
280
+ env,
281
+ );
282
+ if (prCreate.code !== 0) {
283
+ // No PR was opened, but the plan is committed and the branch is pushed —
284
+ // a recoverable partial state. Mirror the post-PR-open recovery hints: keep
285
+ // it fail-closed (exit 1, reason pr_create_failed) while telling the
286
+ // operator that a plain re-run recovers, since the commit step is
287
+ // idempotent (skips when nothing is staged) and the push is idempotent too.
288
+ const detail = prCreate.stderr.trim() || prCreate.stdout.trim();
289
+ const summary = {
290
+ ok: false,
291
+ reason: "pr_create_failed",
292
+ detail: detail || null,
293
+ planFile: planPath,
294
+ branch,
295
+ recovery: `gh pr create failed, so no PR was opened, but the plan is committed and branch ${branch} is pushed. Re-run promote-plan to recover: the commit step is idempotent (skips when nothing is staged) and the push is idempotent, so a clean re-run will retry opening the PR.`,
296
+ };
297
+ emit(stdout, options.json, summary, [
298
+ `promote-plan: FAIL (pr_create_failed)${detail ? `: ${detail}` : ""}`,
299
+ ` branch: ${branch} (committed and pushed; no PR opened — re-run promote-plan to recover, it is idempotent and will retry opening the PR)`,
300
+ ]);
301
+ process.exitCode = 1;
302
+ return summary;
303
+ }
304
+ const prNumber = parsePrNumberFromGhOutput(prCreate.stdout);
305
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
306
+ // create-pr returned success, so the draft PR IS open — this is a
307
+ // post-mutation failure, not a clean fail-closed. Surface a recovery hint
308
+ // (the PR number could not be parsed from gh's output) referencing the head
309
+ // branch so the operator can find the open PR and link it manually.
310
+ const summary = {
311
+ ok: false,
312
+ reason: "pr_number_unparseable",
313
+ detail: prCreate.stdout.trim() || null,
314
+ planFile: planPath,
315
+ branch,
316
+ recovery: `A draft PR was opened on branch ${branch} but its number could not be parsed from gh output. Find it with: gh pr list --head "${branch}" --json number,url. Then record the link with: node scripts/refine/promote-plan.mjs is idempotent only once the front-matter prNumber is set, so add it manually (prNumber: <n>) and commit ${planDocRelPath}.`,
317
+ };
318
+ emit(stdout, options.json, summary, [
319
+ `promote-plan: FAIL (pr_number_unparseable)`,
320
+ ` branch: ${branch} (a draft PR is open; its number could not be parsed — recover via 'gh pr list --head "${branch}"')`,
321
+ ]);
322
+ process.exitCode = 1;
323
+ return summary;
324
+ }
325
+
326
+ // Write the PR number back into the plan's front-matter (the plan->PR link),
327
+ // then commit the link so the committed plan doc records its PR. The PR is
328
+ // ALREADY open here, so a write-back failure is NOT a clean fail-closed: the
329
+ // tracker artifact exists. Surface it with the open PR number and a recovery
330
+ // hint instead of reporting success — otherwise the uncommitted link leaves a
331
+ // partial state that defeats idempotency (a re-run sees no prNumber, re-enters
332
+ // promote, and gh rejects the already-open head).
333
+ const failAfterPrOpen = (reason, detail) => {
334
+ const summary = {
335
+ ok: false,
336
+ reason,
337
+ detail: detail ?? null,
338
+ planFile: planPath,
339
+ branch,
340
+ prNumber,
341
+ recovery: `PR #${prNumber} is open but the plan->PR link commit failed. ${planDocRelPath} now carries prNumber: ${prNumber} in its front-matter; record the link with: git add -- "${planDocRelPath}" && git commit -m "docs(plan): link to PR #${prNumber}" && git push.`,
342
+ };
343
+ emit(stdout, options.json, summary, [
344
+ `promote-plan: FAIL (${reason})${detail ? `: ${detail}` : ""}`,
345
+ ` pr: #${prNumber} (open; plan->PR link NOT committed — recover manually)`,
346
+ ]);
347
+ process.exitCode = 1;
348
+ return summary;
349
+ };
350
+ const linkedMarkdown = writeLinkedPrNumber(markdownText, prNumber);
351
+ await writeFile(planPath, linkedMarkdown, "utf8");
352
+ const linkAdd = await runChildFn("git", ["add", "--", planPath], env);
353
+ if (linkAdd.code !== 0) {
354
+ return failAfterPrOpen("git_link_add_failed", linkAdd.stderr.trim());
355
+ }
356
+ const linkCommit = await runChildFn(
357
+ "git",
358
+ ["commit", "-m", `docs(plan): link ${planDocRelPath} to PR #${prNumber}`],
359
+ env,
360
+ );
361
+ if (linkCommit.code !== 0) {
362
+ return failAfterPrOpen("git_link_commit_failed", linkCommit.stderr.trim());
363
+ }
364
+
365
+ const summary = {
366
+ ok: true,
367
+ action: PLAN_FILE_PROMOTE_ACTION.PROMOTE,
368
+ planFile: planPath,
369
+ planDocPath: planDocRelPath,
370
+ branch,
371
+ prNumber,
372
+ };
373
+ emit(stdout, options.json, summary, [
374
+ "promote-plan: PASS",
375
+ ` plan: ${planPath}`,
376
+ ` branch: ${branch}`,
377
+ ` pr: #${prNumber} (draft; no issue created)`,
378
+ ]);
379
+ return summary;
380
+ }
381
+
382
+ if (isDirectCliRun(import.meta.url)) {
383
+ runCli().catch((error) => {
384
+ process.stderr.write(`${formatCliError(error)}\n`);
385
+ process.exitCode = 1;
386
+ });
387
+ }