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
@@ -9,6 +9,19 @@ const LOCAL_READINESS_IDS = ['subagent-command', 'git-repo'];
9
9
  const REMOTE_READINESS_IDS = ['gh-installed', 'gh-auth', 'subagent-command', 'git-repo'];
10
10
  const INSPECT_ACTIONS = new Set(['open', 'resume', 'status', 'stop', 'restart']);
11
11
 
12
+ // Direct dev-loop entrypoints (#972): thin named wrappers over the public dev-loop contract.
13
+ // Each maps `<verb> <issue|pr>` to the canonical public-intent shorthand the `dev-loop` skill
14
+ // already accepts — no new routing/strategy logic lives here. `start`/`auto` target an issue,
15
+ // `continue` (#988) targets an issue OR a PR (the resolver picks the canonical artifact) and
16
+ // also accepts a bare form (no number) that continues the current in-progress board item;
17
+ // `info` is the read-only state shortcut for an issue or PR.
18
+ const ENTRYPOINT_VERBS = {
19
+ start: { target: 'issue', phrase: (n) => `start dev loop on issue ${n}` },
20
+ auto: { target: 'issue', phrase: (n) => `auto dev loop on issue ${n}` },
21
+ continue: { target: 'either', allowBare: true, phrase: (n) => (n ? `continue dev loop on ${n}` : 'continue the current dev loop') },
22
+ info: { target: 'either', phrase: (n) => `inspect dev loop state on ${n}` },
23
+ };
24
+
12
25
  const UNICODE_SPACE_RE = /[\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g;
13
26
 
14
27
  function normalizeInput(input) {
@@ -88,6 +101,100 @@ function parseInspectCommand(tokens, { surface }) {
88
101
  };
89
102
  }
90
103
 
104
+ // Normalize a single entrypoint target token to a bare issue/PR number.
105
+ // Accepts `123`, `#123`, or a GitHub issue/PR URL (.../issues/123 or .../pull/123).
106
+ // Returns the numeric string, or null when it is not a recognized target.
107
+ function normalizeTargetNumber(raw) {
108
+ const token = String(raw).trim();
109
+ if (/^#?\d+$/.test(token)) {
110
+ return token.replace(/^#/, '');
111
+ }
112
+ const urlMatch = token.match(/^https?:\/\/[^\s]*\/(?:issues|pull)\/(\d+)(?:[/?#].*)?$/);
113
+ if (urlMatch) {
114
+ return urlMatch[1];
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // `start-spike` is a SIBLING of the numeric verbs (start/auto/continue/info): it
120
+ // takes FREE TEXT (a question) or `--file <path>`, NOT a numeric target, so it is
121
+ // parsed on its own path to keep the numeric-validation invariant of the other
122
+ // verbs intact (#988 P2). It is a thin wrapper over the shipped `--spike` intake:
123
+ // the inline-question form scaffolds a startable findings artifact, then both
124
+ // forms hand the resolved spike path to `loop startup --spike <path>`. No new
125
+ // spike behavior lives here — see skills/docs/spike-mode-contract.md.
126
+ function parseStartSpikeCommand(rest, tokens) {
127
+ const positional = rest.filter((t) => t !== undefined);
128
+ // `--file <path>`: start from a pre-authored spike artifact (no scaffolding).
129
+ if (positional[0] === '--file') {
130
+ const file = positional[1];
131
+ // Reject any leading `-`: the path is forwarded to `resolve-dev-loop-startup
132
+ // --spike <path>`, so a value like `-x` could be read as an option (option
133
+ // injection). Fail closed. The free-text question path below is unaffected.
134
+ if (typeof file !== 'string' || file.length === 0 || file.startsWith('-') || positional.length !== 2) {
135
+ return invalidCommand('`start-spike --file` requires exactly one `<path>`.', 'start-spike', tokens);
136
+ }
137
+ return {
138
+ kind: 'start_spike',
139
+ mode: 'file',
140
+ file,
141
+ question: null,
142
+ intent: `start a dev-loop spike from ${file}`,
143
+ tokens,
144
+ };
145
+ }
146
+ // Inline free-text question. Joined verbatim so multi-word questions survive.
147
+ const question = positional.join(' ').trim();
148
+ if (question.length === 0) {
149
+ return invalidCommand('`start-spike` requires a question (or `--file <path>`).', 'start-spike', tokens);
150
+ }
151
+ return {
152
+ kind: 'start_spike',
153
+ mode: 'question',
154
+ file: null,
155
+ question,
156
+ intent: `start a dev-loop spike on the question: ${question}`,
157
+ tokens,
158
+ };
159
+ }
160
+
161
+ function parseEntrypointCommand(action, args, tokens) {
162
+ const spec = ENTRYPOINT_VERBS[action];
163
+ const positional = args.filter((a) => a !== undefined);
164
+ const targetHint = spec.target === 'pr' ? '<pr>' : spec.target === 'either' ? '<issue|pr>' : '<issue>';
165
+ const targetNoun = spec.target === 'pr' ? 'PR' : spec.target === 'either' ? 'issue/PR' : 'issue';
166
+
167
+ // Bare form (no target): only verbs that opt in (e.g. `continue` resumes the
168
+ // current in-progress board item). The command/skill does the board resolve.
169
+ if (positional.length === 0 && spec.allowBare) {
170
+ return {
171
+ kind: 'entrypoint',
172
+ action,
173
+ target: spec.target,
174
+ number: null,
175
+ intent: spec.phrase(null),
176
+ tokens,
177
+ };
178
+ }
179
+
180
+ if (positional.length !== 1) {
181
+ const arity = spec.allowBare ? `at most one ${targetHint}` : `exactly one ${targetHint}`;
182
+ return invalidCommand(`\`${action}\` requires ${arity} argument.`, action, tokens);
183
+ }
184
+ const number = normalizeTargetNumber(positional[0]);
185
+ if (number === null) {
186
+ return invalidCommand(`\`${action}\` expects a numeric ${targetNoun}, got: ${positional[0]}.`, action, tokens);
187
+ }
188
+ return {
189
+ kind: 'entrypoint',
190
+ action,
191
+ target: spec.target,
192
+ number,
193
+ intent: spec.phrase(number),
194
+ tokens,
195
+ };
196
+ }
197
+
91
198
  export function parseDevLoopsCommand(input, { surface = 'extension' } = {}) {
92
199
  const tokens = normalizeInput(input);
93
200
  const [rawAction, rawScope, ...rest] = tokens;
@@ -98,6 +205,15 @@ export function parseDevLoopsCommand(input, { surface = 'extension' } = {}) {
98
205
  return parseInspectCommand(tokens, { surface });
99
206
  }
100
207
 
208
+ // `start-spike` is free-text/path, not a numeric verb — parsed on its own path.
209
+ if (action === 'start-spike') {
210
+ return parseStartSpikeCommand([rawScope, ...rest], tokens);
211
+ }
212
+
213
+ if (action && Object.prototype.hasOwnProperty.call(ENTRYPOINT_VERBS, action)) {
214
+ return parseEntrypointCommand(action, [rawScope, ...rest], tokens);
215
+ }
216
+
101
217
  switch (action) {
102
218
  case undefined:
103
219
  case '':
@@ -255,6 +371,31 @@ export async function executeDevLoopsCommand({ input, surface = 'extension', run
255
371
  }
256
372
  }
257
373
 
374
+ if (parsed.kind === 'start_spike') {
375
+ // Thin wrapper: surface the spike intent so the operator dispatches it through
376
+ // the dev-loop skill, which scaffolds (inline question) or uses the given file,
377
+ // then runs `loop startup --spike <path>`. No spike behavior is decided here.
378
+ return {
379
+ kind: 'start_spike',
380
+ mode: parsed.mode,
381
+ file: parsed.file,
382
+ question: parsed.question,
383
+ intent: parsed.intent,
384
+ };
385
+ }
386
+
387
+ if (parsed.kind === 'entrypoint') {
388
+ // Thin wrapper: surface the canonical public intent so the user dispatches it through the
389
+ // `dev-loop` skill (the single public router). No routing/strategy decision is made here.
390
+ return {
391
+ kind: 'entrypoint',
392
+ action: parsed.action,
393
+ target: parsed.target,
394
+ number: parsed.number,
395
+ intent: parsed.intent,
396
+ };
397
+ }
398
+
258
399
  if (parsed.kind !== 'action') {
259
400
  return parsed;
260
401
  }
package/package.json CHANGED
@@ -31,6 +31,8 @@
31
31
  "test:playwright:viewer": "playwright test -c playwright.inspect-run-viewer.config.mjs",
32
32
  "test:playwright:deep-dive": "playwright test -c playwright.dev-loops-deep-dive.config.mjs",
33
33
  "test:playwright:intro-deck": "playwright test -c playwright.intro-deck.config.mjs",
34
+ "test:playwright:intro-article": "playwright test -c playwright.intro-article.config.mjs",
35
+ "test:playwright:deep-dive-article": "playwright test -c playwright.deep-dive-article.config.mjs",
34
36
  "smoke:headless": "node scripts/claude/headless-info-smoke.mjs",
35
37
  "test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-no-duplicate-rules.mjs",
36
38
  "repo-wiki": "node scripts/repo-wiki.mjs",
@@ -70,7 +72,7 @@
70
72
  },
71
73
  "dependencies": {
72
74
  "mermaid": "11.15.0",
73
- "@dev-loops/core": "^0.5.0"
75
+ "@dev-loops/core": "^0.6.0"
74
76
  },
75
77
  "repository": {
76
78
  "type": "git",
@@ -80,7 +82,7 @@
80
82
  "url": "https://github.com/mfittko/dev-loops/issues"
81
83
  },
82
84
  "homepage": "https://github.com/mfittko/dev-loops#readme",
83
- "version": "0.5.0",
85
+ "version": "0.6.0",
84
86
  "files": [
85
87
  "cli/",
86
88
  "lib/",
@@ -91,6 +93,7 @@
91
93
  ".claude-plugin/",
92
94
  ".claude/.claude-plugin/",
93
95
  ".claude/agents/",
96
+ ".claude/commands/",
94
97
  ".claude/skills/",
95
98
  ".claude/hooks/",
96
99
  "README.md",
@@ -14,7 +14,7 @@ import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
 
17
- import { transformAgent, transformSkill, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
17
+ import { transformAgent, transformSkill, transformCommand, stripPiOnlyBlocks } from "@dev-loops/core/claude/asset-generation";
18
18
 
19
19
  /**
20
20
  * Collect the generated assets as { target, content } pairs (target is repo-relative).
@@ -45,6 +45,19 @@ export function collectGeneratedAssets({ repoRoot = process.cwd() } = {}) {
45
45
  }
46
46
  }
47
47
 
48
+ // Direct slash commands (#972): thin generated wrappers over the public dev-loop contract,
49
+ // one `.claude/commands/<name>.md` per `commands/<name>.command.md` source. No routing logic.
50
+ const commandsDir = path.join(repoRoot, "commands");
51
+ if (fs.existsSync(commandsDir)) {
52
+ for (const entry of fs.readdirSync(commandsDir).sort()) {
53
+ if (!entry.endsWith(".command.md")) continue;
54
+ const source = `commands/${entry}`;
55
+ const raw = fs.readFileSync(path.join(repoRoot, source), "utf8");
56
+ const base = entry.slice(0, -".command.md".length);
57
+ assets.push({ target: `.claude/commands/${base}.md`, content: transformCommand({ source, raw, version }) });
58
+ }
59
+ }
60
+
48
61
  const skillsDir = path.join(repoRoot, "skills");
49
62
  if (fs.existsSync(skillsDir)) {
50
63
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
@@ -171,6 +184,7 @@ function listFilesRecursive(repoRoot, rel) {
171
184
  function listExistingAssetFiles(repoRoot) {
172
185
  const files = [
173
186
  ...listFilesRecursive(repoRoot, ".claude/agents"),
187
+ ...listFilesRecursive(repoRoot, ".claude/commands"),
174
188
  ...listFilesRecursive(repoRoot, ".claude/skills"),
175
189
  ];
176
190
  // `.claude/hooks/` mixes hand-authored scripts (hooks.json, _hook-io.mjs, the three hook
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
4
+ import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { parseArgs } from "node:util";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
8
+
9
+ const USAGE = `Usage: comment-issue.mjs --repo <owner/name> --issue <number> (--body <text> | --body-file <path>)
10
+ Post a comment on a GitHub issue. Thin wrapper over \`gh issue comment\` — use this
11
+ instead of an agent-level raw \`gh issue comment\` so the loop's internal-tooling
12
+ record stays clean (#993; the read-side siblings are list-issues.mjs / fetch-ci-logs.mjs).
13
+ Required:
14
+ --repo <owner/name> Repository slug (e.g. owner/repo)
15
+ --issue <number> Issue number to comment on
16
+ --body <text> Comment body as a single argument
17
+ --body-file <path> Read the comment body from a file (preserves
18
+ newlines; alternative to --body; - reads stdin)
19
+ Output (stdout, JSON):
20
+ { "ok": true, "repo": "owner/repo", "issue": 17, "commentUrl": "https://github.com/owner/repo/issues/17#issuecomment-123" }
21
+ Error output (stderr, JSON):
22
+ { "ok": false, "error": "...", "usage"?: "..." }
23
+ ${JQ_OUTPUT_USAGE}
24
+ Exit codes:
25
+ 0 Success
26
+ 1 Argument error or gh failure
27
+ 2 Invalid --jq filter`.trim();
28
+ const parseError = buildParseError(USAGE);
29
+
30
+ export function parseCommentIssueCliArgs(argv) {
31
+ const { tokens } = parseArgs({
32
+ args: [...argv],
33
+ options: {
34
+ help: { type: "boolean", short: "h" },
35
+ repo: { type: "string" },
36
+ issue: { type: "string" },
37
+ body: { type: "string" },
38
+ "body-file": { type: "string" },
39
+ ...JQ_OUTPUT_PARSE_OPTIONS,
40
+ },
41
+ allowPositionals: true,
42
+ strict: false,
43
+ tokens: true,
44
+ });
45
+ const options = {
46
+ help: false,
47
+ repo: undefined,
48
+ issue: undefined,
49
+ body: undefined,
50
+ bodyFile: undefined,
51
+ jq: undefined,
52
+ silent: false,
53
+ };
54
+ for (const token of tokens) {
55
+ if (token.kind === "positional") {
56
+ throw parseError(`Unknown argument: ${token.value}`);
57
+ }
58
+ if (token.kind !== "option") {
59
+ continue;
60
+ }
61
+ if (token.name === "help") {
62
+ options.help = true;
63
+ return options;
64
+ }
65
+ if (token.name === "repo") {
66
+ options.repo = requireTokenValue(token, parseError).trim();
67
+ continue;
68
+ }
69
+ if (token.name === "issue") {
70
+ options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
71
+ continue;
72
+ }
73
+ if (token.name === "body") {
74
+ options.body = requireTokenValue(token, parseError);
75
+ continue;
76
+ }
77
+ if (token.name === "body-file") {
78
+ const rawPath = requireTokenValue(token, parseError).trim();
79
+ if (rawPath.length === 0) {
80
+ throw parseError("--body-file must be a non-empty path");
81
+ }
82
+ options.bodyFile = rawPath;
83
+ continue;
84
+ }
85
+ if (token.name === "jq") {
86
+ options.jq = requireTokenValue(token, parseError);
87
+ continue;
88
+ }
89
+ if (token.name === "silent") {
90
+ options.silent = true;
91
+ continue;
92
+ }
93
+ throw parseError(`Unknown argument: ${token.rawName}`);
94
+ }
95
+ if (options.repo === undefined || options.issue === undefined) {
96
+ throw parseError("Commenting on an issue requires both --repo <owner/name> and --issue <number>");
97
+ }
98
+ if (options.body === undefined && options.bodyFile === undefined) {
99
+ throw parseError("Commenting on an issue requires --body <text> or --body-file <path>");
100
+ }
101
+ if (options.body !== undefined && options.bodyFile !== undefined) {
102
+ throw parseError("--body and --body-file are mutually exclusive; pass only one");
103
+ }
104
+ try {
105
+ parseRepoSlug(options.repo);
106
+ } catch (error) {
107
+ throw parseError(error instanceof Error ? error.message : String(error));
108
+ }
109
+ return options;
110
+ }
111
+
112
+ async function resolveBody(options) {
113
+ if (options.bodyFile === undefined) {
114
+ if (options.body.trim().length === 0) {
115
+ throw new Error("--body must not be empty");
116
+ }
117
+ return options.body;
118
+ }
119
+ const source = options.bodyFile === "-" ? 0 : options.bodyFile;
120
+ const body = await readFile(source, "utf8");
121
+ if (body.trim().length === 0) {
122
+ throw new Error(`--body-file ${options.bodyFile} is empty`);
123
+ }
124
+ return body;
125
+ }
126
+
127
+ // Post the comment via `gh issue comment`, then read its URL back from
128
+ // `gh issue comment` output. `gh issue comment` prints the new comment URL on
129
+ // success — capture it so callers don't need a follow-up read.
130
+ export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
131
+ const body = await resolveBody(options);
132
+ const result = await run(
133
+ ghCommand,
134
+ ["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
135
+ env,
136
+ );
137
+ if (result.code !== 0) {
138
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
139
+ throw new Error(`gh issue comment failed: ${detail}`);
140
+ }
141
+ // `gh issue comment` prints the created comment's URL (the last non-empty line
142
+ // of stdout). Surface it so the caller has the comment URL without a re-read.
143
+ const commentUrl = result.stdout
144
+ .split(/\r?\n/u)
145
+ .map((line) => line.trim())
146
+ .filter((line) => line.length > 0)
147
+ .pop() ?? null;
148
+ if (commentUrl === null || !/^https?:\/\//u.test(commentUrl)) {
149
+ throw new Error(`gh issue comment did not return a comment URL (got: ${result.stdout.trim() || "<empty>"})`);
150
+ }
151
+ return { ok: true, repo: options.repo, issue: options.issue, commentUrl };
152
+ }
153
+
154
+ export async function runCli(
155
+ argv = process.argv.slice(2),
156
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
157
+ ) {
158
+ let options;
159
+ try {
160
+ options = parseCommentIssueCliArgs(argv);
161
+ } catch (error) {
162
+ stderr.write(`${formatCliError(error)}\n`);
163
+ return 1;
164
+ }
165
+ if (options.help) {
166
+ stdout.write(`${USAGE}\n`);
167
+ return 0;
168
+ }
169
+ let result;
170
+ try {
171
+ result = await commentIssue(options, { env, ghCommand, run });
172
+ } catch (error) {
173
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
174
+ return 1;
175
+ }
176
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
177
+ }
178
+
179
+ if (isDirectCliRun(import.meta.url)) {
180
+ runCli().then((code) => { process.exitCode = code; });
181
+ }
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { parsePrNumber, 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 DEFAULT_TAIL_LINES = 200;
9
+
10
+ const USAGE = `Usage: fetch-ci-logs.mjs --repo <owner/name> --pr <number> [--failed-only] [--tail <n>]
11
+ Fetch the GitHub Actions CI run log tail for a PR's current head SHA. Thin wrapper
12
+ over \`gh run list\` + \`gh run view --log\` — use this instead of an agent-level raw
13
+ \`gh run view\` so the loop's internal-tooling record stays clean (#993). Complements
14
+ probe-ci-status.mjs, which reports failed-check NAMES; this returns the LOG tail.
15
+ (Siblings: list-issues.mjs, comment-issue.mjs.)
16
+ Required:
17
+ --repo <owner/name> Repository slug (e.g. owner/repo)
18
+ --pr <number> Pull request number
19
+ Optional:
20
+ --failed-only Only include runs whose conclusion is failure
21
+ (default: all Actions runs for the head SHA)
22
+ --tail <n> Lines of log tail to return per run (default 200)
23
+ Output (stdout, JSON):
24
+ { "ok": true, "repo": "owner/repo", "pr": 17, "headSha": "abc123",
25
+ "runs": [{ "runId": 42, "name": "ci", "conclusion": "failure", "logTail": "..." }] }
26
+ Notes:
27
+ Actions-only (gh run is GitHub Actions). CircleCI / external commit-status logs
28
+ are NOT covered — use the provider's UI for those (probe-ci-status names the check).
29
+ Error output (stderr, JSON):
30
+ { "ok": false, "error": "...", "usage"?: "..." }
31
+ ${JQ_OUTPUT_USAGE}
32
+ Exit codes:
33
+ 0 Success
34
+ 1 Argument error or gh failure
35
+ 2 Invalid --jq filter`.trim();
36
+ const parseError = buildParseError(USAGE);
37
+
38
+ export function parseFetchCiLogsCliArgs(argv) {
39
+ const { tokens } = parseArgs({
40
+ args: [...argv],
41
+ options: {
42
+ help: { type: "boolean", short: "h" },
43
+ repo: { type: "string" },
44
+ pr: { type: "string" },
45
+ "failed-only": { type: "boolean" },
46
+ tail: { type: "string" },
47
+ ...JQ_OUTPUT_PARSE_OPTIONS,
48
+ },
49
+ allowPositionals: true,
50
+ strict: false,
51
+ tokens: true,
52
+ });
53
+ const options = {
54
+ help: false,
55
+ repo: undefined,
56
+ pr: undefined,
57
+ failedOnly: false,
58
+ tail: DEFAULT_TAIL_LINES,
59
+ jq: undefined,
60
+ silent: false,
61
+ };
62
+ for (const token of tokens) {
63
+ if (token.kind === "positional") {
64
+ throw parseError(`Unknown argument: ${token.value}`);
65
+ }
66
+ if (token.kind !== "option") {
67
+ continue;
68
+ }
69
+ if (token.name === "help") {
70
+ options.help = true;
71
+ return options;
72
+ }
73
+ if (token.name === "repo") {
74
+ options.repo = requireTokenValue(token, parseError).trim();
75
+ continue;
76
+ }
77
+ if (token.name === "pr") {
78
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
79
+ continue;
80
+ }
81
+ if (token.name === "failed-only") {
82
+ options.failedOnly = true;
83
+ continue;
84
+ }
85
+ if (token.name === "tail") {
86
+ const raw = requireTokenValue(token, parseError);
87
+ const value = Number(raw);
88
+ if (!Number.isInteger(value) || value < 1) {
89
+ throw parseError(`--tail must be a positive integer, got "${raw}"`);
90
+ }
91
+ options.tail = value;
92
+ continue;
93
+ }
94
+ if (token.name === "jq") {
95
+ options.jq = requireTokenValue(token, parseError);
96
+ continue;
97
+ }
98
+ if (token.name === "silent") {
99
+ options.silent = true;
100
+ continue;
101
+ }
102
+ throw parseError(`Unknown argument: ${token.rawName}`);
103
+ }
104
+ if (options.repo === undefined || options.pr === undefined) {
105
+ throw parseError("Fetching CI logs requires both --repo <owner/name> and --pr <number>");
106
+ }
107
+ try {
108
+ parseRepoSlug(options.repo);
109
+ } catch (error) {
110
+ throw parseError(error instanceof Error ? error.message : String(error));
111
+ }
112
+ return options;
113
+ }
114
+
115
+ async function ghJson(run, ghCommand, args, env, label) {
116
+ const result = await run(ghCommand, args, env);
117
+ if (result.code !== 0) {
118
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
119
+ throw new Error(`${label} failed: ${detail}`);
120
+ }
121
+ return parseJsonText(result.stdout, { label });
122
+ }
123
+
124
+ function tailLines(text, n) {
125
+ const lines = String(text).replace(/\r?\n$/u, "").split(/\r?\n/u);
126
+ return lines.slice(Math.max(0, lines.length - n)).join("\n");
127
+ }
128
+
129
+ export async function fetchCiLogs(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
130
+ // 1. Resolve the PR's current head SHA — logs must be scoped to the head being
131
+ // evaluated, not a stale push.
132
+ const pr = await ghJson(
133
+ run,
134
+ ghCommand,
135
+ ["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"],
136
+ env,
137
+ "gh pr view",
138
+ );
139
+ const headSha = typeof pr.headRefOid === "string" ? pr.headRefOid.trim() : "";
140
+ if (headSha.length === 0) {
141
+ throw new Error("gh pr view did not return headRefOid");
142
+ }
143
+
144
+ // 2. List Actions runs for that exact commit.
145
+ const runs = await ghJson(
146
+ run,
147
+ ghCommand,
148
+ ["run", "list", "--repo", options.repo, "--commit", headSha, "--json", "databaseId,name,conclusion,status"],
149
+ env,
150
+ "gh run list",
151
+ );
152
+ if (!Array.isArray(runs)) {
153
+ throw new Error("gh run list did not return a JSON array");
154
+ }
155
+ const selected = options.failedOnly
156
+ ? runs.filter((r) => String(r?.conclusion).toLowerCase() === "failure")
157
+ : runs;
158
+
159
+ // 3. Fetch each run's log tail. --log-failed restricts to failed steps when the
160
+ // run failed; for non-failed runs it returns nothing, so fall back to --log.
161
+ const out = [];
162
+ for (const r of selected) {
163
+ const runId = Number.isInteger(r?.databaseId) ? r.databaseId : null;
164
+ if (runId === null) continue;
165
+ const conclusion = typeof r?.conclusion === "string" ? r.conclusion.toLowerCase() : null;
166
+ const logFlag = conclusion === "failure" ? "--log-failed" : "--log";
167
+ const logResult = await run(
168
+ ghCommand,
169
+ ["run", "view", String(runId), "--repo", options.repo, logFlag],
170
+ env,
171
+ );
172
+ // A log fetch failure for one run shouldn't abort the others (logs expire);
173
+ // record an empty tail with a note rather than throwing.
174
+ const logTail =
175
+ logResult.code === 0
176
+ ? tailLines(logResult.stdout, options.tail)
177
+ : `<log unavailable: ${logResult.stderr.trim() || `exit ${logResult.code}`}>`;
178
+ out.push({
179
+ runId,
180
+ name: typeof r?.name === "string" ? r.name : null,
181
+ conclusion,
182
+ logTail,
183
+ });
184
+ }
185
+ return { ok: true, repo: options.repo, pr: options.pr, headSha, runs: out };
186
+ }
187
+
188
+ export async function runCli(
189
+ argv = process.argv.slice(2),
190
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
191
+ ) {
192
+ let options;
193
+ try {
194
+ options = parseFetchCiLogsCliArgs(argv);
195
+ } catch (error) {
196
+ stderr.write(`${formatCliError(error)}\n`);
197
+ return 1;
198
+ }
199
+ if (options.help) {
200
+ stdout.write(`${USAGE}\n`);
201
+ return 0;
202
+ }
203
+ let result;
204
+ try {
205
+ result = await fetchCiLogs(options, { env, ghCommand, run });
206
+ } catch (error) {
207
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
208
+ return 1;
209
+ }
210
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
211
+ }
212
+
213
+ if (isDirectCliRun(import.meta.url)) {
214
+ runCli().then((code) => { process.exitCode = code; });
215
+ }