dev-loops 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/commands/auto.md +7 -0
  5. package/.claude/commands/continue.md +15 -0
  6. package/.claude/commands/info.md +7 -0
  7. package/.claude/commands/start-spike.md +16 -0
  8. package/.claude/commands/start.md +7 -0
  9. package/.claude/commands/status.md +6 -0
  10. package/.claude/hooks/_run-context.mjs +11 -4
  11. package/.claude/skills/dev-loop/SKILL.md +21 -6
  12. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  13. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  14. package/.claude/skills/docs/local-planning-flow.md +63 -0
  15. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  16. package/.claude/skills/docs/merge-preconditions.md +35 -0
  17. package/.claude/skills/docs/plan-file-contract.md +37 -0
  18. package/.claude/skills/docs/release-runbook.md +45 -0
  19. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  20. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  21. package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
  22. package/.claude/skills/local-implementation/SKILL.md +1 -1
  23. package/CHANGELOG.md +73 -0
  24. package/README.md +21 -1
  25. package/agents/dev-loop.agent.md +8 -1
  26. package/agents/refiner.agent.md +1 -0
  27. package/cli/index.mjs +2 -0
  28. package/extension/index.ts +10 -1
  29. package/extension/presentation.ts +15 -0
  30. package/lib/dev-loops-core.mjs +141 -0
  31. package/package.json +8 -3
  32. package/scripts/claude/generate-claude-assets.mjs +15 -1
  33. package/scripts/github/capture-review-threads.mjs +20 -2
  34. package/scripts/github/comment-issue.mjs +181 -0
  35. package/scripts/github/fetch-ci-logs.mjs +215 -0
  36. package/scripts/github/list-issues.mjs +191 -0
  37. package/scripts/github/probe-copilot-review.mjs +69 -3
  38. package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
  39. package/scripts/lib/jq-output.mjs +297 -0
  40. package/scripts/loop/_handoff-contract.mjs +1 -0
  41. package/scripts/loop/check-retro-tooling.mjs +246 -0
  42. package/scripts/loop/copilot-pr-handoff.mjs +21 -3
  43. package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
  44. package/scripts/loop/docs-grill-contract.mjs +70 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/pr-runner-coordination.mjs +20 -4
  47. package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
  48. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  49. package/scripts/loop/run-conductor-cycle.mjs +5 -0
  50. package/scripts/loop/run-watch-cycle.mjs +77 -3
  51. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  52. package/scripts/pages/build-site.mjs +136 -0
  53. package/scripts/projects/add-queue-item.mjs +12 -2
  54. package/scripts/projects/list-queue-items.mjs +12 -2
  55. package/scripts/projects/move-queue-item.mjs +12 -2
  56. package/scripts/projects/resolve-active-board-item.mjs +193 -0
  57. package/scripts/refine/_refine-helpers.mjs +20 -0
  58. package/scripts/refine/exit-spike.mjs +186 -0
  59. package/scripts/refine/promote-plan.mjs +387 -0
  60. package/scripts/refine/refine-plan-file.mjs +165 -0
  61. package/scripts/refine/scaffold-spike-file.mjs +183 -0
  62. package/scripts/refine/validate-plan-file.mjs +64 -0
  63. package/scripts/refine/validate-spike-file.mjs +87 -0
  64. package/scripts/release/extract-changelog-section.mjs +111 -0
  65. package/skills/dev-loop/SKILL.md +24 -2
  66. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  67. package/skills/docs/artifact-authority-contract.md +86 -31
  68. package/skills/docs/local-planning-flow.md +63 -0
  69. package/skills/docs/local-planning-worked-example.md +139 -0
  70. package/skills/docs/merge-preconditions.md +35 -0
  71. package/skills/docs/plan-file-contract.md +37 -0
  72. package/skills/docs/release-runbook.md +45 -0
  73. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  74. package/skills/docs/spike-mode-contract.md +237 -0
  75. package/skills/docs/ui-e2e-scoping-step.md +102 -0
  76. package/skills/local-implementation/SKILL.md +1 -1
