dev-loops 0.5.0 → 0.7.1

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 (177) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/loop-auto.md +7 -0
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/loop-info.md +7 -0
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/loop-start-spike.md +16 -0
  11. package/.claude/commands/loop-start.md +7 -0
  12. package/.claude/commands/loop-status.md +6 -0
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/_run-context.mjs +11 -4
  16. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  17. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  18. package/.claude/skills/dev-loop/SKILL.md +19 -10
  19. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  20. package/.claude/skills/docs/anti-patterns.md +2 -1
  21. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  22. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  23. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  24. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  25. package/.claude/skills/docs/local-planning-flow.md +1 -1
  26. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  27. package/.claude/skills/docs/merge-preconditions.md +17 -1
  28. package/.claude/skills/docs/plan-file-contract.md +1 -1
  29. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  30. package/.claude/skills/docs/release-runbook.md +45 -0
  31. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  32. package/.claude/skills/docs/ui-e2e-scoping-step.md +134 -0
  33. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  34. package/.claude/skills/local-implementation/SKILL.md +26 -15
  35. package/.claude/skills/loop-grill/SKILL.md +163 -0
  36. package/AGENTS.md +1 -0
  37. package/CHANGELOG.md +125 -0
  38. package/README.md +29 -10
  39. package/agents/dev-loop.agent.md +11 -3
  40. package/agents/developer.agent.md +1 -1
  41. package/agents/docs.agent.md +1 -1
  42. package/agents/fixer.agent.md +1 -1
  43. package/agents/quality.agent.md +1 -1
  44. package/agents/refiner.agent.md +1 -1
  45. package/agents/review.agent.md +3 -2
  46. package/cli/index.mjs +35 -40
  47. package/extension/README.md +4 -4
  48. package/extension/checks.ts +1 -5
  49. package/extension/harness-types.ts +1 -0
  50. package/extension/index.ts +16 -1
  51. package/extension/pi-extension-adapter.ts +2 -0
  52. package/extension/presentation.ts +16 -13
  53. package/lib/dev-loops-core.mjs +141 -0
  54. package/package.json +10 -8
  55. package/scripts/claude/generate-claude-assets.mjs +15 -1
  56. package/scripts/github/capture-review-threads.mjs +2 -9
  57. package/scripts/github/comment-issue.mjs +174 -0
  58. package/scripts/github/create-label.mjs +133 -0
  59. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  60. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  61. package/scripts/github/edit-pr.mjs +259 -0
  62. package/scripts/github/fetch-ci-logs.mjs +208 -0
  63. package/scripts/github/list-issues.mjs +184 -0
  64. package/scripts/github/manage-sub-issues.mjs +46 -10
  65. package/scripts/github/offer-human-handoff.mjs +8 -5
  66. package/scripts/github/post-gate-findings.mjs +14 -2
  67. package/scripts/github/probe-ci-status.mjs +8 -2
  68. package/scripts/github/probe-copilot-review.mjs +21 -14
  69. package/scripts/github/ready-for-review.mjs +26 -8
  70. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  71. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  72. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  73. package/scripts/github/request-copilot-review.mjs +8 -2
  74. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  75. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  76. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  77. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  78. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  79. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  80. package/scripts/github/view-pr.mjs +150 -0
  81. package/scripts/github/write-gate-context.mjs +248 -61
  82. package/scripts/github/write-gate-findings-log.mjs +75 -1
  83. package/scripts/lib/jq-output.mjs +18 -0
  84. package/scripts/loop/_handoff-contract.mjs +1 -0
  85. package/scripts/loop/_post-convergence-change.mjs +211 -0
  86. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  87. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  88. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  89. package/scripts/loop/check-retro-tooling.mjs +14 -3
  90. package/scripts/loop/checkpoint-contract.mjs +7 -4
  91. package/scripts/loop/cleanup-worktree.mjs +12 -3
  92. package/scripts/loop/conductor-monitor.mjs +12 -18
  93. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  94. package/scripts/loop/debt-remediate.mjs +24 -12
  95. package/scripts/loop/detect-change-scope.mjs +36 -7
  96. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  97. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  98. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  99. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  100. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  101. package/scripts/loop/detect-pr-gate-coordination-state.mjs +179 -66
  102. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  103. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  104. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  105. package/scripts/loop/ensure-worktree.mjs +8 -3
  106. package/scripts/loop/info.mjs +12 -4
  107. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  108. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  109. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  110. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  111. package/scripts/loop/inspect-run.mjs +8 -2
  112. package/scripts/loop/outer-loop.mjs +8 -4
  113. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  114. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  115. package/scripts/loop/pre-flight-gate.mjs +9 -9
  116. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  117. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  118. package/scripts/loop/provision-worktree.mjs +74 -3
  119. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  120. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  121. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  122. package/scripts/loop/run-conductor-cycle.mjs +13 -2
  123. package/scripts/loop/run-queue.mjs +18 -9
  124. package/scripts/loop/run-refinement-audit.mjs +8 -9
  125. package/scripts/loop/run-watch-cycle.mjs +2 -9
  126. package/scripts/loop/sanctioned-commands.mjs +104 -0
  127. package/scripts/loop/steer-loop.mjs +29 -16
  128. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  129. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  130. package/scripts/pages/build-site.mjs +59 -8
  131. package/scripts/pages/build-state-atlas.mjs +441 -0
  132. package/scripts/projects/_resolve-project.mjs +148 -0
  133. package/scripts/projects/add-queue-item.mjs +60 -54
  134. package/scripts/projects/archive-done-items.mjs +49 -84
  135. package/scripts/projects/ensure-queue-board.mjs +10 -36
  136. package/scripts/projects/list-queue-items.mjs +116 -65
  137. package/scripts/projects/move-queue-item.mjs +28 -49
  138. package/scripts/projects/reconcile-queue.mjs +253 -0
  139. package/scripts/projects/reorder-queue-item.mjs +41 -47
  140. package/scripts/projects/resolve-active-board-item.mjs +255 -0
  141. package/scripts/projects/sync-item-status.mjs +15 -10
  142. package/scripts/refine/_refine-helpers.mjs +21 -5
  143. package/scripts/refine/exit-spike.mjs +18 -8
  144. package/scripts/refine/promote-plan.mjs +22 -16
  145. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  146. package/scripts/refine/refine-plan-file.mjs +13 -4
  147. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  148. package/scripts/refine/scaffold-spike-file.mjs +179 -0
  149. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  150. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  151. package/scripts/refine/validate-plan-file.mjs +1 -1
  152. package/scripts/refine/validate-spike-file.mjs +1 -1
  153. package/scripts/refine/verify.mjs +11 -6
  154. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  155. package/scripts/release/extract-changelog-section.mjs +125 -0
  156. package/skills/copilot-pr-followup/SKILL.md +24 -12
  157. package/skills/dev-loop/SKILL.md +22 -6
  158. package/skills/docs/acceptance-criteria-verification.md +6 -2
  159. package/skills/docs/anti-patterns.md +2 -1
  160. package/skills/docs/artifact-authority-contract.md +22 -4
  161. package/skills/docs/copilot-loop-operations.md +1 -1
  162. package/skills/docs/cross-harness-regression-contract.md +60 -0
  163. package/skills/docs/issue-intake-procedure.md +1 -0
  164. package/skills/docs/local-planning-flow.md +1 -1
  165. package/skills/docs/local-planning-worked-example.md +1 -1
  166. package/skills/docs/merge-preconditions.md +17 -1
  167. package/skills/docs/plan-file-contract.md +1 -1
  168. package/skills/docs/public-dev-loop-contract.md +1 -1
  169. package/skills/docs/release-runbook.md +45 -0
  170. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  171. package/skills/docs/ui-e2e-scoping-step.md +134 -0
  172. package/skills/docs/workflow-handoff-contract.md +39 -0
  173. package/skills/local-implementation/SKILL.md +26 -15
  174. package/skills/loop-grill/SKILL.md +165 -0
  175. package/scripts/loop/conductor.mjs +0 -233
  176. package/scripts/loop/detect-stale-runner.mjs +0 -265
  177. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
