dev-loops 0.3.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.
- 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/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
- package/.claude/skills/dev-loop/SKILL.md +17 -7
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/anti-patterns.md +1 -1
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/copilot-loop-operations.md +1 -1
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- 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 +100 -1
- package/.claude/skills/docs/plan-file-contract.md +37 -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/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +8 -2
- package/CHANGELOG.md +69 -0
- package/README.md +9 -2
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +21 -2
- package/extension/README.md +1 -1
- package/package.json +6 -4
- package/scripts/README.md +2 -2
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/request-copilot-review.mjs +3 -3
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +22 -4
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +21 -5
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-queue.mjs +25 -1
- package/scripts/loop/run-watch-cycle.mjs +152 -25
- 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 +32 -7
- package/scripts/projects/archive-done-items.mjs +2 -1
- package/scripts/projects/ensure-queue-board.mjs +2 -1
- package/scripts/projects/list-queue-items.mjs +14 -3
- package/scripts/projects/move-queue-item.mjs +14 -3
- package/scripts/projects/reorder-queue-item.mjs +5 -4
- package/scripts/projects/sync-item-status.mjs +2 -1
- 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/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/skills/copilot-pr-followup/SKILL.md +9 -7
- package/skills/dev-loop/SKILL.md +13 -3
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/anti-patterns.md +1 -1
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/copilot-loop-operations.md +1 -1
- package/skills/docs/issue-intake-procedure.md +4 -0
- 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 +100 -1
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +8 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
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, parseJsonText } from "../_core-helpers.mjs";
|
|
7
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
8
|
+
import { validatePlanFile } from "./validate-plan-file.mjs";
|
|
9
|
+
import { extractSection, isDirectCliRun } from "./_refine-helpers.mjs";
|
|
10
|
+
import {
|
|
11
|
+
refinePlanFileInPlace,
|
|
12
|
+
PLAN_FILE_REFINE_STOP,
|
|
13
|
+
} from "@dev-loops/core/loop/plan-file-refine-contract";
|
|
14
|
+
import { PLAN_FILE_REFINEMENT_SECTIONS } from "@dev-loops/core/loop/plan-file-intake-contract";
|
|
15
|
+
import { classifyDocsGrillFinding } from "../loop/docs-grill-contract.mjs";
|
|
16
|
+
|
|
17
|
+
const USAGE = `Usage:
|
|
18
|
+
refine-plan-file.mjs --plan-file <path> --payload <path> [--json]
|
|
19
|
+
Refine a local plan file in place: write the refiner-produced Acceptance
|
|
20
|
+
criteria, Definition of done, coverage matrix, and recorded docs-grill
|
|
21
|
+
findings into the plan file, advance the intake state to
|
|
22
|
+
plan_refined_ready_for_promotion, and stop at a local human-review
|
|
23
|
+
checkpoint. Read-only against the tracker: no GitHub calls, no issue/PR/
|
|
24
|
+
comment, no promotion.
|
|
25
|
+
|
|
26
|
+
Required:
|
|
27
|
+
--plan-file <path> Path to the phase-doc-format plan file to refine in place
|
|
28
|
+
--payload <path> Path to a JSON file with the refiner output
|
|
29
|
+
({ acceptanceCriteria, definitionOfDone, coverageMatrix,
|
|
30
|
+
grillFindings? })
|
|
31
|
+
Optional:
|
|
32
|
+
--json Machine-readable JSON output
|
|
33
|
+
--help Show this help
|
|
34
|
+
Exit codes:
|
|
35
|
+
0 Refinement succeeded; plan written in place; stop for local human review
|
|
36
|
+
1 Argument error, or fail-closed refine/grill/ambiguous state (no write)`.trim();
|
|
37
|
+
|
|
38
|
+
const parseError = buildParseError(USAGE);
|
|
39
|
+
|
|
40
|
+
export function parseRefinePlanFileCliArgs(argv) {
|
|
41
|
+
const { tokens } = parseArgs({
|
|
42
|
+
args: [...argv],
|
|
43
|
+
options: {
|
|
44
|
+
help: { type: "boolean", short: "h" },
|
|
45
|
+
"plan-file": { type: "string" },
|
|
46
|
+
payload: { type: "string" },
|
|
47
|
+
json: { type: "boolean" },
|
|
48
|
+
},
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
strict: false,
|
|
51
|
+
tokens: true,
|
|
52
|
+
});
|
|
53
|
+
const options = { help: false, planFile: undefined, payload: undefined, json: false };
|
|
54
|
+
for (const token of tokens) {
|
|
55
|
+
if (token.kind === "positional") {
|
|
56
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
57
|
+
}
|
|
58
|
+
if (token.kind !== "option") continue;
|
|
59
|
+
if (token.name === "help") {
|
|
60
|
+
options.help = true;
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
if (token.name === "plan-file") {
|
|
64
|
+
options.planFile = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (token.name === "payload") {
|
|
68
|
+
options.payload = requireTokenValue(token, parseError, { flagPattern: /^-/u });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (token.name === "json") {
|
|
72
|
+
options.json = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof options.planFile !== "string" || options.planFile.trim().length === 0) {
|
|
78
|
+
throw parseError("refine-plan-file requires --plan-file <path>");
|
|
79
|
+
}
|
|
80
|
+
if (typeof options.payload !== "string" || options.payload.trim().length === 0) {
|
|
81
|
+
throw parseError("refine-plan-file requires --payload <path>");
|
|
82
|
+
}
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
87
|
+
const options = parseRefinePlanFileCliArgs(argv);
|
|
88
|
+
if (options.help) {
|
|
89
|
+
stdout.write(`${USAGE}\n`);
|
|
90
|
+
return { ok: true, help: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const planPath = path.resolve(options.planFile);
|
|
94
|
+
const markdownText = await readFile(planPath, "utf8");
|
|
95
|
+
const payload = parseJsonText(await readFile(path.resolve(options.payload), "utf8"));
|
|
96
|
+
|
|
97
|
+
// Read the section-presence facts with the P1 surfaces, then hand them to the
|
|
98
|
+
// pure refine contract. The contract owns the state-machine gate and the
|
|
99
|
+
// in-place rewrite; this script owns I/O only.
|
|
100
|
+
// The docs-grill runs as a step of refinement. Classify each finding here (this
|
|
101
|
+
// script owns the scripts/ boundary) with #948's classifier and pass the
|
|
102
|
+
// dispositions to the pure core contract; an invalid finding yields a null
|
|
103
|
+
// disposition, which the contract fails closed on (docs_grill_failed).
|
|
104
|
+
const rawFindings = Array.isArray(payload?.grillFindings) ? payload.grillFindings : [];
|
|
105
|
+
const grillDispositions = rawFindings.map((finding) => {
|
|
106
|
+
const classified = classifyDocsGrillFinding(finding);
|
|
107
|
+
return {
|
|
108
|
+
kind: finding?.kind,
|
|
109
|
+
summary: typeof finding?.summary === "string" ? finding.summary : "",
|
|
110
|
+
disposition: classified.ok ? classified.disposition : null,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
|
|
115
|
+
const result = refinePlanFileInPlace({
|
|
116
|
+
markdownText,
|
|
117
|
+
baseSectionsValid: validatePlanFile(markdownText).ok,
|
|
118
|
+
hasAcceptanceCriteria: extractSection(markdownText, acHeading) ? true : false,
|
|
119
|
+
hasDefinitionOfDone: extractSection(markdownText, dodHeading) ? true : false,
|
|
120
|
+
payload: { ...payload, grillDispositions },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!result.ok) {
|
|
124
|
+
// Fail closed: surface the reason, do not write, do not advance, do not promote.
|
|
125
|
+
if (options.json) {
|
|
126
|
+
stdout.write(`${JSON.stringify({ ok: false, reason: result.reason, planFileIntakeState: result.planFileIntakeState ?? null })}\n`);
|
|
127
|
+
} else {
|
|
128
|
+
stdout.write(`refine-plan-file: FAIL (${result.reason})\n`);
|
|
129
|
+
}
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Write the refined plan back in place — the single canonical artifact.
|
|
135
|
+
await writeFile(planPath, result.refinedMarkdown, "utf8");
|
|
136
|
+
|
|
137
|
+
const summary = {
|
|
138
|
+
ok: true,
|
|
139
|
+
planFile: planPath,
|
|
140
|
+
planFileIntakeState: result.planFileIntakeState,
|
|
141
|
+
stop: result.stop,
|
|
142
|
+
grillDispositions: result.grillDispositions,
|
|
143
|
+
};
|
|
144
|
+
if (options.json) {
|
|
145
|
+
stdout.write(`${JSON.stringify(summary)}\n`);
|
|
146
|
+
} else {
|
|
147
|
+
stdout.write(
|
|
148
|
+
[
|
|
149
|
+
"refine-plan-file: PASS",
|
|
150
|
+
` plan: ${planPath}`,
|
|
151
|
+
` state: ${result.planFileIntakeState}`,
|
|
152
|
+
` stop: ${result.stop.kind} (${PLAN_FILE_REFINE_STOP.LOCAL_HUMAN_REVIEW === result.stop.kind ? "stop for local human review; no tracker artifact created" : result.stop.kind})`,
|
|
153
|
+
` grill findings recorded: ${result.grillDispositions.length}`,
|
|
154
|
+
].join("\n") + "\n",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return summary;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
161
|
+
runCli().catch((error) => {
|
|
162
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { formatCliError } from "../_core-helpers.mjs";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_USAGE_SUFFIX,
|
|
7
|
+
checkBaseSections,
|
|
8
|
+
parseCheckerCliArgs,
|
|
9
|
+
writeCheckerOutput,
|
|
10
|
+
isDirectCliRun,
|
|
11
|
+
} from "./_refine-helpers.mjs";
|
|
12
|
+
|
|
13
|
+
const USAGE = `Usage:
|
|
14
|
+
validate-plan-file.mjs --input <path> [--json]
|
|
15
|
+
Validate the base authoring sections of a phase-doc-format plan file: Status, Objective, In scope, Explicit non-goals.${"\n"}${DEFAULT_USAGE_SUFFIX}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base authoring sections a phase doc carries before refinement adds AC/DoD.
|
|
19
|
+
* Heading → distinct missing_* error code. Single source of truth: the section
|
|
20
|
+
* list is derived from these keys so the validator and the exported list cannot
|
|
21
|
+
* drift.
|
|
22
|
+
*/
|
|
23
|
+
const SECTION_CODES = {
|
|
24
|
+
Status: "missing_status",
|
|
25
|
+
Objective: "missing_objective",
|
|
26
|
+
"In scope": "missing_in_scope",
|
|
27
|
+
"Explicit non-goals": "missing_explicit_non_goals",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Base authoring section headings, in order (derived from SECTION_CODES). */
|
|
31
|
+
export const PLAN_FILE_BASE_SECTIONS = Object.keys(SECTION_CODES);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pure validator. Reports whether a plan file (phase-doc format) carries every
|
|
35
|
+
* base authoring section with a non-empty body. An absent or empty-body section
|
|
36
|
+
* is reported under that section's distinct missing_* code. No side effects.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} markdownText
|
|
39
|
+
* @returns {{ checker: "validate-plan-file", ok: boolean, errors: { code: string, message: string }[] }}
|
|
40
|
+
*/
|
|
41
|
+
export function validatePlanFile(markdownText) {
|
|
42
|
+
// checkBaseSections reports each absent/empty section under its distinct
|
|
43
|
+
// missing_* code; an empty-body section is malformed here too.
|
|
44
|
+
return checkBaseSections(markdownText, "validate-plan-file", SECTION_CODES);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
|
|
48
|
+
const options = parseCheckerCliArgs(argv, USAGE, "validate-plan-file");
|
|
49
|
+
if (options.help) {
|
|
50
|
+
stdout.write(`${USAGE}\n`);
|
|
51
|
+
return { ok: true, help: true };
|
|
52
|
+
}
|
|
53
|
+
const markdownText = await readFile(options.input, "utf8");
|
|
54
|
+
const result = validatePlanFile(markdownText);
|
|
55
|
+
writeCheckerOutput(result, { stdout, json: options.json });
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
60
|
+
runCli().catch((error) => {
|
|
61
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
});
|
|
64
|
+
}
|