dev-loops 0.7.1 → 0.8.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 (107) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
@@ -4,7 +4,8 @@ import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { resolveProjectSelector, findProject, applyDevloopsBoard } from "./_resolve-project.mjs";
5
5
  import { parseArgs } from "node:util";
6
6
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
7
- import { loadStateColumnMap, LOGICAL_COLUMN } from "@dev-loops/core/loop/queue-board-sync";
7
+ import { loadStateColumnMap, LOGICAL_COLUMN, nonSuccessBoardColumn } from "@dev-loops/core/loop/queue-board-sync";
8
+ import { decideEnqueueRefinementGate, detectIssueRefinementArtifact } from "@dev-loops/core/loop/issue-refinement-artifact";
8
9
 
9
10
  const USAGE = `Usage: dev-loops queue add --repo <owner/name> [--project <number|id>] --item <number>
10
11
  dev-loops project add … (back-compat alias for "queue add")
@@ -24,10 +25,20 @@ Options:
24
25
  default, honors queue.statusColumns.next_up). Sugar
25
26
  for --column <that column>; cannot be combined with
26
27
  a conflicting --column/--status.
28
+ --auto Headless mode. When an issue targets the pickup
29
+ (next_up) column without a refinement artifact
30
+ (Acceptance criteria/DoD/linked doc), divert it
31
+ to the non-pickup park column instead of failing.
32
+ Without --auto, the same case throws
33
+ MISSING_REFINEMENT_ARTIFACT.
27
34
  --help, -h Show this help.
28
35
 
29
36
  Output (stdout):
30
- JSON: { ok: true, item: { itemId, issueNumber, prNumber, status, alreadyPresent } }
37
+ JSON: { ok: true, item: { itemId, issueNumber, prNumber, status, alreadyPresent },
38
+ refinement? }
39
+ refinement (present only when the pickup-column gate ran): either
40
+ { refined: true } or, on a headless --auto divert,
41
+ { refined: false, diverted: true, requestedColumn, parkedColumn, reason, missing }.
31
42
 
32
43
  ${JQ_OUTPUT_USAGE}
33
44
 
@@ -36,6 +47,8 @@ Exit codes:
36
47
  1 — usage or argument error
37
48
  2 — GitHub API error / invalid --jq filter
38
49
  3 — project, field, column, or issue/PR not found
50
+ 4 — issue targets the pickup column without a refinement artifact
51
+ (MISSING_REFINEMENT_ARTIFACT; interactive only, --auto diverts instead)
39
52
  `.trim();
40
53
 
41
54
  function parseCliArgs(argv) {
@@ -58,6 +71,7 @@ function parseCliArgs(argv) {
58
71
  column: { type: "string" },
59
72
  status: { type: "string" },
60
73
  "next-up": { type: "boolean" },
74
+ auto: { type: "boolean" },
61
75
  help: { type: "boolean", short: "h" },
62
76
  ...JQ_OUTPUT_PARSE_OPTIONS,
63
77
  },
@@ -113,6 +127,12 @@ function parseCliArgs(argv) {
113
127
  }
114
128
  args.nextUp = true;
115
129
  break;
130
+ case "auto":
131
+ if (token.value !== undefined) {
132
+ throw Object.assign(new Error(`Unknown flag: ${token.rawName}=${token.value}`), { code: "INVALID_ARGS", usage: USAGE });
133
+ }
134
+ args.auto = true;
135
+ break;
116
136
  default: {
117
137
  if (matchJqOutputToken(token, args, (t) => requireValue(t, "--jq requires a filter"))) break;
118
138
  throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
@@ -367,6 +387,7 @@ function classifyExitCode(err) {
367
387
  err.code === "INVALID_STATUS" || err.code === "INVALID_ARGS") return 1;
368
388
  if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND" ||
369
389
  err.code === "ITEM_NOT_FOUND" || err.code === "CONTENT_NOT_FOUND") return 3;
390
+ if (err.code === "MISSING_REFINEMENT_ARTIFACT") return 4;
370
391
  return 2;
371
392
  }
372
393
 
