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
@@ -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
@@ -25,10 +25,14 @@
25
25
  "test": "npm run test:assets && npm run test:extension && npm run test:scripts && npm run test:core && npm run test:docs",
26
26
  "test:assets": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/dev-loop-init-phase-smoke.test.mjs test/contracts/*.test.mjs test/workflow-handoff-contract.test.mjs",
27
27
  "test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/extension-pi-adapter.test.mjs test/extension-claude-adapter.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
28
- "test:scripts": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs",
28
+ "test:scripts": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs test/pages/*.test.mjs",
29
29
  "test:core": "node --test --test-reporter ./test/failure-summary-reporter.mjs packages/core/test/*.test.mjs",
30
30
  "test:dev-loop": "node --test --test-reporter ./test/failure-summary-reporter.mjs skills/dev-loop/scripts/dev-mode-context.test.mjs skills/dev-loop/scripts/render-template.test.mjs skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs",
31
31
  "test:playwright:viewer": "playwright test -c playwright.inspect-run-viewer.config.mjs",
32
+ "test:playwright:deep-dive": "playwright test -c playwright.dev-loops-deep-dive.config.mjs",
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",
32
36
  "smoke:headless": "node scripts/claude/headless-info-smoke.mjs",
33
37
  "test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-no-duplicate-rules.mjs",
34
38
  "repo-wiki": "node scripts/repo-wiki.mjs",
@@ -68,7 +72,7 @@
68
72
  },
69
73
  "dependencies": {
70
74
  "mermaid": "11.15.0",
71
- "@dev-loops/core": "^0.4.0"
75
+ "@dev-loops/core": "^0.6.0"
72
76
  },
73
77
  "repository": {
74
78
  "type": "git",
@@ -78,7 +82,7 @@
78
82
  "url": "https://github.com/mfittko/dev-loops/issues"
79
83
  },
80
84
  "homepage": "https://github.com/mfittko/dev-loops#readme",
81
- "version": "0.4.0",
85
+ "version": "0.6.0",
82
86
  "files": [
83
87
  "cli/",
84
88
  "lib/",
@@ -89,6 +93,7 @@
89
93
  ".claude-plugin/",
90
94
  ".claude/.claude-plugin/",
91
95
  ".claude/agents/",
96
+ ".claude/commands/",
92
97
  ".claude/skills/",
93
98
  ".claude/hooks/",
94
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
@@ -11,6 +11,7 @@ import {
11
11
  import { parseArgs } from "node:util";
12
12
  import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
13
13
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
14
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
14
15
  export const REVIEW_THREADS_QUERY = [
15
16
  "query($owner: String!, $name: String!, $pr: Int!) {",
16
17
  " repository(owner: $owner, name: $name) {",
@@ -45,9 +46,11 @@ Modes:
45
46
  Options:
46
47
  --output <path> Write JSON output to file in addition to stdout
47
48
  --help, -h Show this help
49
+ ${JQ_OUTPUT_USAGE}
48
50
  Exit codes:
49
51
  0 Success
50
52
  1 Error
53
+ 2 Invalid --jq filter
51
54
  `;
52
55
  export function parseCaptureCliArgs(argv) {
53
56
  const { tokens } = parseArgs({
@@ -58,6 +61,7 @@ export function parseCaptureCliArgs(argv) {
58
61
  output: { type: "string" },
59
62
  repo: { type: "string" },
60
63
  pr: { type: "string" },
64
+ ...JQ_OUTPUT_PARSE_OPTIONS,
61
65
  },
62
66
  allowPositionals: true,
63
67
  strict: false,
@@ -69,6 +73,8 @@ export function parseCaptureCliArgs(argv) {
69
73
  repo: undefined,
70
74
  pr: undefined,
71
75
  help: false,
76
+ jq: undefined,
77
+ silent: false,
72
78
  };
73
79
  for (const token of tokens) {
74
80
  if (token.kind === "positional") {
@@ -97,6 +103,14 @@ export function parseCaptureCliArgs(argv) {
97
103
  options.pr = parsePrNumber(requireTokenValue(token));
98
104
  continue;
99
105
  }
106
+ if (token.name === "jq") {
107
+ options.jq = requireTokenValue(token);
108
+ continue;
109
+ }
110
+ if (token.name === "silent") {
111
+ options.silent = true;
112
+ continue;
113
+ }
100
114
  throw new Error(`Unknown argument: ${token.rawName}`);
101
115
  }
102
116
  const hasLiveArgs = options.repo !== undefined || options.pr !== undefined;
@@ -188,10 +202,14 @@ export async function runCli(
188
202
  if (options.outputPath) {
189
203
  await writeOutputFile(options.outputPath, payload);
190
204
  }
191
- stdout.write(`${JSON.stringify(payload)}\n`);
205
+ return emitResult(payload, { jq: options.jq, silent: options.silent, stdout });
192
206
  }
193
207
  if (isDirectCliRun(import.meta.url)) {
194
- runCli().catch((error) => {
208
+ runCli().then((code) => {
209
+ if (typeof code === "number") {
210
+ process.exitCode = code;
211
+ }
212
+ }).catch((error) => {
195
213
  process.stderr.write(`${formatCliError(error)}\n`);
196
214
  process.exitCode = 1;
197
215
  });
@@ -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
+ }