@@ -0,0 +1,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,183 @@
1
+ #!/usr/bin/env node
2
+ // Scaffold a STARTABLE spike findings artifact from an inline question (#988 P2).
3
+ //
4
+ // `/start-spike <question>` needs a findings artifact that passes
5
+ // `validateSpikeExplorationSections` (non-empty Question/Approach/Findings) so
6
+ // `resolve-dev-loop-startup --spike <path>` accepts it. This is the only new
7
+ // piece behind /start-spike: a pure section builder + a thin file writer. The
8
+ // Recommendation is left for the spike to fill in (the exit marker), matching
9
+ // the spike-mode contract — so this scaffolds an in-progress spike, not a
10
+ // ready-for-exit one. No new spike behavior; the intake/gate/exit machinery is
11
+ // the shipped #964/#965/#966 surface.
12
+ import { mkdir, writeFile } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { parseArgs } from "node:util";
15
+
16
+ import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
17
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
18
+ import { SPIKE_FILE_EXPLORATION_SECTIONS } from "./validate-spike-file.mjs";
19
+
20
+ // Placeholder bodies the operator fills in during the spike. Non-empty so the
21
+ // exploration scaffold validates; explicitly marked TBD so they are obviously
22
+ // stubs, not findings. (`Recommendation` is intentionally omitted — it is the
23
+ // exit marker, written when the spike concludes.)
24
+ const PLACEHOLDER = "TBD — filled in during the spike.";
25
+
26
+ const USAGE = `Usage:
27
+ scaffold-spike-file.mjs --question <text> --out <path>
28
+
29
+ Scaffold a startable spike findings artifact from an inline question. Writes a
30
+ file carrying the exploration scaffold (## Question filled from --question,
31
+ ## Approach/## Findings stubbed) so \`resolve-dev-loop-startup --spike <path>\`
32
+ accepts it. The ## Recommendation is left for the spike to fill in.
33
+
34
+ Options:
35
+ --question <text> Required. The question the spike investigates.
36
+ --out <path> Required. Where to write the findings artifact.
37
+ --help, -h Show this help.
38
+
39
+ Output (stdout):
40
+ JSON: { ok: true, path: "<abs>", question: "<text>" }
41
+
42
+ ${JQ_OUTPUT_USAGE}
43
+ `.trim();
44
+
45
+ // Pure builder: question text -> a findings-artifact markdown body carrying the
46
+ // exploration scaffold. Question gets the operator's text; Approach/Findings get
47
+ // non-empty placeholder bodies so the exploration scaffold validates.
48
+ export function buildSpikeScaffold(question) {
49
+ const trimmed = String(question ?? "").trim();
50
+ if (trimmed.length === 0) {
51
+ throw Object.assign(new Error("--question must be non-empty"), { code: "INVALID_ARGS" });
52
+ }
53
+ const bodies = { Question: trimmed, Approach: PLACEHOLDER, Findings: PLACEHOLDER };
54
+ const sections = SPIKE_FILE_EXPLORATION_SECTIONS.map(
55
+ (heading) => `## ${heading}\n\n${bodies[heading]}\n`,
56
+ );
57
+ return `# Spike\n\n${sections.join("\n")}`;
58
+ }
59
+
60
+ function parseCliArgs(argv) {
61
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
62
+ const requireValue = (token, message) => {
63
+ const v = token.value;
64
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
65
+ throw parseError(message);
66
+ }
67
+ return v;
68
+ };
69
+ // The question is free text embedded in the artifact body, never forwarded as
70
+ // a CLI option — so unlike --out/--jq it may legitimately begin with `-`
71
+ // (e.g. "- should we ...?"). Only require a non-empty value, matching the
72
+ // core free-text parser; leave the leading-`-` guard to the path/filter flags.
73
+ const requireQuestion = (token, message) => {
74
+ const v = token.value;
75
+ if (typeof v !== "string" || v.length === 0) {
76
+ throw parseError(message);
77
+ }
78
+ return v;
79
+ };
80
+
81
+ const args = {};
82
+ const { tokens } = parseArgs({
83
+ args: [...argv],
84
+ options: {
85
+ question: { type: "string" },
86
+ out: { type: "string" },
87
+ json: { type: "boolean" },
88
+ help: { type: "boolean", short: "h" },
89
+ ...JQ_OUTPUT_PARSE_OPTIONS,
90
+ },
91
+ allowPositionals: true,
92
+ strict: false,
93
+ tokens: true,
94
+ });
95
+
96
+ for (const token of tokens) {
97
+ if (token.kind === "positional") {
98
+ throw parseError(`Unexpected argument: ${token.value}`);
99
+ }
100
+ if (token.kind !== "option") {
101
+ continue;
102
+ }
103
+ switch (token.name) {
104
+ case "help":
105
+ args.help = true;
106
+ break;
107
+ case "json":
108
+ // Output is JSON by default; accepted as a no-op for callers that pass it.
109
+ break;
110
+ case "question":
111
+ args.question = requireQuestion(token, "--question requires a value");
112
+ break;
113
+ case "out":
114
+ args.out = requireValue(token, "--out requires a value (path)");
115
+ break;
116
+ case "jq":
117
+ args.jq = requireValue(token, "--jq requires a filter");
118
+ break;
119
+ case "silent":
120
+ args.silent = true;
121
+ break;
122
+ default:
123
+ throw parseError(`Unknown flag: ${token.rawName}`);
124
+ }
125
+ }
126
+ return args;
127
+ }
128
+
129
+ export async function main(args, { writeFileImpl = writeFile, mkdirImpl = mkdir } = {}) {
130
+ if (!args.question) {
131
+ throw Object.assign(new Error("--question is required"), { usage: USAGE, code: "INVALID_ARGS" });
132
+ }
133
+ if (!args.out) {
134
+ throw Object.assign(new Error("--out is required"), { usage: USAGE, code: "INVALID_ARGS" });
135
+ }
136
+ const body = buildSpikeScaffold(args.question);
137
+ const outPath = path.resolve(args.out);
138
+ await mkdirImpl(path.dirname(outPath), { recursive: true });
139
+ // Fail closed on an existing file: the slash-command picks a fresh path and
140
+ // must never clobber a spike already in progress. `wx` makes write+exclusive
141
+ // atomic, so a concurrent create still loses (no overwrite).
142
+ try {
143
+ await writeFileImpl(outPath, body, { encoding: "utf8", flag: "wx" });
144
+ } catch (err) {
145
+ if (err?.code === "EEXIST") {
146
+ throw Object.assign(new Error(`--out already exists, refusing to overwrite: ${outPath}`), {
147
+ usage: USAGE,
148
+ code: "INVALID_ARGS",
149
+ });
150
+ }
151
+ throw err;
152
+ }
153
+ return { ok: true, path: outPath, question: String(args.question).trim() };
154
+ }
155
+
156
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
157
+ let args;
158
+ try {
159
+ args = parseCliArgs(argv);
160
+ } catch (err) {
161
+ stderr.write(`${formatCliError(err)}\n`);
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+ if (args.help) {
166
+ stdout.write(`${USAGE}\n`);
167
+ return;
168
+ }
169
+ try {
170
+ const result = await main(args);
171
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
172
+ } catch (err) {
173
+ stderr.write(`${formatCliError(err)}\n`);
174
+ process.exitCode = err.code === "INVALID_ARGS" ? 1 : 2;
175
+ }
176
+ }
177
+
178
+ if (isDirectCliRun(import.meta.url)) {
179
+ runCli(process.argv.slice(2)).catch((error) => {
180
+ process.stderr.write(`${formatCliError(error)}\n`);
181
+ process.exitCode = 2;
182
+ });
183
+ }
@@ -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
+ }
@@ -0,0 +1,87 @@
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-spike-file.mjs --input <path> [--json]
15
+ Validate the base authoring sections of a spike artifact: Question, Approach, Findings, Recommendation.${"\n"}${DEFAULT_USAGE_SUFFIX}`;
16
+
17
+ /**
18
+ * Base authoring sections a spike artifact carries.
19
+ * Heading → distinct missing_* error code.
20
+ */
21
+ const SECTION_CODES = {
22
+ Question: "missing_question",
23
+ Approach: "missing_approach",
24
+ Findings: "missing_findings",
25
+ Recommendation: "missing_recommendation",
26
+ };
27
+
28
+ export const SPIKE_FILE_BASE_SECTIONS = Object.keys(SECTION_CODES);
29
+
30
+ /**
31
+ * Recommendation is the exit-marker section: present + non-empty means the
32
+ * spike has reached a recommendation and is ready for a discard/graduate exit
33
+ * decision. The exploration scaffold (Question/Approach/Findings) is what an
34
+ * in-progress spike must carry for `--spike` entry. (Mirrors the plan-file
35
+ * base-vs-refinement-section split.)
36
+ */
37
+ export const SPIKE_FILE_EXIT_MARKER_SECTION = "Recommendation";
38
+ export const SPIKE_FILE_EXPLORATION_SECTIONS = SPIKE_FILE_BASE_SECTIONS.filter(
39
+ (h) => h !== SPIKE_FILE_EXIT_MARKER_SECTION,
40
+ );
41
+
42
+ const EXPLORATION_SECTION_CODES = Object.fromEntries(
43
+ SPIKE_FILE_EXPLORATION_SECTIONS.map((h) => [h, SECTION_CODES[h]]),
44
+ );
45
+
46
+ /**
47
+ * Pure validator. Reports whether a spike artifact carries every base authoring
48
+ * section with a non-empty body. An absent or empty-body section is reported
49
+ * under that section's distinct missing_* code. No side effects.
50
+ *
51
+ * @param {string} markdownText
52
+ * @returns {{ checker: "validate-spike-file", ok: boolean, errors: { code: string, message: string }[] }}
53
+ */
54
+ export function validateSpikeFile(markdownText) {
55
+ return checkBaseSections(markdownText, "validate-spike-file", SECTION_CODES);
56
+ }
57
+
58
+ /**
59
+ * Entry-gate validator for `--spike`: a spike is startable as soon as it carries
60
+ * the exploration scaffold (Question/Approach/Findings) with non-empty bodies;
61
+ * the Recommendation is filled in DURING the spike, not required to begin one.
62
+ *
63
+ * @param {string} markdownText
64
+ * @returns {{ checker: "validate-spike-file", ok: boolean, errors: { code: string, message: string }[] }}
65
+ */
66
+ export function validateSpikeExplorationSections(markdownText) {
67
+ return checkBaseSections(markdownText, "validate-spike-file", EXPLORATION_SECTION_CODES);
68
+ }
69
+
70
+ export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
71
+ const options = parseCheckerCliArgs(argv, USAGE, "validate-spike-file");
72
+ if (options.help) {
73
+ stdout.write(`${USAGE}\n`);
74
+ return { ok: true, help: true };
75
+ }
76
+ const markdownText = await readFile(options.input, "utf8");
77
+ const result = validateSpikeFile(markdownText);
78
+ writeCheckerOutput(result, { stdout, json: options.json });
79
+ return result;
80
+ }
81
+
82
+ if (isDirectCliRun(import.meta.url)) {
83
+ runCli().catch((error) => {
84
+ process.stderr.write(`${formatCliError(error)}\n`);
85
+ process.exitCode = 1;
86
+ });
87
+ }
@@ -0,0 +1,111 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { isDirectCliRun } from "../_core-helpers.mjs";
4
+
5
+ const USAGE = `Usage: extract-changelog-section.mjs --version <v> [--changelog <path>]
6
+
7
+ Prints the CHANGELOG.md section for <version> (the block from "## <version>"
8
+ up to the next "## " heading). Exits 1 if no such section exists or the section
9
+ is empty, so a release is never created for an undocumented version. Exits 2 on
10
+ usage/argument errors (missing --version, missing flag value, unreadable file).`;
11
+
12
+ /**
13
+ * Extract the changelog block for a single version.
14
+ *
15
+ * Matches a heading line of the form "## <version>" optionally followed by
16
+ * " - <date>" (or any trailing text), and returns everything up to but not
17
+ * including the next "## " heading. Returns null when no matching section
18
+ * exists (fail closed — caller must not synthesize notes).
19
+ *
20
+ * @param {string} changelog - Full CHANGELOG.md contents.
21
+ * @param {string} version - Version without a leading "v" (e.g. "0.5.0").
22
+ * @returns {string|null} Trimmed section body (without the heading), or null.
23
+ */
24
+ export function extractChangelogSection(changelog, version) {
25
+ const lines = changelog.split("\n");
26
+ // A heading is "## " followed by the exact version token, then either end of
27
+ // line or a non-version-character (space / "-"). This avoids "0.5.0" matching
28
+ // "0.5.0-rc1" or "0.5.01".
29
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
+ const headingRe = new RegExp(`^##\\s+v?${escaped}(?=\\s|$)`);
31
+
32
+ let start = -1;
33
+ for (let i = 0; i < lines.length; i += 1) {
34
+ if (headingRe.test(lines[i])) {
35
+ start = i;
36
+ break;
37
+ }
38
+ }
39
+
40
+ if (start === -1) {
41
+ return null;
42
+ }
43
+
44
+ const body = [];
45
+ for (let i = start + 1; i < lines.length; i += 1) {
46
+ if (/^##\s/.test(lines[i])) {
47
+ break;
48
+ }
49
+ body.push(lines[i]);
50
+ }
51
+
52
+ return body.join("\n").trim();
53
+ }
54
+
55
+ export async function main(argv = process.argv.slice(2)) {
56
+ if (argv.includes("--help") || argv.includes("-h")) {
57
+ process.stdout.write(`${USAGE}\n`);
58
+ return 0;
59
+ }
60
+
61
+ let version;
62
+ let changelogPath = "CHANGELOG.md";
63
+ for (let i = 0; i < argv.length; i += 1) {
64
+ if (argv[i] === "--version" || argv[i] === "--changelog") {
65
+ const flag = argv[i];
66
+ const value = argv[i + 1];
67
+ if (value === undefined) {
68
+ process.stderr.write(`error: ${flag} requires a value\n\n${USAGE}\n`);
69
+ return 2;
70
+ }
71
+ if (flag === "--version") {
72
+ version = value;
73
+ } else {
74
+ changelogPath = value;
75
+ }
76
+ i += 1;
77
+ }
78
+ }
79
+
80
+ if (!version) {
81
+ process.stderr.write(`error: --version is required\n\n${USAGE}\n`);
82
+ return 2;
83
+ }
84
+
85
+ const normalized = version.replace(/^v/, "");
86
+
87
+ let changelog;
88
+ try {
89
+ changelog = await readFile(changelogPath, "utf8");
90
+ } catch (error) {
91
+ process.stderr.write(`error: cannot read ${changelogPath}: ${error.message}\n`);
92
+ return 2;
93
+ }
94
+
95
+ const section = extractChangelogSection(changelog, normalized);
96
+ if (section === null || section === "") {
97
+ process.stderr.write(
98
+ `error: no section found for version ${normalized} in ${changelogPath}. ` +
99
+ `Refusing to create a release for an undocumented version.\n`,
100
+ );
101
+ return 1;
102
+ }
103
+
104
+ process.stdout.write(`${section}\n`);
105
+ return 0;
106
+ }
107
+
108
+ if (isDirectCliRun(import.meta.url)) {
109
+ const exitCode = await main();
110
+ process.exitCode = exitCode;
111
+ }
@@ -35,7 +35,14 @@ For async-required routes (config `workflow.asyncStartMode`, default `required`)
35
35
  > Under the Claude Code harness the dev-loop runs as a single agent: run these steps directly — no read-only boundary and no separate async-subagent dispatch. See [Main Agent Contract](../docs/main-agent-contract.md).