@@ -502,6 +523,67 @@ async function main(args, { env = process.env, runChild, cwd = process.cwd() } =
502
523
  let issueNumber = fullResult.__typename === "Issue" ? itemNumber : null;
503
524
  let prNumber = fullResult.__typename === "PullRequest" ? itemNumber : null;
504
525
 
526
+ // 5b. Refinement-artifact gate: shift the draft-gate check
527
+ // LEFT to enqueue time so an un-refined issue never lands in the Next Up
528
+ // pickup column. Scoped to issues (not PRs) targeting the pickup column —
529
+ // create-pr.mjs auto-enqueues PRs into In Progress, which never trips this.
530
+ let refinement;
531
+ let effectiveTargetOption = targetOption;
532
+ let effectiveTargetStatus = targetStatus;
533
+ if (issueNumber !== null && targetStatus === nextUpColumn) {
534
+ const bodyResult = await child(
535
+ "gh",
536
+ ["issue", "view", String(issueNumber), "--repo", repo, "--json", "body"],
537
+ env,
538
+ );
539
+ if (bodyResult.code !== 0) {
540
+ const detail = bodyResult.stderr.trim() || `exit code ${bodyResult.code}`;
541
+ throw Object.assign(new Error(`gh issue view failed: ${detail}`), { code: "GH_API_ERROR" });
542
+ }
543
+ const bodyPayload = parseJsonText(bodyResult.stdout, { label: "gh issue view" });
544
+ const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
545
+ const artifact = detectIssueRefinementArtifact({ body, issueNumber });
546
+ const decision = decideEnqueueRefinementGate({ artifact, targetIsPickup: true, auto: !!args.auto });
547
+ if (decision.action === "block") {
548
+ throw Object.assign(new Error(decision.reason), {
549
+ code: "MISSING_REFINEMENT_ARTIFACT",
550
+ missing: decision.missing,
551
+ });
552
+ }
553
+ if (decision.action === "divert") {
554
+ const parkedColumn = nonSuccessBoardColumn(cwd);
555
+ if (parkedColumn === nextUpColumn) {
556
+ // A non-success park column equal to the pickup column would divert the
557
+ // un-refined item straight back into Next Up, defeating the gate. Fail
558
+ // closed on that misconfiguration rather than parking into pickup.
559
+ throw Object.assign(
560
+ new Error(`Park column "${parkedColumn}" (queue.nonSuccessStatus) is the pickup column; cannot divert an un-refined item into Next Up.`),
561
+ { code: "INVALID_STATUS" },
562
+ );
563
+ }
564
+ const parkedOption = statusField.options.find((o) => o.name === parkedColumn);
565
+ if (!parkedOption) {
566
+ const available = statusField.options.map((o) => o.name).join(", ");
567
+ throw Object.assign(
568
+ new Error(`Park column "${parkedColumn}" (queue.nonSuccessStatus) not found in Status field. Available: ${available}`),
569
+ { code: "COLUMN_NOT_FOUND" },
570
+ );
571
+ }
572
+ effectiveTargetOption = parkedOption;
573
+ effectiveTargetStatus = parkedColumn;
574
+ refinement = {
575
+ refined: false,
576
+ diverted: true,
577
+ requestedColumn: nextUpColumn,
578
+ parkedColumn,
579
+ reason: decision.reason,
580
+ missing: decision.missing,
581
+ };
582
+ } else {
583
+ refinement = { refined: true };
584
+ }
585
+ }
586
+
505
587
  // 6. Add item to project
506
588
  const addPayload = await ghGraphql(ADD_PROJECT_ITEM, {
507
589
  projectId: project.id,
@@ -518,7 +600,7 @@ async function main(args, { env = process.env, runChild, cwd = process.cwd() } =
518
600
  projectId: project.id,
519
601
  itemId: newItem.id,
520
602
  fieldId: statusField.id,
521
- optionId: targetOption.id,
603
+ optionId: effectiveTargetOption.id,
522
604
  }, env, child);
523
605
 
524
606
  const updated = updatePayload?.data?.updateProjectV2ItemFieldValue?.projectV2Item;
@@ -532,9 +614,10 @@ async function main(args, { env = process.env, runChild, cwd = process.cwd() } =
532
614
  itemId: newItem.id,
533
615
  issueNumber,
534
616
  prNumber,
535
- status: targetStatus,
617
+ status: effectiveTargetStatus,
536
618
  alreadyPresent: false,
537
619
  },
620
+ ...(refinement ? { refinement } : {}),
538
621
  };
539
622
  }
540
623
 
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
- import { runChild as _runChild } from "../_cli-primitives.mjs";
4
- import { resolveProjectSelector, findProject, applyDevloopsBoard } from "./_resolve-project.mjs";
2
+ import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
3
+ import { applyDevloopsBoard } from "./_resolve-project.mjs";
5
4
  import { parseArgs } from "node:util";
6
5
  import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
6
+ import { main, classifyExitCode } from "@dev-loops/core/projects/list-queue-items";
7
7
 
