dev-loops 0.6.0 → 0.7.2

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 (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { isCopilotLogin, parseReviewThreads } from "../_core-helpers.mjs";
2
+ import { isCopilotLogin, parseReviewThreads, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
3
3
  import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
4
4
  export const MIN_DISMISSAL_REASON_LENGTH = 30;
5
5
  export function hasCommitShaReference(text) {
@@ -175,12 +175,15 @@ export async function replyAndMaybeResolve(
175
175
  } else {
176
176
  await validateReplyTarget({ repo, pr, commentId, threadId }, { env, ghCommand });
177
177
  }
178
+ // Neutralize any bare @copilot/`/copilot`* tokens the reply text quotes (e.g.
179
+ // a dismissal reason citing the anti-summon rule) so posting this reply can
180
+ // never arm request-copilot-review.mjs's anti-summon guard.
178
181
  const reply = parseReplyPayload(await postReply(
179
182
  {
180
183
  repo,
181
184
  pr,
182
185
  commentId,
183
- body,
186
+ body: sanitizeCopilotSummonTokens(body),
184
187
  },
185
188
  { env, ghCommand },
186
189
  ));
@@ -11,7 +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
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
15
15
  export const REVIEW_THREADS_QUERY = [
16
16
  "query($owner: String!, $name: String!, $pr: Int!) {",
17
17
  " repository(owner: $owner, name: $name) {",
@@ -103,14 +103,7 @@ export function parseCaptureCliArgs(argv) {
103
103
  options.pr = parsePrNumber(requireTokenValue(token));
104
104
  continue;
105
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
- }
106
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t))) continue;
114
107
  throw new Error(`Unknown argument: ${token.rawName}`);
115
108
  }
116
109
  const hasLiveArgs = options.repo !== undefined || options.pr !== undefined;
@@ -4,7 +4,7 @@ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helper
4
4
  import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
5
5
  import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
6
  import { parseArgs } from "node:util";
7
- import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
8
8
 
