dev-loops 0.1.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 (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. package/skills/local-implementation/SKILL.md +640 -0
@@ -0,0 +1,837 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
6
+ import { runChild as _runChild } from "../_cli-primitives.mjs";
7
+
8
+ const USAGE = `Usage: dev-loops project ensure --repo <owner/name> [--project <number>] [--title <title>] [--link-repo <owner/name>] [--repair-rename]
9
+
10
+ --repair-rename Rename semantically equivalent Status columns to the standard names
11
+ (e.g. "Ready" -> "Next Up"). Without this flag the helper only
12
+ reports rename candidates and leaves existing columns untouched.
13
+
14
+ Idempotent bootstrap for a GitHub Projects V2 board used as the dev-loop queue.
15
+
16
+ Creates the project board if it doesn't exist, ensures a Status field with
17
+ standard columns (Backlog, Next Up, In Progress, Done). Exits clean if the
18
+ board and Status field already exist.
19
+
20
+ When --link-repo is provided, links the project to the given repository after creation.
21
+
22
+ When --project is not provided, resolves from .devloops at repo root
23
+ queue.projectNumber or queue.boardTitle.
24
+
25
+ Output (stdout):
26
+ JSON: { ok: true, project: { id, number, title, url, statusFieldId, linkedRepo } }
27
+
28
+ Exit codes:
29
+ 0 — board exists or was created successfully (idempotent)
30
+ 1 — usage or argument error
31
+ 2 — GitHub API error
32
+ 3 — board schema/config mismatch (manual reconciliation needed)
33
+ `;
34
+
35
+ const VALID_ARGS = new Set(["--repo", "--project", "--title", "--link-repo", "--repair-rename", "--help", "-h"]);
36
+
37
+ function parseArgs(argv) {
38
+ const args = {}; // title default applied in runCli after settings fallback
39
+ const consumed = new Set();
40
+ for (let i = 0; i < argv.length; i++) {
41
+ if (consumed.has(i)) continue;
42
+ const arg = argv[i];
43
+ if (!VALID_ARGS.has(arg) && arg.startsWith("-")) {
44
+ throw Object.assign(
45
+ new Error(`Unknown flag: ${arg}`),
46
+ { code: "INVALID_REPO", usage: USAGE },
47
+ );
48
+ }
49
+ if (arg === "--repo") {
50
+ if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
51
+ throw Object.assign(
52
+ new Error("--repo requires a value (owner/name)"),
53
+ { code: "INVALID_REPO", usage: USAGE },
54
+ );
55
+ }
56
+ args.repo = argv[++i];
57
+ consumed.add(i);
58
+ } else if (arg === "--project") {
59
+ if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
60
+ throw Object.assign(
61
+ new Error("--project requires a numeric value"),
62
+ { code: "INVALID_PROJECT", usage: USAGE },
63
+ );
64
+ }
65
+ const num = Number(argv[++i]);
66
+ if (!Number.isInteger(num) || num <= 0) {
67
+ throw Object.assign(
68
+ new Error(`--project must be a positive integer, got "${argv[i]}"`),
69
+ { code: "INVALID_PROJECT", usage: USAGE },
70
+ );
71
+ }
72
+ args.project = num;
73
+ } else if (arg === "--title") {
74
+ if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
75
+ throw Object.assign(
76
+ new Error("--title requires a value"),
77
+ { code: "INVALID_REPO", usage: USAGE },
78
+ );
79
+ }
80
+ args.title = argv[++i];
81
+ consumed.add(i);
82
+ } else if (arg === "--link-repo") {
83
+ if (i + 1 >= argv.length || argv[i + 1].startsWith("-")) {
84
+ throw Object.assign(
85
+ new Error("--link-repo requires a value (owner/name)"),
86
+ { code: "INVALID_REPO", usage: USAGE },
87
+ );
88
+ }
89
+ args.linkRepo = argv[++i];
90
+ consumed.add(i);
91
+ } else if (arg === "--repair-rename") {
92
+ args.repairRename = true;
93
+ } else if (arg === "--help" || arg === "-h") {
94
+ args.help = true;
95
+ } else {
96
+ throw Object.assign(
97
+ new Error(`Unexpected argument: ${arg}`),
98
+ { code: "INVALID_REPO", usage: USAGE },
99
+ );
100
+ }
101
+ }
102
+ return args;
103
+ }
104
+
105
+ // ── Validation ───────────────────────────────────────────────────────────
106
+
107
+ // GitHub slug rules: owner 1-39 chars (alnum/dash, no leading/trailing dash,
108
+ // no consecutive dashes); repo name similar but also allows dots/underscores.
109
+ const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
110
+ const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
111
+
112
+ function validateRepo(repo) {
113
+ if (!repo || typeof repo !== "string") {
114
+ throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO", usage: USAGE });
115
+ }
116
+ const trimmed = repo.trim();
117
+ if (trimmed !== repo) {
118
+ throw Object.assign(
119
+ new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
120
+ { code: "INVALID_REPO", usage: USAGE },
121
+ );
122
+ }
123
+ const slashIdx = repo.indexOf("/");
124
+ if (slashIdx === -1) {
125
+ throw Object.assign(
126
+ new Error(`--repo must be exactly owner/name, got "${repo}"`),
127
+ { code: "INVALID_REPO", usage: USAGE },
128
+ );
129
+ }
130
+ const owner = repo.slice(0, slashIdx);
131
+ const name = repo.slice(slashIdx + 1);
132
+ if (!owner || !name) {
133
+ throw Object.assign(
134
+ new Error(`--repo must be exactly owner/name, got "${repo}"`),
135
+ { code: "INVALID_REPO", usage: USAGE },
136
+ );
137
+ }
138
+ if (!OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
139
+ throw Object.assign(
140
+ new Error(`--repo must be exactly owner/name, got "${repo}"`),
141
+ { code: "INVALID_REPO", usage: USAGE },
142
+ );
143
+ }
144
+ return repo;
145
+ }
146
+
147
+ // ── Settings fallback ────────────────────────────────────────────────────
148
+
149
+ function resolveSettings(cwd) {
150
+ // Try bare .devloops and extension variants (.yaml, .yml, .json)
151
+ // to match the config loader's consumer override detection.
152
+ // .json uses strict JSON.parse; all others use the YAML parser.
153
+ const basePath = path.join(cwd, ".devloops");
154
+ const extensions = ["", ".yaml", ".yml", ".json"];
155
+ for (const ext of extensions) {
156
+ try {
157
+ const settingsPath = basePath + ext;
158
+ const raw = readFileSync(settingsPath, "utf-8");
159
+ const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
160
+ const queue = settings?.queue;
161
+ if (queue) {
162
+ if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
163
+ return { project: queue.projectNumber };
164
+ }
165
+ if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
166
+ return { title: queue.boardTitle.trim() };
167
+ }
168
+ }
169
+ return null;
170
+ } catch {
171
+ // extension not present or unparseable — try next
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ // ── API helpers ──────────────────────────────────────────────────────────
178
+
179
+ async function ghGraphql(query, vars, env, runChild = _runChild) {
180
+ const fieldArgs = [];
181
+ for (const [key, value] of Object.entries(vars)) {
182
+ fieldArgs.push("--field", `${key}=${value}`);
183
+ }
184
+ const result = await runChild(
185
+ "gh",
186
+ ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
187
+ env,
188
+ );
189
+ if (result.code !== 0) {
190
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
191
+ throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
192
+ }
193
+ const payload = parseJsonText(result.stdout);
194
+ if (payload.errors && payload.errors.length > 0) {
195
+ throw Object.assign(
196
+ new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
197
+ { code: "GRAPHQL_ERROR" },
198
+ );
199
+ }
200
+ return payload;
201
+ }
202
+
203
+ // ── Query/mutation fragments ────────────────────────────────────────────
204
+
205
+ const GET_USER_ID = [
206
+ "query($login:String!) {",
207
+ " user(login:$login) {",
208
+ " id",
209
+ " }",
210
+ "}"
211
+ ].join("\n");
212
+
213
+ const GET_ORG_ID = [
214
+ "query($login:String!) {",
215
+ " organization(login:$login) {",
216
+ " id",
217
+ " }",
218
+ "}"
219
+ ].join("\n");
220
+
221
+ const LIST_USER_PROJECTS = [
222
+ "query($login:String!, $after:String) {",
223
+ " user(login:$login) {",
224
+ " projectsV2(first:50, after:$after) {",
225
+ " pageInfo {",
226
+ " hasNextPage",
227
+ " endCursor",
228
+ " }",
229
+ " nodes {",
230
+ " id",
231
+ " number",
232
+ " title",
233
+ " url",
234
+ " }",
235
+ " }",
236
+ " }",
237
+ "}"
238
+ ].join("\n");
239
+
240
+ const LIST_ORG_PROJECTS = [
241
+ "query($login:String!, $after:String) {",
242
+ " organization(login:$login) {",
243
+ " projectsV2(first:50, after:$after) {",
244
+ " pageInfo {",
245
+ " hasNextPage",
246
+ " endCursor",
247
+ " }",
248
+ " nodes {",
249
+ " id",
250
+ " number",
251
+ " title",
252
+ " url",
253
+ " }",
254
+ " }",
255
+ " }",
256
+ "}"
257
+ ].join("\n");
258
+
259
+ const CREATE_PROJECT = [
260
+ "mutation($ownerId:ID!, $title:String!) {",
261
+ " createProjectV2(input:{ownerId:$ownerId, title:$title}) {",
262
+ " projectV2 {",
263
+ " id",
264
+ " number",
265
+ " title",
266
+ " url",
267
+ " }",
268
+ " }",
269
+ "}"
270
+ ].join("\n");
271
+
272
+ const GET_PROJECT_FIELDS = [
273
+ "query($projectId:ID!, $after:String) {",
274
+ " node(id:$projectId) {",
275
+ " ... on ProjectV2 {",
276
+ " fields(first:50, after:$after) {",
277
+ " pageInfo {",
278
+ " hasNextPage",
279
+ " endCursor",
280
+ " }",
281
+ " nodes {",
282
+ " ... on ProjectV2SingleSelectField {",
283
+ " id",
284
+ " name",
285
+ " options {",
286
+ " id",
287
+ " name",
288
+ " color",
289
+ " description",
290
+ " }",
291
+ " }",
292
+ " }",
293
+ " }",
294
+ " }",
295
+ " }",
296
+ "}"
297
+ ].join("\n");
298
+
299
+ const CREATE_SINGLE_SELECT_FIELD = [
300
+ "mutation($projectId:ID!) {",
301
+ " createProjectV2Field(input:{projectId:$projectId, dataType:SINGLE_SELECT, name:\"Status\", singleSelectOptions:[",
302
+ " {name:\"Backlog\",color:GRAY,description:\"\"},",
303
+ " {name:\"Next Up\",color:BLUE,description:\"\"},",
304
+ " {name:\"In Progress\",color:YELLOW,description:\"\"},",
305
+ " {name:\"Done\",color:GREEN,description:\"\"}",
306
+ " ]}) {",
307
+ " projectV2Field {",
308
+ " ... on ProjectV2SingleSelectField {",
309
+ " id",
310
+ " name",
311
+ " }",
312
+ " }",
313
+ " }",
314
+ "}"
315
+ ].join("\n");
316
+
317
+ const LINK_PROJECT_TO_REPO = [
318
+ "mutation($projectId:ID!, $repositoryId:ID!) {",
319
+ " linkProjectV2ToRepository(input:{projectId:$projectId, repositoryId:$repositoryId}) {",
320
+ " clientMutationId",
321
+ " }",
322
+ "}"
323
+ ].join("\n");
324
+
325
+ const UPDATE_PROJECT_FIELD = [
326
+ "mutation($fieldId:ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {",
327
+ " updateProjectV2Field(input:{fieldId:$fieldId, singleSelectOptions:$options}) {",
328
+ " projectV2Field {",
329
+ " ... on ProjectV2SingleSelectField {",
330
+ " id",
331
+ " name",
332
+ " options {",
333
+ " id",
334
+ " name",
335
+ " }",
336
+ " }",
337
+ " }",
338
+ " }",
339
+ "}"
340
+ ].join("\n");
341
+
342
+ const GET_REPO_ID = [
343
+ "query($owner:String!, $name:String!) {",
344
+ " repository(owner:$owner, name:$name) {",
345
+ " id",
346
+ " }",
347
+ "}"
348
+ ].join("\n");
349
+
350
+ // ── Owner resolution ────────────────────────────────────────────────────
351
+
352
+ async function resolveOwner(login, env, runChild) {
353
+ // Try user first
354
+ const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
355
+ if (userPayload?.data?.user?.id) {
356
+ return { id: userPayload.data.user.id, kind: "user" };
357
+ }
358
+ // Try organization (only if user returned null — not for API errors)
359
+ const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
360
+ if (orgPayload?.data?.organization?.id) {
361
+ return { id: orgPayload.data.organization.id, kind: "org" };
362
+ }
363
+ throw Object.assign(
364
+ new Error(`Could not resolve owner ID for "${login}" (not a user or organization)`),
365
+ { code: "NO_USER_ID" },
366
+ );
367
+ }
368
+
369
+ // ── Repository ID resolution ─────────────────────────────────────────────
370
+
371
+ async function resolveRepoId(slug, env, runChild) {
372
+ const [owner, name] = slug.split("/");
373
+ const payload = await ghGraphql(GET_REPO_ID, { owner, name }, env, runChild);
374
+ if (!payload?.data?.repository?.id) {
375
+ throw Object.assign(
376
+ new Error(`Could not resolve repository ID for "${slug}"`),
377
+ { code: "NO_REPO_ID" },
378
+ );
379
+ }
380
+ return payload.data.repository.id;
381
+ }
382
+
383
+ // ── Paginated project listing ────────────────────────────────────────────
384
+
385
+ async function listAllProjects(login, kind, env, runChild) {
386
+ const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
387
+ const projects = [];
388
+ let after = null;
389
+ while (true) {
390
+ const vars = { login };
391
+ if (after) vars.after = after;
392
+ const payload = await ghGraphql(query, vars, env, runChild);
393
+ const connection = kind === "org"
394
+ ? payload?.data?.organization?.projectsV2
395
+ : payload?.data?.user?.projectsV2;
396
+ const nodes = connection?.nodes ?? [];
397
+ projects.push(...nodes);
398
+ const pageInfo = connection?.pageInfo ?? {};
399
+ if (!pageInfo.hasNextPage) break;
400
+ if (!pageInfo.endCursor) {
401
+ throw Object.assign(
402
+ new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
403
+ { code: "GH_API_ERROR" },
404
+ );
405
+ }
406
+ after = pageInfo.endCursor;
407
+ }
408
+ return projects;
409
+ }
410
+
411
+ // ── Paginated field listing ──────────────────────────────────────────────
412
+
413
+ async function listAllFields(projectId, env, runChild) {
414
+ const fields = [];
415
+ let after = null;
416
+ while (true) {
417
+ const vars = { projectId };
418
+ if (after) vars.after = after;
419
+ const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
420
+ const connection = payload?.data?.node?.fields;
421
+ const nodes = connection?.nodes ?? [];
422
+ fields.push(...nodes);
423
+ const pageInfo = connection?.pageInfo ?? {};
424
+ if (!pageInfo.hasNextPage) break;
425
+ if (!pageInfo.endCursor) {
426
+ throw Object.assign(
427
+ new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
428
+ { code: "GH_API_ERROR" },
429
+ );
430
+ }
431
+ after = pageInfo.endCursor;
432
+ }
433
+ return fields;
434
+ }
435
+
436
+
437
+ // ── Column rename/reconcile helpers ──────────────────────────────────────
438
+
439
+ const RENAME_EQUIVALENTS = {
440
+ "Backlog": ["Backlog", "Todo", "To do", "Pending"],
441
+ "Next Up": ["Next Up", "Ready", "Next", "Up next"],
442
+ "In Progress": ["In Progress", "Doing", "In progress", "InProgress"],
443
+ "Done": ["Done", "Complete", "Completed"],
444
+ };
445
+
446
+ function normalizeOptionName(name) {
447
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
448
+ }
449
+
450
+ const EQUIVALENT_TO_STANDARD = new Map();
451
+ for (const [standard, synonyms] of Object.entries(RENAME_EQUIVALENTS)) {
452
+ for (const synonym of synonyms) {
453
+ EQUIVALENT_TO_STANDARD.set(normalizeOptionName(synonym), standard);
454
+ }
455
+ }
456
+
457
+ function buildOptionInput(option) {
458
+ const standard = STANDARD_COLUMNS.find((c) => c.name === option.name);
459
+ return {
460
+ name: option.name,
461
+ color: option.color ?? standard?.color ?? "GRAY",
462
+ description: option.description ?? "",
463
+ };
464
+ }
465
+
466
+ function classifyOptions(existingOptions, repairRename) {
467
+ const exactPresent = new Set(existingOptions.map((o) => o.name));
468
+ const candidates = [];
469
+ const conflicts = [];
470
+ const seenCandidateStandard = new Map(); // standard -> option name
471
+
472
+ for (const option of existingOptions) {
473
+ const standard = EQUIVALENT_TO_STANDARD.get(normalizeOptionName(option.name));
474
+ if (!standard) continue;
475
+
476
+ // Exact standard columns are not drift; skip them.
477
+ if (option.name === standard) continue;
478
+
479
+ if (exactPresent.has(standard)) {
480
+ conflicts.push({
481
+ option: option.name,
482
+ reason: `Equivalent "${option.name}" maps to "${standard}", but the exact standard column already exists.`,
483
+ });
484
+ continue;
485
+ }
486
+
487
+ if (seenCandidateStandard.has(standard)) {
488
+ conflicts.push({
489
+ option: option.name,
490
+ reason: `Multiple columns map to "${standard}": "${seenCandidateStandard.get(standard)}" and "${option.name}".`,
491
+ });
492
+ continue;
493
+ }
494
+
495
+ seenCandidateStandard.set(standard, option.name);
496
+ candidates.push({
497
+ optionId: option.id,
498
+ from: option.name,
499
+ to: standard,
500
+ });
501
+ }
502
+
503
+ const candidateTargets = new Set(candidates.map((c) => c.to));
504
+
505
+ if (repairRename && conflicts.length === 0) {
506
+ const appliedRenames = [];
507
+ const renamedOptions = existingOptions.map((option) => {
508
+ const candidate = candidates.find((c) => c.optionId === option.id);
509
+ if (!candidate) return buildOptionInput(option);
510
+ appliedRenames.push({ from: candidate.from, to: candidate.to });
511
+ return {
512
+ name: candidate.to,
513
+ color: STANDARD_COLUMNS.find((c) => c.name === candidate.to)?.color ?? "GRAY",
514
+ description: option.description ?? "",
515
+ };
516
+ });
517
+
518
+ const coveredStandards = new Set([...exactPresent, ...appliedRenames.map((r) => r.to)]);
519
+ const additiveMissing = STANDARD_COLUMNS.filter((c) => !coveredStandards.has(c.name));
520
+
521
+ return {
522
+ options: [...renamedOptions, ...additiveMissing],
523
+ repairs: {
524
+ additive: additiveMissing.map((c) => c.name),
525
+ renameCandidates: [],
526
+ renamesApplied: appliedRenames,
527
+ conflicts: [],
528
+ },
529
+ };
530
+ }
531
+
532
+ const coveredStandards = new Set([...exactPresent, ...candidateTargets]);
533
+ const additiveMissing = STANDARD_COLUMNS.filter((c) => !coveredStandards.has(c.name));
534
+
535
+ return {
536
+ options: [
537
+ ...existingOptions.map(buildOptionInput),
538
+ ...additiveMissing,
539
+ ],
540
+ repairs: {
541
+ additive: additiveMissing.map((c) => c.name),
542
+ renameCandidates: candidates.map((c) => ({ from: c.from, to: c.to })),
543
+ renamesApplied: [],
544
+ conflicts,
545
+ },
546
+ };
547
+ }
548
+
549
+ async function classifyAndRepairColumns(
550
+ fieldId,
551
+ existingOptions,
552
+ repairRename,
553
+ env,
554
+ runChild,
555
+ ) {
556
+ const classification = classifyOptions(existingOptions, repairRename);
557
+
558
+ const willRename = classification.repairs.renamesApplied.length > 0;
559
+ const willAdd = classification.repairs.additive.length > 0;
560
+
561
+ if (!willRename && !willAdd) {
562
+ return { options: existingOptions, repairs: classification.repairs };
563
+ }
564
+
565
+ // When conflicts are present, do not mutate the field; surface the conflict.
566
+ if (classification.repairs.conflicts.length > 0) {
567
+ return { options: existingOptions, repairs: classification.repairs };
568
+ }
569
+
570
+ const payload = await ghGraphql(UPDATE_PROJECT_FIELD, {
571
+ fieldId,
572
+ options: JSON.stringify(classification.options),
573
+ }, env, runChild);
574
+
575
+ const updatedField = payload?.data?.updateProjectV2Field?.projectV2Field;
576
+ if (!updatedField) {
577
+ throw Object.assign(
578
+ new Error("Failed to update Status field with column repairs"),
579
+ { code: "UPDATE_FIELD_FAILED" },
580
+ );
581
+ }
582
+
583
+ return { options: updatedField.options ?? classification.options, repairs: classification.repairs };
584
+ }
585
+
586
+ // ── Column auto-repair ───────────────────────────────────────────────────
587
+
588
+ const STANDARD_COLUMNS = [
589
+ { name: "Backlog", color: "GRAY", description: "" },
590
+ { name: "Next Up", color: "BLUE", description: "" },
591
+ { name: "In Progress", color: "YELLOW", description: "" },
592
+ { name: "Done", color: "GREEN", description: "" },
593
+ ];
594
+
595
+ const STANDARD_COLUMN_NAMES = STANDARD_COLUMNS.map((c) => c.name);
596
+
597
+ const EMPTY_REPAIRS = Object.freeze({ additive: [], renameCandidates: [], renamesApplied: [], conflicts: [] });
598
+
599
+ /**
600
+ * Auto-repair a Status field that is missing standard columns.
601
+ *
602
+ * Calls updateProjectV2Field to add missing columns while preserving
603
+ * any existing (non-standard) columns in their current order.
604
+ *
605
+ * Returns the updated field options (with IDs from the mutation response).
606
+ */
607
+ async function autoRepairColumns(
608
+ fieldId,
609
+ existingOptions,
610
+ env,
611
+ runChild,
612
+ ) {
613
+ const existingNames = new Set(existingOptions.map((o) => o.name));
614
+ const missingColumns = STANDARD_COLUMNS.filter(
615
+ (c) => !existingNames.has(c.name),
616
+ );
617
+
618
+ if (missingColumns.length === 0) {
619
+ // Nothing to repair — should not be called in this case
620
+ return existingOptions;
621
+ }
622
+
623
+ // Build full option list: existing options + missing standard columns appended
624
+ const fullOptions = [
625
+ ...existingOptions.map((o) => ({ name: o.name, color: o.color ?? "GRAY", description: o.description ?? "" })),
626
+ ...missingColumns,
627
+ ];
628
+
629
+ const payload = await ghGraphql(UPDATE_PROJECT_FIELD, {
630
+ fieldId,
631
+ options: JSON.stringify(fullOptions),
632
+ }, env, runChild);
633
+
634
+ const updatedField = payload?.data?.updateProjectV2Field?.projectV2Field;
635
+ if (!updatedField) {
636
+ throw Object.assign(
637
+ new Error("Failed to update Status field with missing columns"),
638
+ { code: "UPDATE_FIELD_FAILED" },
639
+ );
640
+ }
641
+
642
+ return updatedField.options ?? fullOptions;
643
+ }
644
+
645
+ // ── Exit code classification ────────────────────────────────────────────
646
+
647
+ function classifyExitCode(err) {
648
+ if (err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT") return 1;
649
+ return 2;
650
+ }
651
+
652
+ // ── Main logic ──────────────────────────────────────────────────────────
653
+
654
+ async function main(args, { env = process.env, runChild } = {}) {
655
+ const child = runChild ?? _runChild;
656
+ const repo = validateRepo(args.repo);
657
+ const [owner] = repo.split("/");
658
+ const title = args.title || "Dev Loop Queue"; // explicit default after settings fallback in runCli
659
+ const linkRepo = args.linkRepo || null;
660
+ if (linkRepo) validateRepo(linkRepo); // validate format early
661
+
662
+ // 1. Resolve owner (user or org)
663
+ const { id: ownerId, kind: ownerKind } = await resolveOwner(owner, env, child);
664
+
665
+ // 2. Look for existing project
666
+ const projects = await listAllProjects(owner, ownerKind, env, child);
667
+ let project;
668
+ if (args.project) {
669
+ project = projects.find((p) => p.number === args.project);
670
+ if (!project) {
671
+ throw Object.assign(
672
+ new Error(`Project #${args.project} not found under "${owner}". Use --title to create a new board.`),
673
+ { code: "PROJECT_NOT_FOUND" },
674
+ );
675
+ }
676
+ } else {
677
+ project = projects.find((p) => p.title === title);
678
+ }
679
+
680
+ if (project) {
681
+ // Project exists — verify Status field (paginated)
682
+ const fieldNodes = await listAllFields(project.id, env, child);
683
+ const statusField = fieldNodes.find(
684
+ (f) => f.name === "Status" && f.options,
685
+ );
686
+
687
+ if (statusField) {
688
+ // Always classify drift for the repairs object, then mutate only when authorized.
689
+ const { repairs } = await classifyAndRepairColumns(
690
+ statusField.id,
691
+ statusField.options,
692
+ args.repairRename ?? false,
693
+ env,
694
+ child,
695
+ );
696
+
697
+ let linkedRepo = null;
698
+ if (linkRepo) {
699
+ const repoId = await resolveRepoId(linkRepo, env, child);
700
+ await ghGraphql(LINK_PROJECT_TO_REPO, {
701
+ projectId: project.id,
702
+ repositoryId: repoId,
703
+ }, env, child);
704
+ linkedRepo = linkRepo;
705
+ }
706
+ return {
707
+ ok: true,
708
+ project: {
709
+ id: project.id,
710
+ number: project.number,
711
+ title: project.title,
712
+ url: project.url,
713
+ statusFieldId: statusField.id,
714
+ ...(linkedRepo ? { linkedRepo } : {}),
715
+ },
716
+ repairs,
717
+ };
718
+ }
719
+
720
+ // No Status field — create it
721
+ const createFieldPayload = await ghGraphql(CREATE_SINGLE_SELECT_FIELD, {
722
+ projectId: project.id,
723
+ }, env, child);
724
+ const newField = createFieldPayload?.data?.createProjectV2Field?.projectV2Field;
725
+ if (!newField) {
726
+ throw Object.assign(new Error("Failed to create Status field"), { code: "CREATE_FIELD_FAILED" });
727
+ }
728
+
729
+ let linkedRepo = null;
730
+ if (linkRepo) {
731
+ const repoId = await resolveRepoId(linkRepo, env, child);
732
+ await ghGraphql(LINK_PROJECT_TO_REPO, {
733
+ projectId: project.id,
734
+ repositoryId: repoId,
735
+ }, env, child);
736
+ linkedRepo = linkRepo;
737
+ }
738
+ return {
739
+ ok: true,
740
+ project: {
741
+ id: project.id,
742
+ number: project.number,
743
+ title: project.title,
744
+ url: project.url,
745
+ statusFieldId: newField.id,
746
+ ...(linkedRepo ? { linkedRepo } : {}),
747
+ },
748
+ repairs: EMPTY_REPAIRS,
749
+ };
750
+ }
751
+
752
+ // 3. Create project
753
+ const createPayload = await ghGraphql(CREATE_PROJECT, {
754
+ ownerId,
755
+ title,
756
+ }, env, child);
757
+ project = createPayload?.data?.createProjectV2?.projectV2;
758
+ if (!project) {
759
+ throw Object.assign(new Error("Failed to create project board"), { code: "CREATE_PROJECT_FAILED" });
760
+ }
761
+
762
+ // 4. Link to repo if --link-repo provided
763
+ let linkedRepo = null;
764
+ if (linkRepo) {
765
+ const repoId = await resolveRepoId(linkRepo, env, child);
766
+ await ghGraphql(LINK_PROJECT_TO_REPO, {
767
+ projectId: project.id,
768
+ repositoryId: repoId,
769
+ }, env, child);
770
+ linkedRepo = linkRepo;
771
+ }
772
+
773
+ // 5. Create Status field on new project
774
+ const createFieldPayload = await ghGraphql(CREATE_SINGLE_SELECT_FIELD, {
775
+ projectId: project.id,
776
+ }, env, child);
777
+ const newField = createFieldPayload?.data?.createProjectV2Field?.projectV2Field;
778
+ if (!newField) {
779
+ throw Object.assign(new Error("Failed to create Status field on new project"), { code: "CREATE_FIELD_FAILED" });
780
+ }
781
+
782
+ return {
783
+ ok: true,
784
+ project: {
785
+ id: project.id,
786
+ number: project.number,
787
+ title: project.title,
788
+ url: project.url,
789
+ statusFieldId: newField.id,
790
+ ...(linkedRepo ? { linkedRepo } : {}),
791
+ },
792
+ repairs: EMPTY_REPAIRS,
793
+ };
794
+ }
795
+
796
+ // ── CLI entrypoint ──────────────────────────────────────────────────────
797
+
798
+ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, cwd = process.cwd() } = {}) {
799
+ let args;
800
+ try {
801
+ args = parseArgs(argv);
802
+ } catch (err) {
803
+ stderr.write(`${formatCliError(err)}\n`);
804
+ process.exitCode = 1;
805
+ return;
806
+ }
807
+ if (args.help) {
808
+ stdout.write(USAGE);
809
+ return;
810
+ }
811
+
812
+ // Settings-based fallback for --project and --title
813
+ const settings = resolveSettings(cwd);
814
+ if (args.project === undefined && settings?.project) {
815
+ args.project = settings.project;
816
+ }
817
+ if (args.title === undefined && settings?.title) {
818
+ args.title = settings.title;
819
+ }
820
+
821
+ try {
822
+ const result = await main(args, { env });
823
+ stdout.write(JSON.stringify(result) + "\n");
824
+ } catch (err) {
825
+ stderr.write(`${formatCliError(err)}\n`);
826
+ process.exitCode = classifyExitCode(err);
827
+ }
828
+ }
829
+
830
+ if (isDirectCliRun(import.meta.url)) {
831
+ runCli(process.argv.slice(2)).catch((error) => {
832
+ process.stderr.write(`${formatCliError(error)}\n`);
833
+ process.exitCode = 2;
834
+ });
835
+ }
836
+
837
+ export { main, autoRepairColumns, resolveSettings, STANDARD_COLUMNS, STANDARD_COLUMN_NAMES };