8
8
  const USAGE = `Usage: dev-loops queue list --repo <owner/name> [--project <number|id>] [--column <name>] [--limit <n>]
9
9
  dev-loops queue list --repo <owner/name> [--project <number|id>] --summary [--done-limit <n>]
@@ -139,380 +139,6 @@ function parseCliArgs(argv) {
139
139
  }
140
140
  return args;
141
141
  }
142
- // ── Validation ───────────────────────────────────────────────────────────
143
-
144
- const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
145
- const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
146
-
147
- function validateRepo(repo) {
148
- if (!repo || typeof repo !== "string") {
149
- throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
150
- }
151
- const trimmed = repo.trim();
152
- if (trimmed !== repo) {
153
- throw Object.assign(
154
- new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
155
- { code: "INVALID_REPO" },
156
- );
157
- }
158
- const slashIdx = repo.indexOf("/");
159
- if (slashIdx === -1) {
160
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
161
- }
162
- const owner = repo.slice(0, slashIdx);
163
- const name = repo.slice(slashIdx + 1);
164
- if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
165
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
166
- }
167
- return repo;
168
- }
169
-
170
- // ── API helpers ──────────────────────────────────────────────────────────
171
-
172
- async function ghGraphql(query, vars, env, runChild = _runChild) {
173
- const fieldArgs = [];
174
- for (const [key, value] of Object.entries(vars)) {
175
- fieldArgs.push("--field", `${key}=${value}`);
176
- }
177
- const result = await runChild(
178
- "gh",
179
- ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
180
- env,
181
- );
182
- if (result.code !== 0) {
183
- const detail = result.stderr.trim() || `exit code ${result.code}`;
184
- throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
185
- }
186
- const payload = parseJsonText(result.stdout);
187
- if (payload.errors && payload.errors.length > 0) {
188
- throw Object.assign(
189
- new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
190
- { code: "GRAPHQL_ERROR" },
191
- );
192
- }
193
- return payload;
194
- }
195
-
196
- // ── GraphQL fragments ────────────────────────────────────────────────────
197
-
198
- const GET_USER_ID = [
199
- "query($login:String!) {",
200
- " user(login:$login) { id }",
201
- "}"
202
- ].join("\n");
203
-
204
- const GET_ORG_ID = [
205
- "query($login:String!) {",
206
- " organization(login:$login) { id }",
207
- "}"
208
- ].join("\n");
209
-
210
- const LIST_USER_PROJECTS = [
211
- "query($login:String!, $after:String) {",
212
- " user(login:$login) {",
213
- " projectsV2(first:50, after:$after) {",
214
- " pageInfo { hasNextPage endCursor }",
215
- " nodes { id number title url }",
216
- " }",
217
- " }",
218
- "}"
219
- ].join("\n");
220
-
221
- const LIST_ORG_PROJECTS = [
222
- "query($login:String!, $after:String) {",
223
- " organization(login:$login) {",
224
- " projectsV2(first:50, after:$after) {",
225
- " pageInfo { hasNextPage endCursor }",
226
- " nodes { id number title url }",
227
- " }",
228
- " }",
229
- "}"
230
- ].join("\n");
231
-
232
- const GET_PROJECT_FIELDS = [
233
- "query($projectId:ID!, $after:String) {",
234
- " node(id:$projectId) {",
235
- " ... on ProjectV2 {",
236
- " fields(first:50, after:$after) {",
237
- " pageInfo { hasNextPage endCursor }",
238
- " nodes {",
239
- " ... on ProjectV2SingleSelectField {",
240
- " id name",
241
- " options { id name }",
242
- " }",
243
- " }",
244
- " }",
245
- " }",
246
- " }",
247
- "}"
248
- ].join("\n");
249
-
250
- const GET_PROJECT_ITEMS = [
251
- "query($projectId:ID!, $after:String) {",
252
- " node(id:$projectId) {",
253
- " ... on ProjectV2 {",
254
- " items(first:100, after:$after) {",
255
- " pageInfo { hasNextPage endCursor }",
256
- " nodes {",
257
- " id",
258
- " fieldValues(first:20) {",
259
- " nodes {",
260
- " ... on ProjectV2ItemFieldSingleSelectValue {",
261
- " field { ... on ProjectV2SingleSelectField { id name } }",
262
- " name",
263
- " }",
264
- " }",
265
- " }",
266
- " content {",
267
- " ... on Issue { number title url id }",
268
- " ... on PullRequest { number title url id }",
269
- " }",
270
- " }",
271
- " }",
272
- " }",
273
- " }",
274
- "}"
275
- ].join("\n");
276
-
277
- // ── Owner resolution ────────────────────────────────────────────────────
278
-
279
- async function resolveOwner(login, env, runChild) {
280
- const userPayload = await ghGraphql(GET_USER_ID, { login }, env, runChild);
281
- if (userPayload?.data?.user?.id) {
282
- return { id: userPayload.data.user.id, kind: "user" };
283
- }
284
- const orgPayload = await ghGraphql(GET_ORG_ID, { login }, env, runChild);
285
- if (orgPayload?.data?.organization?.id) {
286
- return { id: orgPayload.data.organization.id, kind: "org" };
287
- }
288
- throw Object.assign(
289
- new Error(`Could not resolve owner ID for "${login}"`),
290
- { code: "NO_USER_ID" },
291
- );
292
- }
293
-
294
- // ── Paginated project listing ────────────────────────────────────────────
295
-
296
- async function listAllProjects(login, kind, env, runChild) {
297
- const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
298
- const projects = [];
299
- let after = null;
300
- while (true) {
301
- const vars = { login };
302
- if (after) vars.after = after;
303
- const payload = await ghGraphql(query, vars, env, runChild);
304
- const connection = kind === "org"
305
- ? payload?.data?.organization?.projectsV2
306
- : payload?.data?.user?.projectsV2;
307
- const nodes = connection?.nodes ?? [];
308
- projects.push(...nodes);
309
- const pageInfo = connection?.pageInfo ?? {};
310
- if (!pageInfo.hasNextPage) break;
311
- if (!pageInfo.endCursor) {
312
- throw Object.assign(
313
- new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
314
- { code: "GH_API_ERROR" },
315
- );
316
- }
317
- after = pageInfo.endCursor;
318
- }
319
- return projects;
320
- }
321
-
322
- // ── Paginated field listing ──────────────────────────────────────────────
323
-
324
- async function listAllFields(projectId, env, runChild) {
325
- const fields = [];
326
- let after = null;
327
- while (true) {
328
- const vars = { projectId };
329
- if (after) vars.after = after;
330
- const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
331
- const connection = payload?.data?.node?.fields;
332
- const nodes = connection?.nodes ?? [];
333
- fields.push(...nodes);
334
- const pageInfo = connection?.pageInfo ?? {};
335
- if (!pageInfo.hasNextPage) break;
336
- if (!pageInfo.endCursor) {
337
- throw Object.assign(
338
- new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
339
- { code: "GH_API_ERROR" },
340
- );
341
- }
342
- after = pageInfo.endCursor;
343
- }
344
- return fields;
345
- }
346
-
347
- // ── Paginated item listing ───────────────────────────────────────────────
348
-
349
- async function listAllItems(projectId, env, runChild) {
350
- const items = [];
351
- let after = null;
352
- while (true) {
353
- const vars = { projectId };
354
- if (after) vars.after = after;
355
- const payload = await ghGraphql(GET_PROJECT_ITEMS, vars, env, runChild);
356
- const connection = payload?.data?.node?.items;
357
- const nodes = connection?.nodes ?? [];
358
- items.push(...nodes);
359
- const pageInfo = connection?.pageInfo ?? {};
360
- if (!pageInfo.hasNextPage) break;
361
- if (!pageInfo.endCursor) {
362
- throw Object.assign(
363
- new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
364
- { code: "GH_API_ERROR" },
365
- );
366
- }
367
- after = pageInfo.endCursor;
368
- }
369
- return items;
370
- }
371
-
372
- // ── Exit code classification ────────────────────────────────────────────
373
-
374
- function classifyExitCode(err) {
375
- if (err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT" || err.code === "INVALID_ARGS") return 1;
376
- if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND") return 3;
377
- return 2;
378
- }
379
-
380
- // ── Main logic ──────────────────────────────────────────────────────────
381
-
382
- async function main(args, { env = process.env, runChild } = {}) {
383
- const child = runChild ?? _runChild;
384
- const repo = validateRepo(args.repo);
385
- const [owner] = repo.split("/");
386
- const selector = resolveProjectSelector(args);
387
-
388
- // Mutual exclusion: --summary is the whole-board grouped view; --column/--limit
389
- // are flat-mode knobs. Combining them is ambiguous.
390
- if (args.summary && args.column) {
391
- throw Object.assign(
392
- new Error("--summary and --column are mutually exclusive (--column filters to one status; --summary groups the whole board)"),
393
- { code: "INVALID_ARGS" },
394
- );
395
- }
396
- if (args.summary && args.limit) {
397
- throw Object.assign(
398
- new Error("--summary and --limit are mutually exclusive; use --done-limit to cap the Done group (or terminal column if no Done column exists)"),
399
- { code: "INVALID_ARGS" },
400
- );
401
- }
402
- if (args.doneLimit !== undefined && !args.summary) {
403
- throw Object.assign(
404
- new Error("--done-limit only applies with --summary"),
405
- { code: "INVALID_ARGS" },
406
- );
407
- }
408
-
409
- // 1. Resolve owner (user or org).
410
- // URI refs encode owner+kind directly; skip the API round-trip for owner resolution.
411
- const projectOwner = selector.projectRef?.kind === "uri" ? selector.projectRef.owner : owner;
412
- const ownerKind = selector.projectRef?.kind === "uri"
413
- ? selector.projectRef.ownerKind
414
- : (await resolveOwner(owner, env, child)).kind;
415
-
416
- // 2. Resolve project
417
- const projects = await listAllProjects(projectOwner, ownerKind, env, child);
418
- const project = findProject(projects, selector, projectOwner);
419
-
420
- // 3. Resolve Status field and target column
421
- const fieldNodes = await listAllFields(project.id, env, child);
422
- const statusField = fieldNodes.find((f) => f.name === "Status" && f.options);
423
- if (!statusField) {
424
- throw Object.assign(
425
- new Error(`Status field not found in project "${project.title}" (number ${project.number})`),
426
- { code: "FIELD_NOT_FOUND" },
427
- );
428
- }
429
-
430
- let targetOption = null;
431
- if (args.column) {
432
- targetOption = statusField.options.find(
433
- (o) => o.name === args.column,
434
- );
435
- if (!targetOption) {
436
- const available = statusField.options.map((o) => o.name).join(", ");
437
- throw Object.assign(
438
- new Error(
439
- `Column "${args.column}" not found in Status field. Available: ${available}`,
440
- ),
441
- { code: "COLUMN_NOT_FOUND" },
442
- );
443
- }
444
- }
445
-
446
- // 4. List and filter items (ordered by position ascending, GraphQL default)
447
- const rawItems = await listAllItems(project.id, env, child);
448
-
449
- const results = [];
450
- for (const item of rawItems) {
451
- const content = item.content;
452
- if (!content) continue;
453
-
454
- // Determine status from field values
455
- let status = null;
456
- const fieldValues = item.fieldValues?.nodes ?? [];
457
- for (const fv of fieldValues) {
458
- if (fv && fv.field && fv.field.name === "Status") {
459
- status = fv.name;
460
- break;
461
- }
462
- }
463
-
464
- // Filter by column
465
- if (args.column && status !== args.column) continue;
466
-
467
- const isPr = content.__typename === "PullRequest";
468
-
469
- results.push({
470
- issueNumber: isPr ? null : content.number,
471
- prNumber: isPr ? content.number : null,
472
- title: content.title ?? null,
473
- url: content.url ?? null,
474
- itemId: item.id,
475
- contentId: content.id ?? null,
476
- status: status ?? null,
477
- });
478
- }
479
-
480
- // 5a. Summary mode: group by Status column in board option order.
481
- if (args.summary) {
482
- // Object.create(null): board option names are free text, so a column named
483
- // "__proto__"/"constructor" must be an own key, not touch Object.prototype.
484
- const groups = Object.create(null);
485
- for (const option of statusField.options) {
486
- groups[option.name] = { count: 0, items: [] };
487
- }
488
- for (const r of results) {
489
- // Items with null status belong to no Status option, so they are excluded here — matches --column filtering behavior.
490
- if (r.status === null) continue;
491
- const group = groups[r.status];
492
- if (!group) continue; // status value not among current board options
493
- group.count += 1;
494
- group.items.push(r);
495
- }
496
- if (args.doneLimit !== undefined) {
497
- // Cap "Done" per the issue AC; if no column is literally named "Done",
498
- // fall back to the last board option (conventionally the terminal column)
499
- // so --done-limit is honest instead of a silent no-op.
500
- const doneGroup = groups.Done ?? groups[statusField.options.at(-1)?.name];
501
- if (doneGroup) {
502
- doneGroup.items = doneGroup.items.slice(0, args.doneLimit);
503
- }
504
- }
505
- return { ok: true, groups };
506
- }
507
-
508
- // 5b. Flat mode: items are returned in position order from GraphQL. Apply limit.
509
- const limited = args.limit ? results.slice(0, args.limit) : results;
510
-
511
- return {
512
- ok: true,
513
- items: limited,
514
- };
515
- }
516
142
 
517
143
  // ── CLI entrypoint ──────────────────────────────────────────────────────
518
144