9
9
  const USAGE = `Usage: comment-issue.mjs --repo <owner/name> --issue <number> (--body <text> | --body-file <path>)
10
10
  Post a comment on a GitHub issue. Thin wrapper over \`gh issue comment\` — use this
@@ -82,14 +82,7 @@ export function parseCommentIssueCliArgs(argv) {
82
82
  options.bodyFile = rawPath;
83
83
  continue;
84
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
- }
85
+ if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
93
86
  throw parseError(`Unknown argument: ${token.rawName}`);
94
87
  }
95
88
  if (options.repo === undefined || options.issue === undefined) {
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { parseArgs } from "node:util";
4
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
6
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
7
+
8
+ const USAGE = `Usage: create-label.mjs --repo <owner/name> --name <label> [--color <hex>] [--description <text>] [--force]
9
+ Create (or idempotently reuse) a GitHub label. Thin wrapper over \`gh label create\`
10
+ — use this instead of an agent-level raw \`gh label create\` so the loop's
11
+ internal-tooling record stays clean (siblings: comment-issue.mjs, edit-pr.mjs).
12
+ Required:
13
+ --repo <owner/name> Repository slug (e.g. owner/repo)
14
+ --name <label> Label name to create
15
+ Optional:
16
+ --color <hex> Hex color without '#' (default: d73a4a)
17
+ --description <text> Label description
18
+ --force Update the label if it already exists
19
+ Output (stdout, JSON):
20
+ { "ok": true, "created": true, "name": "gate:full", "color": "d73a4a", "repo": "owner/repo" }
21
+ { "ok": true, "created": false, "alreadyExists": true, "name": "gate:full" }
22
+ Error output (stderr, JSON):
23
+ { "ok": false, "error": "...", "usage"?: "..." }
24
+ ${JQ_OUTPUT_USAGE}
25
+ Exit codes:
26
+ 0 Success (created or already exists)
27
+ 1 Argument error or gh failure
28
+ 2 Invalid --jq filter`.trim();
29
+ const parseError = buildParseError(USAGE);
30
+
31
+ export function parseCreateLabelCliArgs(argv) {
32
+ const { values } = parseArgs({
33
+ args: [...argv],
34
+ options: {
35
+ help: { type: "boolean", short: "h" },
36
+ repo: { type: "string" },
37
+ name: { type: "string" },
38
+ color: { type: "string" },
39
+ description: { type: "string" },
40
+ force: { type: "boolean" },
41
+ ...JQ_OUTPUT_PARSE_OPTIONS,
42
+ },
43
+ allowPositionals: false,
44
+ strict: false,
45
+ });
46
+ if (values.help) {
47
+ return { help: true };
48
+ }
49
+ const repo = typeof values.repo === "string" ? values.repo.trim() : undefined;
50
+ const name = typeof values.name === "string" ? values.name.trim() : undefined;
51
+ if (!repo || !name) {
52
+ throw parseError("Creating a label requires both --repo <owner/name> and --name <label>");
53
+ }
54
+ try {
55
+ parseRepoSlug(repo);
56
+ } catch (error) {
57
+ throw parseError(error instanceof Error ? error.message : String(error));
58
+ }
59
+ const color = typeof values.color === "string" && values.color.trim().length > 0 ? values.color.trim() : "d73a4a";
60
+ const description =
61
+ typeof values.description === "string" && values.description.length > 0 ? values.description : undefined;
62
+ return {
63
+ help: false,
64
+ repo,
65
+ name,
66
+ color,
67
+ description,
68
+ force: values.force === true,
69
+ jq: values.jq,
70
+ silent: values.silent === true,
71
+ };
72
+ }
73
+
74
+ // Pure: assemble the `gh label create` argv. Exported so the arg shape can be
75
+ // tested without spawning gh.
76
+ export function buildLabelArgs({ repo, name, color, description, force }) {
77
+ const args = ["label", "create", name, "--repo", repo, "--color", color];
78
+ if (description !== undefined) {
79
+ args.push("--description", description);
80
+ }
81
+ if (force) {
82
+ args.push("--force");
83
+ }
84
+ return args;
85
+ }
86
+
87
+ export function createLabel(options, { ghCommand = "gh", exec = execFileSync } = {}) {
88
+ const args = buildLabelArgs(options);
89
+ try {
90
+ exec(ghCommand, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
91
+ } catch (error) {
92
+ const stderr = typeof error?.stderr === "string" ? error.stderr : String(error?.stderr ?? "");
93
+ // Without --force, gh errors if the label exists. Treat that as idempotent
94
+ // success so re-runs are safe; any other failure propagates.
95
+ if (!options.force && /already exists/i.test(stderr)) {
96
+ return { ok: true, created: false, alreadyExists: true, name: options.name };
97
+ }
98
+ const detail = stderr.trim() || (error instanceof Error ? error.message : String(error));
99
+ throw new Error(`gh label create failed: ${detail}`);
100
+ }
101
+ return { ok: true, created: true, name: options.name, color: options.color, repo: options.repo };
102
+ }
103
+
104
+ export function run(
105
+ argv = process.argv.slice(2),
106
+ { stdout = process.stdout, stderr = process.stderr, ghCommand = "gh", exec = execFileSync } = {},
107
+ ) {
108
+ let options;
109
+ try {
110
+ options = parseCreateLabelCliArgs(argv);
111
+ } catch (error) {
112
+ stderr.write(`${formatCliError(error)}\n`);
113
+ return 1;
114
+ }
115
+ if (options.help) {
116
+ stdout.write(`${USAGE}\n`);
117
+ return 0;
118
+ }
119
+ let result;
120
+ try {
121
+ result = createLabel(options, { ghCommand, exec });
122
+ } catch (error) {
123
+ stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
124
+ return 1;
125
+ }
126
+ return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
127
+ }
128
+
129
+ export const main = run;
130
+
131
+ if (isDirectCliRun(import.meta.url)) {
132
+ process.exitCode = run();
133
+ }
@@ -2,6 +2,10 @@
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
5
+ import { runChild as _runChild } from "../_cli-primitives.mjs";
6
+ import { resolveSettings, applyDevloopsBoard } from "../projects/_resolve-project.mjs";
7
+ import { main as addQueueItemMain } from "../projects/add-queue-item.mjs";
8
+ import { loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
5
9
  const USAGE = `Usage: create-pr.mjs [gh pr create args...]
6
10
  Canonical PR-creation wrapper around \`gh pr create\`. Every PR opened through this
7
11
  tool is ALWAYS a draft and is self-assigned by default. Never call raw \`gh pr create\`.
@@ -11,6 +15,16 @@ Behavior:
11
15
  - honors an explicit \`--assignee <login>\` / \`-a <login>\` when supplied (no default injected)
12
16
  - rejects \`--ready\` before invoking \`gh\`
13
17
  - detects missing \`Closes #N\` / \`Fixes #N\` in \`--body\` or \`--body-file\` content (non-fatal stderr warning)
18
+ - \`--lightweight\` (consumed here in every form — bare or \`=true/1/false/0\`, last
19
+ occurrence wins — never forwarded to \`gh\`): when an explicit \`--body\`/
20
+ \`--body-file\` also carries no \`Closes #N\`/\`Fixes #N\`, the new PR is issue-less
21
+ lightweight and is auto-enqueued as a board PR item in the configured In Progress column
22
+ (reuses \`queue.projectNumber\` / \`queue.boardTitle\` from \`.devloops\`, same as the queue
23
+ scripts). Requires an explicit \`--repo owner/name\` (space or = form). A trailing stdout
24
+ line reports the outcome: \`{"board":{"enqueued":bool,...}}\`. No board configured, no
25
+ \`--repo\`, no explicit body source, or an enqueue error is a non-fatal no-op (noted in
26
+ that line; exit code unaffected). Omitting \`--lightweight\`, or a body that already
27
+ carries a closing keyword (tracker-backed), never calls the board.
14
28
  - forwards every other argument to \`gh pr create\` unchanged
15
29
  - preserves the underlying \`gh pr create\` stdout, stderr, and exit code
16
30
  Examples:
@@ -26,8 +40,17 @@ Exit codes:
26
40
  N same non-zero exit code returned by \`gh pr create\``.trim();
27
41
  const parseError = buildParseError(USAGE);
28
42
  const READY_FLAG_PATTERN = /^--ready(?:$|=)/u;
43
+ // Bare and inline-boolean forms, mirroring DRAFT_FLAG_PATTERN below: every
44
+ // matching token is consumed (never forwarded to gh, which rejects unknown
45
+ // flags), and the LAST occurrence decides — bare or =true/=1 enables,
46
+ // anything else (=false, =0, ...) disables.
47
+ const LIGHTWEIGHT_FLAG_PATTERN = /^--lightweight(?:=(.*))?$/iu;
48
+ // Both `--repo owner/name` and `--repo=owner/name` — gh accepts either form.
49
+ const REPO_FLAG_PATTERN = /^--repo(?:$|=)/u;
50
+ const PR_URL_NUMBER_PATTERN = /\/pull\/(\d+)(?:\D|$)/u;
29
51
  const DRAFT_FLAG_PATTERN = /^--draft(?:=(.*))?$/iu;
30
- const DRAFT_TRUE_VALUE_PATTERN = /^(?:true|1)$/iu;
52
+ // Shared inline-boolean truthiness for --draft= and --lightweight= values.
53
+ const TRUE_FLAG_VALUE_PATTERN = /^(?:true|1)$/iu;
31
54
  // Detect both the long `--assignee`/`--assignee=<login>` forms and the `-a`
32
55
  // short flag that `gh pr create` documents, so an explicit assignee in either
33
56
  // form suppresses the `--assignee @me` default (otherwise a caller passing
@@ -56,8 +79,7 @@ async function resolveBody(args) {
56
79
  }
57
80
  return null; // unreadable → warn
58
81
  }
59
- async function warnMissingClosingKeyword(args) {
60
- const body = await resolveBody(args);
82
+ function warnMissingClosingKeyword(body) {
61
83
  if (body === null) return; // no --body or --body-file, skip
62
84
  if (!detectClosingKeyword(body)) {
63
85
  process.stderr.write(
@@ -66,6 +88,48 @@ async function warnMissingClosingKeyword(args) {
66
88
  );
67
89
  }
68
90
  }
91
+ // A plain string value for a single-value flag, in both the space form
92
+ // (`--repo owner/name`) and the inline form (`--repo=owner/name`); unlike
93
+ // resolveBody, never reads a file. Returns null when the flag is absent.
94
+ function getFlagValue(args, flagPattern) {
95
+ const idx = args.findIndex((token) => flagPattern.test(token));
96
+ if (idx === -1) return null;
97
+ const eq = args[idx].indexOf("=");
98
+ if (eq !== -1) return args[idx].slice(eq + 1);
99
+ return idx + 1 < args.length ? args[idx + 1] : null;
100
+ }
101
+ function parsePrNumberFromOutput(stdout) {
102
+ const match = PR_URL_NUMBER_PATTERN.exec(stdout ?? "");
103
+ return match ? Number(match[1]) : null;
104
+ }
105
+ // Auto-enqueue an issue-less lightweight PR as a board PR item in the configured
106
+ // In Progress column. Reuses the same .devloops queue.projectNumber /
107
+ // queue.boardTitle resolution and add-queue-item's idempotent add — never
108
+ // reimplements the board API calls. Never throws: an unconfigured board, a
109
+ // missing --repo, an unparsed PR number, or an enqueue failure are all
110
+ // non-fatal no-ops reported in the returned shape.
111
+ export async function enqueueIssuelessLightweightPr({ repo, prNumber, cwd, env, runChild }) {
112
+ if (!repo) return { enqueued: false, reason: "repo-not-specified" };
113
+ if (!Number.isInteger(prNumber) || prNumber < 1) return { enqueued: false, reason: "pr-number-not-parsed" };
114
+ const settings = resolveSettings(cwd);
115
+ if (!settings?.project && !settings?.title) {
116
+ return { enqueued: false, reason: "no-board-configured" };
117
+ }
118
+ const { columnNames, error: columnError } = loadStateColumnMap(cwd);
119
+ if (columnError) {
120
+ return { enqueued: false, reason: `config-error: ${columnError}` };
121
+ }
122
+ const args = { repo, item: prNumber };
123
+ applyDevloopsBoard(args, cwd);
124
+ args.column = columnNames[LOGICAL_COLUMN.IN_PROGRESS];
125
+ try {
126
+ const result = await addQueueItemMain(args, { env, runChild, cwd });
127
+ const { itemId, prNumber: itemPrNumber, status, alreadyPresent } = result.item;
128
+ return { enqueued: true, itemId, prNumber: itemPrNumber, status, alreadyPresent };
129
+ } catch (err) {
130
+ return { enqueued: false, reason: `enqueue-error: ${err instanceof Error ? err.message : String(err)}` };
131
+ }
132
+ }
69
133
  export function buildCreatePrArgs(argv) {
70
134
  const args = [...argv];
71
135
  if (args.includes("--help") || args.includes("-h")) {
@@ -79,7 +143,7 @@ export function buildCreatePrArgs(argv) {
79
143
  }
80
144
  const draftTokens = args.filter((token) => DRAFT_FLAG_PATTERN.test(token));
81
145
  const lastDraftToken = draftTokens.length > 0 ? draftTokens.at(-1) : null;
82
- const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && DRAFT_TRUE_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
146
+ const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && TRUE_FLAG_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
83
147
  const hasAssignee = args.some((token) => ASSIGNEE_FLAG_PATTERN.test(token));
84
148
  return {
85
149
  help: false,
@@ -92,26 +156,71 @@ export function buildCreatePrArgs(argv) {
92
156
  ],
93
157
  };
94
158
  }
95
- export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
159
+ // captureStdout tees gh's stdout to a buffer (still writing it straight through to
160
+ // process.stdout, unbuffered) so the PR URL can be parsed once gh exits, without
161
+ // changing what a caller/terminal sees. Only enabled for the issue-less lightweight
162
+ // path; every other caller keeps the plain "inherit" byte-identical behavior.
163
+ export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}, { captureStdout = false } = {}) {
96
164
  return new Promise((resolve, reject) => {
165
+ // `gh pr create` never reads stdin (body comes from --body/--body-file), so
166
+ // the captured path ignores it rather than inheriting: inheriting a
167
+ // never-closing stdin (e.g. an in-process test runner) would hang any child
168
+ // that waits for stdin to end.
97
169
  const child = spawn(ghCommand, ghArgs, {
98
170
  env,
99
- stdio: "inherit",
171
+ stdio: captureStdout ? ["ignore", "pipe", "inherit"] : "inherit",
100
172
  });
173
+ let stdout = "";
174
+ if (captureStdout) {
175
+ child.stdout.on("data", (chunk) => {
176
+ stdout += chunk;
177
+ process.stdout.write(chunk);
178
+ });
179
+ }
101
180
  child.on("error", reject);
102
181
  child.on("close", (code) => {
103
- resolve(typeof code === "number" ? code : 1);
182
+ resolve({ code: typeof code === "number" ? code : 1, stdout });
104
183
  });
105
184
  });
106
185
  }
107
186
  export async function main(argv = process.argv.slice(2), runtime = {}) {
108
- const { help, ghArgs } = buildCreatePrArgs(argv);
187
+ // Last occurrence wins, same as the --draft handling in buildCreatePrArgs.
188
+ const lastLightweightToken = argv.filter((token) => LIGHTWEIGHT_FLAG_PATTERN.test(token)).at(-1) ?? null;
189
+ const lightweight = lastLightweightToken === "--lightweight" ||
190
+ (typeof lastLightweightToken === "string" && TRUE_FLAG_VALUE_PATTERN.test(lastLightweightToken.slice("--lightweight=".length)));
191
+ const forwardedArgv = argv.filter((token) => !LIGHTWEIGHT_FLAG_PATTERN.test(token));
192
+ const { help, ghArgs } = buildCreatePrArgs(forwardedArgv);
109
193
  if (help) {
110
194
  process.stdout.write(`${USAGE}\n`);
111
195
  return 0;
112
196
  }
113
- await warnMissingClosingKeyword(argv);
114
- return spawnCreatePr(ghArgs, runtime);
197
+ const body = await resolveBody(forwardedArgv);
198
+ warnMissingClosingKeyword(body);
199
+ // Issue-less lightweight: caller signals lightweight AND an explicit body
200
+ // source (--body/--body-file) carries no closing keyword. A tracker-backed
201
+ // lightweight PR (closing keyword present) never reaches the board — its
202
+ // issue already owns the board entry. A null body (no explicit source) is
203
+ // NOT classified issue-less: the body may come from elsewhere (editor,
204
+ // template) and could carry a closing keyword this wrapper never saw, so it
205
+ // fails toward not enqueuing and reports body-not-provided instead.
206
+ const issueLess = lightweight && body !== null && !detectClosingKeyword(body);
207
+ const { code, stdout } = await spawnCreatePr(ghArgs, runtime, { captureStdout: issueLess });
208
+ if (lightweight && code === 0 && (issueLess || body === null)) {
209
+ const board = issueLess
210
+ ? await enqueueIssuelessLightweightPr({
211
+ repo: getFlagValue(forwardedArgv, REPO_FLAG_PATTERN),
212
+ prNumber: parsePrNumberFromOutput(stdout),
213
+ cwd: runtime.cwd ?? process.cwd(),
214
+ env: runtime.env ?? process.env,
215
+ runChild: runtime.runChild ?? _runChild,
216
+ })
217
+ : { enqueued: false, reason: "body-not-provided" };
218
+ if (!board.enqueued) {
219
+ process.stderr.write(`[create-pr] Board note: PR not enqueued (${board.reason}).\n`);
220
+ }
221
+ process.stdout.write(`${JSON.stringify({ board })}\n`);
222
+ }
223
+ return code;
115
224
  }
116
225
  if (isDirectCliRun(import.meta.url)) {
117
226
  try {