36
36
 
37
37
  <!-- pi-only -->
38
- **CLI invocation (`<dev-loops-package-root>`):** dev-loop CLI commands below are invoked as `node <dev-loops-package-root>/cli/index.mjs <verb...>` using the package-local CLI rather than `npx`, so they resolve unambiguously from the installed package without a global install. Resolve `<dev-loops-package-root>` from this skill's own installed path: this skill is installed at `<package-root>/.pi/skills/dev-loop/SKILL.md`, so the package root is `../../..` from this skill's directory. (The `dev-loop` agent resolves it analogously from its own installed path.)
38
+ **CLI invocation (`<dev-loops-package-root>`):** dev-loop CLI commands below are invoked as `node <dev-loops-package-root>/cli/index.mjs <verb...>` using the package-local CLI rather than `npx`, so they resolve unambiguously from the installed package without a global install. Resolve `<dev-loops-package-root>` via the first of these **bounded** candidates whose `cli/index.mjs` exists — never assume a single fixed layout (under a Pi user-level install the package lives at `~/.pi/agent/npm/node_modules/dev-loops/`, so the old `../../..` package-relative guess from `skills/dev-loop/SKILL.md` overshoots the package root):
39
+
40
+ 1. **Node module resolution** (best-effort first try): `node -e "try{const p=require('node:path');console.log(p.resolve(p.dirname(require.resolve('dev-loops/cli/index.mjs')),'..'))}catch{process.exit(1)}"` — resolves the package root when `dev-loops` is reachable from Node's module search path (notably under `~/.pi/agent/npm`); this is cwd-dependent and commonly misses from a target-repo cwd, so the probe is wrapped in try/catch (no stack trace, exits non-zero on miss) — treat a non-zero exit as "probe missed, try the next candidate", not a hard failure.
41
+ 2. **Pi user-agent npm root** (reliable for user-level installs): `~/.pi/agent/npm/node_modules/dev-loops`.
42
+ 3. **Package-relative (legacy):** `../../..` from this skill's own directory (the original package-local install layout).
43
+ 4. **Global npm root:** `$(npm root -g)/dev-loops`.
44
+
45
+ NEVER fall back to `find /` or any unbounded filesystem walk to locate the CLI — it stalls and trips the needs-attention timeout. If every bounded candidate fails, stop and ask the orchestrator/operator for the dev-loops package root rather than searching. (The `dev-loop` agent resolves it analogously.)
39
46
  <!-- /pi-only -->
