dev-loops 0.5.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 (35) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/commands/auto.md +7 -0
  4. package/.claude/commands/continue.md +15 -0
  5. package/.claude/commands/info.md +7 -0
  6. package/.claude/commands/start-spike.md +16 -0
  7. package/.claude/commands/start.md +7 -0
  8. package/.claude/commands/status.md +6 -0
  9. package/.claude/hooks/_run-context.mjs +11 -4
  10. package/.claude/skills/dev-loop/SKILL.md +11 -6
  11. package/.claude/skills/docs/release-runbook.md +45 -0
  12. package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
  13. package/.claude/skills/local-implementation/SKILL.md +1 -1
  14. package/CHANGELOG.md +30 -0
  15. package/README.md +20 -1
  16. package/agents/dev-loop.agent.md +8 -1
  17. package/cli/index.mjs +2 -0
  18. package/extension/index.ts +10 -1
  19. package/extension/presentation.ts +15 -0
  20. package/lib/dev-loops-core.mjs +141 -0
  21. package/package.json +5 -2
  22. package/scripts/claude/generate-claude-assets.mjs +15 -1
  23. package/scripts/github/comment-issue.mjs +181 -0
  24. package/scripts/github/fetch-ci-logs.mjs +215 -0
  25. package/scripts/github/list-issues.mjs +191 -0
  26. package/scripts/loop/_handoff-contract.mjs +1 -0
  27. package/scripts/loop/detect-pr-gate-coordination-state.mjs +31 -1
  28. package/scripts/loop/run-conductor-cycle.mjs +5 -0
  29. package/scripts/projects/resolve-active-board-item.mjs +193 -0
  30. package/scripts/refine/scaffold-spike-file.mjs +183 -0
  31. package/scripts/release/extract-changelog-section.mjs +111 -0
  32. package/skills/dev-loop/SKILL.md +14 -2
  33. package/skills/docs/release-runbook.md +45 -0
  34. package/skills/docs/ui-e2e-scoping-step.md +102 -0
  35. package/skills/local-implementation/SKILL.md +1 -1
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { parseArgs } from "node:util";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
+
8
+ const STATES = new Set(["open", "closed", "all"]);
9
+
10
+ const USAGE = `Usage: list-issues.mjs --repo <owner/name> [--state <open|closed|all>] [--label <l>] [--limit <n>]
11
+ List/filter repository issues. Thin wrapper over \`gh issue list\` — use this instead
12
+ of an agent-level raw \`gh issue list\` so the loop's internal-tooling record stays
13
+ clean (#993). The queue tool lists the project board, not arbitrary issue queries;
14
+ this fills that gap (siblings: comment-issue.mjs, fetch-ci-logs.mjs).
15
+ Required:
16
+ --repo <owner/name> Repository slug (e.g. owner/repo)
17
+ Optional:
18
+ --state <open|closed|all> Issue state filter (default open)
19
+ --label <l> Filter by label (repeatable; AND-combined by gh)
20
+ --limit <n> Return at most <n> issues (default 30)
21
+ Output (stdout, JSON):
22
+ { "ok": true, "issues": [{ "number": 17, "title": "...", "state": "open", "labels": ["bug"] }, ...] }
23
+ Error output (stderr, JSON):
24
+ { "ok": false, "error": "...", "usage"?: "..." }
25
+ ${JQ_OUTPUT_USAGE}
26
+ Exit codes:
27
+ 0 Success
28
+ 1 Argument error or gh failure
29
+ 2 Invalid --jq filter`.trim();
30
+ const parseError = buildParseError(USAGE);
31
+
32
+ export function parseListIssuesCliArgs(argv) {
33
+ const { tokens } = parseArgs({
34
+ args: [...argv],
35
+ options: {
36
+ help: { type: "boolean", short: "h" },
37
+ repo: { type: "string" },
38
+ state: { type: "string" },
39
+ label: { type: "string", multiple: true },
40
+ limit: { type: "string" },
41
+ ...JQ_OUTPUT_PARSE_OPTIONS,
42
+ },
43
+ allowPositionals: true,
44
+ strict: false,
45
+ tokens: true,
46
+ });
47
+ const options = {
48
+ help: false,
49
+ repo: undefined,
50
+ state: "open",
51
+ labels: [],
52
+ limit: 30,
53
+ jq: undefined,
54
+ silent: false,
55
+ };
56
+ for (const token of tokens) {
57
+ if (token.kind === "positional") {
58
+ throw parseError(`Unknown argument: ${token.value}`);
59
+ }
60
+ if (token.kind !== "option") {
61
+ continue;
62
+ }
63
+ if (token.name === "help") {
64
+ options.help = true;
65
+ return options;
66
+ }
67
+ if (token.name === "repo") {
68
+ options.repo = requireTokenValue(token, parseError).trim();
69
+ continue;
70
+ }
71
+ if (token.name === "state") {
72
+ const state = requireTokenValue(token, parseError).trim().toLowerCase();
73
+ if (!STATES.has(state)) {
74
+ throw parseError("--state must be one of: open, closed, all");
75
+ }
76
+ options.state = state;
77
+ continue;
78
+ }
79
+ if (token.name === "label") {
80
+ const label = requireTokenValue(token, parseError).trim();
81
+ if (label.length === 0) {
82
+ throw parseError("--label must be a non-empty string");
83
+ }
84
+ options.labels.push(label);
85
+ continue;
86
+ }
87
+ if (token.name === "limit") {
88
+ const raw = requireTokenValue(token, parseError);
89
+ const value = Number(raw);
90
+ if (!Number.isInteger(value) || value < 1) {
91
+ throw parseError(`--limit must be a positive integer, got "${raw}"`);
92
+ }
93
+ options.limit = value;
94
+ continue;
95
+ }
96
+ if (token.name === "jq") {
97
+ options.jq = requireTokenValue(token, parseError);
98
+ continue;
99
+ }
100
+ if (token.name === "silent") {
101
+ options.silent = true;
102
+ continue;
103
+ }
104
+ throw parseError(`Unknown argument: ${token.rawName}`);
105
+ }
106
+ if (options.repo === undefined) {
107
+ throw parseError("Listing issues requires --repo <owner/name>");
108
+ }
109
+ try {
110
+ parseRepoSlug(options.repo);
111
+ } catch (error) {
112
+ throw parseError(error instanceof Error ? error.message : String(error));
113
+ }
114
+ return options;
115
+ }
116
+
117
+ // Returns a well-typed issue, or null if the gh entry is missing/invalid in any
118
+ // required field. Callers filter the nulls so the emitted shape stays
119
+ // well-typed (documented output) and --jq filters never hit null number/title/state.
120
+ function normalizeIssue(raw) {
121
+ if (!Number.isInteger(raw?.number) || typeof raw?.title !== "string" || typeof raw?.state !== "string") {
122
+ return null;
123
+ }
124
+ return {
125
+ number: raw.number,
126
+ title: raw.title,
127
+ // gh reports issue state UPPERCASE (OPEN/CLOSED); normalize to lowercase to
128
+ // match the --state flag vocabulary.
129
+ state: raw.state.toLowerCase(),
130
+ labels: Array.isArray(raw?.labels)
131
+ ? raw.labels.map((l) => (typeof l?.name === "string" ? l.name : null)).filter((n) => n !== null)
132
+ : [],
133
+ };
134
+ }
135
+
136
+ export async function listIssues(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
137
+ const args = [
138
+ "issue",
139
+ "list",
140
+ "--repo",
141
+ options.repo,
142
+ "--state",
143
+ options.state,
144
+ "--limit",
145
+ String(options.limit),
146
+ "--json",
147
+ "number,title,state,labels",
148
+ ];
149
+ for (const label of options.labels) {
150
+ args.push("--label", label);
151
+ }
152
+ const result = await run(ghCommand, args, env);
153
+ if (result.code !== 0) {
154
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
155
+ throw new Error(`gh issue list failed: ${detail}`);
156
+ }
157
+ const payload = parseJsonText(result.stdout, { label: "gh issue list" });
158
+ if (!Array.isArray(payload)) {
159
+ throw new Error("gh issue list did not return a JSON array");
160
+ }
161
+ return { ok: true, issues: payload.map(normalizeIssue).filter((issue) => issue !== null) };
162
+ }
163
+
164
+ export async function runCli(
165
+ argv = process.argv.slice(2),
166
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
167
+ ) {
168
+ let options;
169
+ try {
170
+ options = parseListIssuesCliArgs(argv);
171
+ } catch (error) {
172
+ stderr.write(`${formatCliError(error)}\n`);
173
+ return 1;
174
+ }
175
+ if (options.help) {
176
+ stdout.write(`${USAGE}\n`);
177
+ return 0;
178
+ }
179
+ let result;
180
+ try {
181
+ result = await listIssues(options, { env, ghCommand, run });
182
+ } catch (error) {
183
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
184
+ return 1;
185
+ }
186
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
187
+ }
188
+
189
+ if (isDirectCliRun(import.meta.url)) {
190
+ runCli().then((code) => { process.exitCode = code; });
191
+ }
@@ -28,6 +28,7 @@ const SUBAGENT_ACTIONS = new Set([
28
28
  "request_review",
29
29
  "rerequest_review",
30
30
  "run_pre_approval",
31
+ "run_ui_e2e",
31
32
  ]);
