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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +21 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +21 -1
- package/agents/dev-loop.agent.md +8 -1
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +8 -3
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +24 -2
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- 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
|
+
}
|