40
47
 
41
48
  Resolve authoritative state via the startup resolver (`node <dev-loops-package-root>/cli/index.mjs loop startup --issue <n>` for issues, `node <dev-loops-package-root>/cli/index.mjs loop startup --pr <n>` for PRs), then immediately build the handoff envelope via `node <dev-loops-package-root>/cli/index.mjs loop build-envelope --input <resolver-output.json>`. The envelope determines `requiredReads`, `nextAction`, `stopRules`, and `acceptance` — load only those files, execute only that bounded task. It is the first handoff artifact consumed before loading any route pack. See [Workflow Handoff Contract](../docs/workflow-handoff-contract.md) for the derivation contract.
@@ -118,6 +125,21 @@ Info/handoff requests can be served directly via `node <dev-loops-package-root>/
118
125
  - `node <dev-loops-package-root>/cli/index.mjs loop info --pr <n>` — human-readable PR state summary (branch, CI, threads, rounds, action)
119
126
  - `node <dev-loops-package-root>/cli/index.mjs loop info --issue <n> --json` — machine-readable JSON output
120
127
 
128
+ ## Reading tool output (token-economical convention)
129
+
130
+ When you need a fact from a dev-loops JSON-emitting script, climb this ladder and stop at the first rung that answers the question — never read more output than you need:
131
+
132
+ 1. **Prefer the dev-loops subcommand / concise mode.** Use `loop info` or a script's `--concise`/`--summary` mode (e.g. `run-watch-cycle.mjs --concise`, `probe-copilot-review.mjs --concise`) for a human-readable digest. The concise modes surface loop state, Copilot round count, unresolved/actionable thread counts, round-cap-clean eligibility, CI status, next action, and the current round's new Copilot comment bodies.
133
+ 2. **`--silent` / `-s` for a yes/no check.** Reads ZERO output: `… --jq '<predicate>' --silent; echo $?` exits `0` for true / `1` for false. Without `--jq`, `--silent` maps the script's success (`ok:true`) to exit `0`, failure to `1`. Example: `probe-copilot-review.mjs --repo o/r --pr N --jq '.status=="idle"' -s`.
134
+ 3. **`--jq <filter>` to extract a single field.** The `--jq`-wired scripts (`probe-copilot-review.mjs`, `capture-review-threads.mjs`, `upsert-checkpoint-verdict.mjs`, the `scripts/projects/*` queue scripts) accept a gh-style `--jq` filter (jq subset: field access, `.[]`/`.[N]`, `|`, `select(...)`, `==`/`!=`/`<`/`<=`/`>`/`>=`, `length`, `keys`). It prints only the filtered value. An invalid filter fails closed (stderr + exit `2`), distinct from a clean predicate-false (silent exit `1`).
135
+ 4. **Use a dev-loops wrapper for `gh` reads — never an agent-level raw `gh`.** With `workflow.requireRetrospectiveInternalTooling: true`, a raw `gh` call is a recorded retro violation (fail-closed). If no script covers the read you need, treat it as a tooling gap: file/build a thin wrapper (reuse `scripts/lib/jq-output.mjs`, like the three below), don't shell out. The reads that already have wrappers:
136
+ - CI run-log tail (a failing PR's job log) → `scripts/github/fetch-ci-logs.mjs --repo <o/r> --pr <n> [--failed-only] [--tail <n>]`, **never raw `gh run view --log`/`--log-failed`**. (`probe-ci-status.mjs` names the failed checks; this returns the LOG.)
137
+ - Issue list/filter → `scripts/github/list-issues.mjs --repo <o/r> [--state <open|closed|all>] [--label <l>] [--limit <n>]`, **never raw `gh issue list`**. (The queue tool lists the project board; this is for arbitrary issue queries.)
138
+ - Issue comment → `scripts/github/comment-issue.mjs --repo <o/r> --issue <n> (--body <text> | --body-file <path>)`, **never raw `gh issue comment`**. Returns `{ ok, commentUrl }`.
139
+
140
+ All three accept the same `--jq`/`--silent` output flags as the other JSON-emitting scripts.
141
+ 5. **NEVER `| python3` or `node -e`** to parse tool JSON. If a field you need is missing from a script's output, add it to the script (or its concise mode), not an inline parser.
142
+
121
143
  ## Guard rules
122
144
 
123
145
  **Handoff envelope precedence:** The dev-loop builds the envelope immediately after authoritative-state resolution and treats it as the first handoff artifact. Read it first, load only `requiredReads`, execute `nextAction`. See [Resolve authoritative state](#resolve-authoritative-state). Derivation contract: [Workflow Handoff Contract](../docs/workflow-handoff-contract.md).
@@ -128,7 +150,7 @@ Info/handoff requests can be served directly via `node <dev-loops-package-root>/
128
150
 
129
151
  **Bounded async task contract:** Break work into discrete tasks with clear inputs, explicit outputs, bounded scope. No shell polling — use `run-watch-cycle.mjs` or `gh run watch`.
130
152
 
131
- **Round-cap budget check (enforced):** After every watch cycle, fix pass, or reply-resolve, check whether completed Copilot review rounds have reached the maximum (default: 5). Stop re-requesting Copilot review when the limit is reached — never re-request after the cap.
153
+ **Round-cap budget check (enforced):** After every watch cycle, fix pass, or reply-resolve, check whether completed Copilot review rounds have reached the maximum (default: 5). Stop re-requesting Copilot review when the limit is reached — never re-request after the cap. Read these gate-cadence facts via the token-economical convention above (`run-watch-cycle.mjs --concise`, or `--jq`/`--silent` for a single field/predicate) — never `| python3` or `node -e`.
132
154
 
133
155
  ## Shorthand issue-based auto trigger contract
134
156