32
33
  function normalizeContractValue(value) {
33
34
  return typeof value === "string" ? value.trim().toLowerCase() : null;
@@ -17,6 +17,7 @@ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
17
17
  import { buildSnapshotFromPrFacts, interpretLoopState, summarizeLoopInterpretation } from "@dev-loops/core/loop/copilot-loop-state";
18
18
  import { evaluatePrGateCoordination, PR_CHECKPOINT, PR_CHECKPOINT_ACTION } from "@dev-loops/core/loop/pr-gate-coordination";
19
19
  import { shouldGuardCopilotReviewRequest } from "@dev-loops/core/loop/pr-gate-coordination";
20
+ import { UI_E2E_CHECK_NAMES } from "@dev-loops/core/loop/ui-e2e-scoping";
20
21
  import { fetchGithubReviewThreadsPayload } from "../github/capture-review-threads.mjs";
21
22
  import { detectCheckpointEvidence } from "../github/detect-checkpoint-evidence.mjs";
22
23
  import { parseArgs } from "node:util";
@@ -163,7 +164,7 @@ async function fetchRequestedReviewers({ repo, pr }, { env = process.env, ghComm
163
164
  async function fetchPrFacts({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
164
165
  const result = await runChild(
165
166
  ghCommand,
166
- ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup"],
167
+ ["pr", "view", String(pr), "--repo", repo, "--json", "number,state,isDraft,headRefOid,mergeable,mergeStateStatus,body,title,closingIssuesReferences,reviews,statusCheckRollup,files"],
167
168
  env,
168
169
  );
169
170
  if (result.code !== 0) {
@@ -198,6 +199,32 @@ export async function fetchPrFactsWithSettledMergeable(
198
199
  }
199
200
  return prData;
200
201
  }
202
+ // Changed-file paths from `gh pr view --json files` (issue #976). Feeds the
203
+ // path-triggered UI e2e scoping precondition in the evaluator.
204
+ export function extractChangedFiles(prData) {
205
+ const files = Array.isArray(prData?.files) ? prData.files : [];
206
+ return files
207
+ .map((entry) => (typeof entry?.path === "string" ? entry.path : null))
208
+ .filter((p) => typeof p === "string" && p.length > 0);
209
+ }
210
+
211
+ // Whether the shared UI e2e suite passed for this head, read deterministically
212
+ // from the statusCheckRollup: every UI e2e check that is present must be
213
+ // SUCCESS. Returns null when no UI e2e check is present in the rollup (unknown
214
+ // → the evaluator fails closed), false if any present UI e2e check is not a
215
+ // success, true if all present ones succeeded.
216
+ export function deriveUiE2ePassed(prData, checkNames = UI_E2E_CHECK_NAMES) {
217
+ const rollup = Array.isArray(prData?.statusCheckRollup) ? prData.statusCheckRollup : [];
218
+ const wanted = new Set(checkNames);
219
+ const present = rollup.filter((entry) => wanted.has(entry?.name) || wanted.has(entry?.context));
220
+ if (present.length === 0) return null;
221
+ return present.every((entry) => {
222
+ const conclusion = String(entry?.conclusion ?? "").toUpperCase();
223
+ const state = String(entry?.state ?? "").toUpperCase();
224
+ return conclusion === "SUCCESS" || state === "SUCCESS";
225
+ });
226
+ }
227
+
201
228
  export function resolveLinkedIssueFromPr(prData) {
202
229
  if (!prData || typeof prData !== "object") return null;
203
230
  const closing = Array.isArray(prData.closingIssuesReferences) ? prData.closingIssuesReferences : [];
@@ -439,6 +466,9 @@ export async function detectPrGateCoordinationState(options, runtime = {}) {
439
466
  mergeStateStatus: context.mergeStateStatus,
440
467
  mergeable: context.mergeable,
441
468
  conflictFiles: context.conflictFiles,
469
+ // UI e2e auto-scoping (#976): path-triggered + fail-closed precondition.
470
+ changedFiles: extractChangedFiles(context.prData),
471
+ uiE2ePassed: deriveUiE2ePassed(context.prData),
442
472
  lifecycleState: context.interpretation.state,
443
473
  loopDisposition: context.disposition.loopDisposition,
444
474
  ciStatus: context.snapshot?.ciStatus ?? null,
@@ -32,11 +32,16 @@ export const CHECKPOINT_ACTION_TO_CONDUCTOR_ACTION = Object.freeze({
32
32
  [PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY]: "merge",
33
33
  [PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]: "await_approval",
34
34
  [PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS]: "resolve_conflicts",
35
+ [PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE]: "run_ui_e2e",
35
36
  [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]: "blocked",
36
37
  [PR_CHECKPOINT_ACTION.REPORT_DONE]: "done",
37
38
  });
38
39
  export const ACTION_PRIORITY = Object.freeze({
39
40
  merge: 100,
41
+ // UI e2e auto-scoping fix work (#976): a path-triggered, fail-closed
42
+ // precondition — surface it ahead of feedback/draft work so the rendered
43
+ // artifact gets registered + covered before the gate proceeds.
44
+ run_ui_e2e: 95,
40
45
  fix_threads: 90,
41
46
  run_pre_approval: 80,
42
47
  draft_gate: 70,
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // Collapse the board's "In Progress" column to a SINGLE continue target (#988 P1).
3
+ //
4
+ // Pure list->single-target collapse with NO routing opinions: it lists the
5
+ // In-Progress items via list-queue-items.mjs and either returns the lone target
6
+ // or FAILS CLOSED. It never guesses among multiple active items.
7
+ //
8
+ // exactly one -> { ok: true, target: { kind: "issue"|"pr", number } }
9
+ // zero -> { ok: false, reason: "..." } (no in-progress item)
10
+ // multiple -> { ok: false, reason: "..." } (names the items)
11
+ //
12
+ // Downstream (the dev-loop skill) resolves authoritative state from this number;
13
+ // this helper deliberately makes no further decisions.
14
+ import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
15
+ import { parseArgs } from "node:util";
16
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
17
+ import { main as listQueueItems } from "./list-queue-items.mjs";
18
+
19
+ const IN_PROGRESS_COLUMN = "In Progress";
20
+
21
+ const USAGE = `Usage: dev-loops queue resolve-active --repo <owner/name> --project <number|id>
22
+
23
+ Collapse the board's "${IN_PROGRESS_COLUMN}" column to a single continue target.
24
+ Used by bare \`/continue\` to pick up the one in-progress item. Fails closed
25
+ (no guessing) when the board has zero or more than one in-progress item.
26
+
27
+ Options:
28
+ --repo <owner/name> Required. Repository to scope the project search.
29
+ --project <number|id> Required. Project number (integer) or node ID.
30
+ --help, -h Show this help.
31
+
32
+ Output (stdout):
33
+ JSON, exactly one in-progress item:
34
+ { ok: true, target: { kind: "issue"|"pr", number } }
35
+ JSON, zero or multiple (fail closed):
36
+ { ok: false, reason: "..." }
37
+
38
+ ${JQ_OUTPUT_USAGE}
39
+
40
+ Exit codes (default / unfiltered output):
41
+ 0 — exactly one in-progress item resolved
42
+ 1 — usage or argument error
43
+ 2 — GitHub API error / invalid --jq filter
44
+ 3 — fail closed (pass an explicit issue/PR): zero or multiple in-progress
45
+ items, or the board/project could not be resolved (project, status
46
+ field, or "${IN_PROGRESS_COLUMN}" column not found)
47
+
48
+ With --jq/--silent the result is filtered to a value/predicate, so the exit code
49
+ follows the shared jq-output contract (0 = truthy/ok, 1 = falsy/non-ok, 2 =
50
+ invalid filter) — fail closed surfaces as a falsy \`.ok\`, i.e. exit 1, not 3.
51
+ `.trim();
52
+
53
+ function parseCliArgs(argv) {
54
+ const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
55
+ const requireValue = (token, message) => {
56
+ const v = token.value;
57
+ if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
58
+ throw parseError(message);
59
+ }
60
+ return v;
61
+ };
62
+
63
+ const args = {};
64
+ const { tokens } = parseArgs({
65
+ args: [...argv],
66
+ options: {
67
+ repo: { type: "string" },
68
+ project: { type: "string" },
69
+ help: { type: "boolean", short: "h" },
70
+ ...JQ_OUTPUT_PARSE_OPTIONS,
71
+ },
72
+ allowPositionals: true,
73
+ strict: false,
74
+ tokens: true,
75
+ });
76
+
77
+ for (const token of tokens) {
78
+ if (token.kind === "positional") {
79
+ throw parseError(`Unexpected argument: ${token.value}`);
80
+ }
81
+ if (token.kind !== "option") {
82
+ continue;
83
+ }
84
+ switch (token.name) {
85
+ case "help":
86
+ if (token.value !== undefined) {
87
+ throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
88
+ }
89
+ args.help = true;
90
+ break;
91
+ case "repo":
92
+ args.repo = requireValue(token, "--repo requires a value (owner/name)");
93
+ break;
94
+ case "project":
95
+ args.project = requireValue(token, "--project requires a value (number or node ID)");
96
+ break;
97
+ case "jq":
98
+ args.jq = requireValue(token, "--jq requires a filter");
99
+ break;
100
+ case "silent":
101
+ args.silent = true;
102
+ break;
103
+ default:
104
+ throw parseError(`Unknown flag: ${token.rawName}`);
105
+ }
106
+ }
107
+ return args;
108
+ }
109
+
110
+ function describeItem(item) {
111
+ const ref = item.prNumber != null ? `PR #${item.prNumber}` : `issue #${item.issueNumber}`;
112
+ return item.title ? `${ref} (${item.title})` : ref;
113
+ }
114
+
115
+ // Collapse a list of board items to a single continue target. Prefer the linked
116
+ // PR number when present (the canonical artifact once work is in flight), else
117
+ // the issue. No routing opinion beyond that single pick.
118
+ function collapseToTarget(items) {
119
+ if (items.length === 0) {
120
+ return {
121
+ ok: false,
122
+ reason: `No in-progress board item to continue. Pass an explicit issue/PR, e.g. \`/continue #N\`.`,
123
+ };
124
+ }
125
+ if (items.length > 1) {
126
+ const listed = items.map(describeItem).join(", ");
127
+ return {
128
+ ok: false,
129
+ reason: `${items.length} in-progress board items: ${listed}. Pass an explicit issue/PR to disambiguate, e.g. \`/continue #N\`.`,
130
+ };
131
+ }
132
+ const item = items[0];
133
+ const target = item.prNumber != null
134
+ ? { kind: "pr", number: item.prNumber }
135
+ : { kind: "issue", number: item.issueNumber };
136
+ return { ok: true, target };
137
+ }
138
+
139
+ async function main(args, { env = process.env, runChild } = {}) {
140
+ const listed = await listQueueItems(
141
+ { repo: args.repo, project: args.project, column: IN_PROGRESS_COLUMN },
142
+ { env, runChild },
143
+ );
144
+ return collapseToTarget(listed.items ?? []);
145
+ }
146
+
147
+ function classifyExitCode(err) {
148
+ if (err.code === "INVALID_ARGS" || err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT") return 1;
149
+ if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND") return 3;
150
+ return 2;
151
+ }
152
+
153
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, runChild } = {}) {
154
+ let args;
155
+ try {
156
+ args = parseCliArgs(argv);
157
+ } catch (err) {
158
+ stderr.write(`${formatCliError(err)}\n`);
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ if (args.help) {
163
+ stdout.write(USAGE);
164
+ return;
165
+ }
166
+ try {
167
+ const result = await main(args, { env, runChild });
168
+ // Fail closed (zero/multiple) is a clean, expected outcome — distinct exit code 3,
169
+ // not a crash; --jq/--silent still apply so callers can probe `.ok`.
170
+ process.exitCode = emitResult(result, {
171
+ jq: args.jq,
172
+ silent: args.silent,
173
+ stdout,
174
+ stderr,
175
+ ok: result.ok,
176
+ });
177
+ if (result.ok !== true && args.jq === undefined && !args.silent) {
178
+ process.exitCode = 3;
179
+ }
180
+ } catch (err) {
181
+ stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
182
+ process.exitCode = classifyExitCode(err);
183
+ }
184
+ }
185
+
186
+ if (isDirectCliRun(import.meta.url)) {
187
+ runCli(process.argv.slice(2)).catch((error) => {
188
+ process.stderr.write(JSON.stringify({ ok: false, error: error.message, code: error.code ?? "UNKNOWN" }) + "\n");
189
+ process.exitCode = 2;
190
+ });
191
+ }
192
+
193
+ export { main, collapseToTarget, runCli };
@@ -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
+ }