4
- import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseIssueNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
7
  export const LINKED_ISSUE_PR_QUERY = [
7
8
  "query($owner:String!, $name:String!, $issue:Int!, $after:String) {",
8
9
  " repository(owner:$owner, name:$name) {",
@@ -28,6 +29,7 @@ export const LINKED_ISSUE_PR_QUERY = [
28
29
  " }",
29
30
  " ... on CrossReferencedEvent {",
30
31
  " createdAt",
32
+ " willCloseTarget",
31
33
  " source {",
32
34
  " __typename",
33
35
  " ... on PullRequest {",
@@ -73,12 +75,18 @@ Error output (stderr, JSON):
73
75
  Argument/usage errors:
74
76
  { "ok": false, "error": "...", "usage": "..." }
75
77
  gh/runtime failures:
76
- { "ok": false, "error": "..." }`.trim();
78
+ { "ok": false, "error": "..." }
79
+ ${JQ_OUTPUT_USAGE}`.trim();
77
80
  const parseError = buildParseError(USAGE);
78
81
  export function parseDetectLinkedIssuePrCliArgs(argv) {
79
82
  const { tokens } = parseArgs({
80
83
  args: [...argv],
81
- options: { help: { type: "boolean", short: "h" }, repo: { type: "string" }, issue: { type: "string" } },
84
+ options: {
85
+ help: { type: "boolean", short: "h" },
86
+ repo: { type: "string" },
87
+ issue: { type: "string" },
88
+ ...JQ_OUTPUT_PARSE_OPTIONS,
89
+ },
82
90
  allowPositionals: true,
83
91
  strict: false,
84
92
  tokens: true,
@@ -107,6 +115,7 @@ export function parseDetectLinkedIssuePrCliArgs(argv) {
107
115
  options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
108
116
  continue;
109
117
  }
118
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
110
119
  throw parseError(`Unknown argument: ${token.rawName}`);
111
120
  }
112
121
  if (options.repo === undefined || options.issue === undefined) {
@@ -162,6 +171,13 @@ function normalizeLinkedPrNode(node) {
162
171
  };
163
172
  }
164
173
  if (node.__typename === "CrossReferencedEvent") {
174
+ // Only a cross-reference that will CLOSE this issue owns its board status.
175
+ // A bare body-mention (willCloseTarget:false, e.g. "part of #X") must not
176
+ // create board-ownership linkage — otherwise every sibling a PR mentions
177
+ // gets dragged to In Progress and the resolver fails closed (#1130).
178
+ if (node.willCloseTarget !== true) {
179
+ return null;
180
+ }
165
181
  return {
166
182
  eventType: "CROSS_REFERENCED_EVENT",
167
183
  eventCreatedAt: node.createdAt,
@@ -253,7 +269,7 @@ export function selectLinkedIssuePr(candidates) {
253
269
  });
254
270
  return sorted[0] ?? null;
255
271
  }
256
- export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
272
+ export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
257
273
  const { owner, name } = parseRepoSlug(repo);
258
274
  const candidates = [];
259
275
  const closedUnmergedCandidates = [];
@@ -322,7 +338,7 @@ export async function detectLinkedIssuePr({ repo, issue }, { env = process.env,
322
338
  }
323
339
  export async function runCli(
324
340
  argv = process.argv.slice(2),
325
- { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
341
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh" } = {},
326
342
  ) {
327
343
  const options = parseDetectLinkedIssuePrCliArgs(argv);
328
344
  if (options.help) {
@@ -333,7 +349,7 @@ export async function runCli(
333
349
  { repo: options.repo, issue: options.issue },
334
350
  { env, ghCommand },
335
351
  );
336
- stdout.write(`${JSON.stringify(result)}\n`);
352
+ process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
337
353
  }
338
354
  if (isDirectCliRun(import.meta.url)) {
339
355
  runCli().catch((error) => {
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
6
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
7
+ import { parseArgs } from "node:util";
8
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
9
+
10
+ const USAGE = `Usage: edit-pr.mjs --repo <owner/name> --pr <number> [--title <t>] [--body <b> | --body-file <path>] [--add-assignee <u>] [--remove-assignee <u>] [--milestone <m>]
11
+ Edit PR title/body/assignees/milestone. Thin wrapper over \`gh pr edit\` — use this
12
+ instead of an agent-level raw \`gh pr edit\` so the loop's internal-tooling record
13
+ stays clean (siblings: view-pr.mjs, comment-issue.mjs; #1057).
14
+ Required:
15
+ --repo <owner/name> Repository slug (e.g. owner/repo)
16
+ --pr <number> Pull request number
17
+ At least one edit:
18
+ --title <t> New PR title
19
+ --body <b> New PR body as a single argument
20
+ --body-file <path> Read the new body from a file (- reads stdin)
21
+ --add-assignee <u> Assignee to add (repeatable)
22
+ --remove-assignee <u> Assignee to remove (repeatable)
23
+ --milestone <m> Milestone to set (empty string clears it)
24
+ (--title/--body/--body-file reject empty or
25
+ whitespace-only values; use --milestone "" only
26
+ to clear the milestone)
27
+ Output (stdout, JSON):
28
+ { "ok": true, "repo": "owner/repo", "pr": 17, "edited": ["title", "body", ...] }
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 parseEditPrCliArgs(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
+ title: { type: "string" },
46
+ body: { type: "string" },
47
+ "body-file": { type: "string" },
48
+ "add-assignee": { type: "string", multiple: true },
49
+ "remove-assignee": { type: "string", multiple: true },
50
+ milestone: { type: "string" },
51
+ ...JQ_OUTPUT_PARSE_OPTIONS,
52
+ },
53
+ allowPositionals: true,
54
+ strict: false,
55
+ tokens: true,
56
+ });
57
+ const options = {
58
+ help: false,
59
+ repo: undefined,
60
+ pr: undefined,
61
+ title: undefined,
62
+ body: undefined,
63
+ bodyFile: undefined,
64
+ addAssignees: [],
65
+ removeAssignees: [],
66
+ milestone: undefined,
67
+ jq: undefined,
68
+ silent: false,
69
+ };
70
+ for (const token of tokens) {
71
+ if (token.kind === "positional") {
72
+ throw parseError(`Unknown argument: ${token.value}`);
73
+ }
74
+ if (token.kind !== "option") {
75
+ continue;
76
+ }
77
+ if (token.name === "help") {
78
+ options.help = true;
79
+ return options;
80
+ }
81
+ if (token.name === "repo") {
82
+ options.repo = requireTokenValue(token, parseError).trim();
83
+ continue;
84
+ }
85
+ if (token.name === "pr") {
86
+ options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
87
+ continue;
88
+ }
89
+ if (token.name === "title") {
90
+ const title = requireTokenValue(token, parseError);
91
+ if (title.trim().length === 0) {
92
+ throw parseError("--title must not be empty or whitespace-only");
93
+ }
94
+ options.title = title;
95
+ continue;
96
+ }
97
+ if (token.name === "body") {
98
+ const body = requireTokenValue(token, parseError);
99
+ if (body.trim().length === 0) {
100
+ throw parseError("--body must not be empty or whitespace-only");
101
+ }
102
+ options.body = body;
103
+ continue;
104
+ }
105
+ if (token.name === "body-file") {
106
+ const rawPath = requireTokenValue(token, parseError).trim();
107
+ if (rawPath.length === 0) {
108
+ throw parseError("--body-file must be a non-empty path");
109
+ }
110
+ options.bodyFile = rawPath;
111
+ continue;
112
+ }
113
+ if (token.name === "add-assignee") {
114
+ const u = requireTokenValue(token, parseError).trim();
115
+ if (u.length === 0) throw parseError("--add-assignee must be a non-empty login");
116
+ options.addAssignees.push(u);
117
+ continue;
118
+ }
119
+ if (token.name === "remove-assignee") {
120
+ const u = requireTokenValue(token, parseError).trim();
121
+ if (u.length === 0) throw parseError("--remove-assignee must be a non-empty login");
122
+ options.removeAssignees.push(u);
123
+ continue;
124
+ }
125
+ if (token.name === "milestone") {
126
+ // Read the raw token value: an empty string is a valid milestone value
127
+ // (`gh pr edit --milestone ""` clears it), so this deliberately does NOT
128
+ // go through requireTokenValue (which rejects empty). Guard only a truly
129
+ // missing value (`--milestone` with no following token). A whitespace-only
130
+ // value is neither a clear nor a real milestone name — fail closed rather
131
+ // than forwarding it to gh for a less actionable error.
132
+ if (typeof token.value !== "string") {
133
+ throw parseError("--milestone requires a value (use an empty string to clear)");
134
+ }
135
+ if (token.value.length > 0 && token.value.trim().length === 0) {
136
+ throw parseError('--milestone must be a milestone name or "" to clear (whitespace-only is not allowed)');
137
+ }
138
+ options.milestone = token.value;
139
+ continue;
140
+ }
141
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
142
+ throw parseError(`Unknown argument: ${token.rawName}`);
143
+ }
144
+ if (options.repo === undefined || options.pr === undefined) {
145
+ throw parseError("Editing a PR requires both --repo <owner/name> and --pr <number>");
146
+ }
147
+ if (options.body !== undefined && options.bodyFile !== undefined) {
148
+ throw parseError("--body and --body-file are mutually exclusive; pass only one");
149
+ }
150
+ const hasEdit =
151
+ options.title !== undefined ||
152
+ options.body !== undefined ||
153
+ options.bodyFile !== undefined ||
154
+ options.addAssignees.length > 0 ||
155
+ options.removeAssignees.length > 0 ||
156
+ options.milestone !== undefined;
157
+ if (!hasEdit) {
158
+ throw parseError("Editing a PR requires at least one of --title/--body/--body-file/--add-assignee/--remove-assignee/--milestone");
159
+ }
160
+ try {
161
+ parseRepoSlug(options.repo);
162
+ } catch (error) {
163
+ throw parseError(error instanceof Error ? error.message : String(error));
164
+ }
165
+ return options;
166
+ }
167
+
168
+ async function resolveBody(options) {
169
+ if (options.bodyFile === undefined) return options.body;
170
+ // Stdin (fd 0): the fs/promises readFile does NOT accept an integer fd, so read
171
+ // it synchronously via the callback-style API (which does). A real path stays on
172
+ // the async promise read.
173
+ const body =
174
+ options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
175
+ // Fail closed on an empty / whitespace-only file so a blank --body-file cannot
176
+ // silently clear the PR body (USAGE promises --body/--title reject empties).
177
+ if (body.trim().length === 0) {
178
+ throw new Error(`--body-file ${options.bodyFile} is empty`);
179
+ }
180
+ return body;
181
+ }
182
+
183
+ // Build the `gh pr edit` args and the parallel `edited` list (which fields were
184
+ // touched) so callers get a stable summary without re-reading the PR.
185
+ async function buildEditArgs(options) {
186
+ const args = ["pr", "edit", String(options.pr), "--repo", options.repo];
187
+ const edited = [];
188
+ if (options.title !== undefined) {
189
+ args.push("--title", options.title);
190
+ edited.push("title");
191
+ }
192
+ // resolveBody still runs for validation (reads the file, throws on empty /
193
+ // whitespace-only). A REAL --body-file path is handed straight to gh so large
194
+ // bodies avoid command-length limits. But `--body-file -` (stdin) was already
195
+ // consumed by resolveBody reading fd 0; re-emitting `--body-file -` makes gh
196
+ // re-read an exhausted stdin and clear the body, so pass the resolved string
197
+ // inline via --body instead.
198
+ const body = await resolveBody(options);
199
+ if (body !== undefined) {
200
+ if (options.bodyFile !== undefined && options.bodyFile !== "-") {
201
+ args.push("--body-file", options.bodyFile);
202
+ } else {
203
+ args.push("--body", body);
204
+ }
205
+ edited.push("body");
206
+ }
207
+ for (const u of options.addAssignees) {
208
+ args.push("--add-assignee", u);
209
+ }
210
+ if (options.addAssignees.length > 0) edited.push("add-assignee");
211
+ for (const u of options.removeAssignees) {
212
+ args.push("--remove-assignee", u);
213
+ }
214
+ if (options.removeAssignees.length > 0) edited.push("remove-assignee");
215
+ if (options.milestone !== undefined) {
216
+ args.push("--milestone", options.milestone);
217
+ edited.push("milestone");
218
+ }
219
+ return { args, edited };
220
+ }
221
+
222
+ export async function editPr(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
223
+ const { args, edited } = await buildEditArgs(options);
224
+ const result = await run(ghCommand, args, env);
225
+ if (result.code !== 0) {
226
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
227
+ throw new Error(`gh pr edit failed: ${detail}`);
228
+ }
229
+ return { ok: true, repo: options.repo, pr: options.pr, edited };
230
+ }
231
+
232
+ export async function runCli(
233
+ argv = process.argv.slice(2),
234
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
235
+ ) {
236
+ let options;
237
+ try {
238
+ options = parseEditPrCliArgs(argv);
239
+ } catch (error) {
240
+ stderr.write(`${formatCliError(error)}\n`);
241
+ return 1;
242
+ }
243
+ if (options.help) {
244
+ stdout.write(`${USAGE}\n`);
245
+ return 0;
246
+ }
247
+ let result;
248
+ try {
249
+ result = await editPr(options, { env, ghCommand, run });
250
+ } catch (error) {
251
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
252
+ return 1;
253
+ }
254
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
255
+ }
256
+
257
+ if (isDirectCliRun(import.meta.url)) {
258
+ runCli().then((code) => { process.exitCode = code; });
259
+ }
@@ -0,0 +1,208 @@
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, matchJqOutputToken } 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 (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
95
+ throw parseError(`Unknown argument: ${token.rawName}`);
96
+ }
97
+ if (options.repo === undefined || options.pr === undefined) {
98
+ throw parseError("Fetching CI logs requires both --repo <owner/name> and --pr <number>");
99
+ }
100
+ try {
101
+ parseRepoSlug(options.repo);
102
+ } catch (error) {
103
+ throw parseError(error instanceof Error ? error.message : String(error));
104
+ }
105
+ return options;
106
+ }
107
+
108
+ async function ghJson(run, ghCommand, args, env, label) {
109
+ const result = await run(ghCommand, args, env);
110
+ if (result.code !== 0) {
111
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
112
+ throw new Error(`${label} failed: ${detail}`);
113
+ }
114
+ return parseJsonText(result.stdout, { label });
115
+ }
116
+
117
+ function tailLines(text, n) {
118
+ const lines = String(text).replace(/\r?\n$/u, "").split(/\r?\n/u);
119
+ return lines.slice(Math.max(0, lines.length - n)).join("\n");
120
+ }
121
+
122
+ export async function fetchCiLogs(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
123
+ // 1. Resolve the PR's current head SHA — logs must be scoped to the head being
124
+ // evaluated, not a stale push.
125
+ const pr = await ghJson(
126
+ run,
127
+ ghCommand,
128
+ ["pr", "view", String(options.pr), "--repo", options.repo, "--json", "headRefOid"],
129
+ env,
130
+ "gh pr view",
131
+ );
132
+ const headSha = typeof pr.headRefOid === "string" ? pr.headRefOid.trim() : "";
133
+ if (headSha.length === 0) {
134
+ throw new Error("gh pr view did not return headRefOid");
135
+ }
136
+
137
+ // 2. List Actions runs for that exact commit.
138
+ const runs = await ghJson(
139
+ run,
140
+ ghCommand,
141
+ ["run", "list", "--repo", options.repo, "--commit", headSha, "--json", "databaseId,name,conclusion,status"],
142
+ env,
143
+ "gh run list",
144
+ );
145
+ if (!Array.isArray(runs)) {
146
+ throw new Error("gh run list did not return a JSON array");
147
+ }
148
+ const selected = options.failedOnly
149
+ ? runs.filter((r) => String(r?.conclusion).toLowerCase() === "failure")
150
+ : runs;
151
+
152
+ // 3. Fetch each run's log tail. --log-failed restricts to failed steps when the
153
+ // run failed; for non-failed runs it returns nothing, so fall back to --log.
154
+ const out = [];
155
+ for (const r of selected) {
156
+ const runId = Number.isInteger(r?.databaseId) ? r.databaseId : null;
157
+ if (runId === null) continue;
158
+ const conclusion = typeof r?.conclusion === "string" ? r.conclusion.toLowerCase() : null;
159
+ const logFlag = conclusion === "failure" ? "--log-failed" : "--log";
160
+ const logResult = await run(
161
+ ghCommand,
162
+ ["run", "view", String(runId), "--repo", options.repo, logFlag],
163
+ env,
164
+ );
165
+ // A log fetch failure for one run shouldn't abort the others (logs expire);
166
+ // record an empty tail with a note rather than throwing.
167
+ const logTail =
168
+ logResult.code === 0
169
+ ? tailLines(logResult.stdout, options.tail)
170
+ : `<log unavailable: ${logResult.stderr.trim() || `exit ${logResult.code}`}>`;
171
+ out.push({
172
+ runId,
173
+ name: typeof r?.name === "string" ? r.name : null,
174
+ conclusion,
175
+ logTail,
176
+ });
177
+ }
178
+ return { ok: true, repo: options.repo, pr: options.pr, headSha, runs: out };
179
+ }
180
+
181
+ export async function runCli(
182
+ argv = process.argv.slice(2),
183
+ { stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
184
+ ) {
185
+ let options;
186
+ try {
187
+ options = parseFetchCiLogsCliArgs(argv);
188
+ } catch (error) {
189
+ stderr.write(`${formatCliError(error)}\n`);
190
+ return 1;
191
+ }
192
+ if (options.help) {
193
+ stdout.write(`${USAGE}\n`);
194
+ return 0;
195
+ }
196
+ let result;
197
+ try {
198
+ result = await fetchCiLogs(options, { env, ghCommand, run });
199
+ } catch (error) {
200
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
201
+ return 1;
202
+ }
203
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
204
+ }
205
+
206
+ if (isDirectCliRun(import.meta.url)) {
207
+ runCli().then((code) => { process.exitCode = code; });
208
+ }