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.
Files changed (85) 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/hooks/_run-context.mjs +9 -16
  5. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  6. package/.claude/skills/dev-loop/SKILL.md +17 -7
  7. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  8. package/.claude/skills/docs/anti-patterns.md +1 -1
  9. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  10. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/local-planning-flow.md +63 -0
  13. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  14. package/.claude/skills/docs/merge-preconditions.md +100 -1
  15. package/.claude/skills/docs/plan-file-contract.md +37 -0
  16. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  17. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  18. package/.claude/skills/docs/stop-conditions.md +1 -0
  19. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  20. package/.claude/skills/local-implementation/SKILL.md +8 -2
  21. package/CHANGELOG.md +69 -0
  22. package/README.md +9 -2
  23. package/agents/refiner.agent.md +1 -0
  24. package/cli/index.mjs +21 -2
  25. package/extension/README.md +1 -1
  26. package/package.json +6 -4
  27. package/scripts/README.md +2 -2
  28. package/scripts/github/capture-review-threads.mjs +20 -2
  29. package/scripts/github/offer-human-handoff.mjs +147 -0
  30. package/scripts/github/probe-ci-status.mjs +468 -0
  31. package/scripts/github/probe-copilot-review.mjs +69 -3
  32. package/scripts/github/request-copilot-review.mjs +3 -3
  33. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  34. package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
  35. package/scripts/lib/jq-output.mjs +297 -0
  36. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  37. package/scripts/loop/_worktree-path.mjs +27 -0
  38. package/scripts/loop/check-retro-tooling.mjs +246 -0
  39. package/scripts/loop/cleanup-worktree.mjs +175 -0
  40. package/scripts/loop/copilot-pr-handoff.mjs +22 -4
  41. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  42. package/scripts/loop/detect-stale-runner.mjs +3 -4
  43. package/scripts/loop/docs-grill-contract.mjs +70 -0
  44. package/scripts/loop/ensure-worktree.mjs +219 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/outer-loop.mjs +1 -1
  47. package/scripts/loop/pr-runner-coordination.mjs +21 -5
  48. package/scripts/loop/pre-flight-gate.mjs +10 -7
  49. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  50. package/scripts/loop/provision-worktree.mjs +243 -0
  51. package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
  52. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  53. package/scripts/loop/run-queue.mjs +25 -1
  54. package/scripts/loop/run-watch-cycle.mjs +152 -25
  55. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  56. package/scripts/pages/build-site.mjs +136 -0
  57. package/scripts/projects/add-queue-item.mjs +32 -7
  58. package/scripts/projects/archive-done-items.mjs +2 -1
  59. package/scripts/projects/ensure-queue-board.mjs +2 -1
  60. package/scripts/projects/list-queue-items.mjs +14 -3
  61. package/scripts/projects/move-queue-item.mjs +14 -3
  62. package/scripts/projects/reorder-queue-item.mjs +5 -4
  63. package/scripts/projects/sync-item-status.mjs +2 -1
  64. package/scripts/refine/_refine-helpers.mjs +20 -0
  65. package/scripts/refine/exit-spike.mjs +186 -0
  66. package/scripts/refine/promote-plan.mjs +387 -0
  67. package/scripts/refine/refine-plan-file.mjs +165 -0
  68. package/scripts/refine/validate-plan-file.mjs +64 -0
  69. package/scripts/refine/validate-spike-file.mjs +87 -0
  70. package/skills/copilot-pr-followup/SKILL.md +9 -7
  71. package/skills/dev-loop/SKILL.md +13 -3
  72. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  73. package/skills/docs/anti-patterns.md +1 -1
  74. package/skills/docs/artifact-authority-contract.md +86 -31
  75. package/skills/docs/copilot-loop-operations.md +1 -1
  76. package/skills/docs/issue-intake-procedure.md +4 -0
  77. package/skills/docs/local-planning-flow.md +63 -0
  78. package/skills/docs/local-planning-worked-example.md +139 -0
  79. package/skills/docs/merge-preconditions.md +100 -1
  80. package/skills/docs/plan-file-contract.md +37 -0
  81. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  82. package/skills/docs/spike-mode-contract.md +237 -0
  83. package/skills/docs/stop-conditions.md +1 -0
  84. package/skills/docs/tracker-first-loop-state.md +6 -6
  85. 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
+ }