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.
- package/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +11 -6
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +20 -1
- package/agents/dev-loop.agent.md +8 -1
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +5 -2
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +31 -1
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +14 -2
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- 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
|
+
}
|
|
@@ -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
|
+
}
|