@dev-loops/core 0.2.7 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
6
6
  "exports": {
@@ -28,6 +28,7 @@
28
28
  "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
29
29
  "./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
30
30
  "./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
31
+ "./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
31
32
  "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
32
33
  "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
33
34
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
@@ -36,7 +37,9 @@
36
37
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
37
38
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
38
39
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
40
+ "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
39
41
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
42
+ "./loop/queue-membership": "./src/loop/queue-membership.mjs",
40
43
  "./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
41
44
  "./loop/queue-state": "./src/loop/queue-state.mjs",
42
45
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
@@ -53,6 +53,22 @@ const GatesConfig = z.strictObject({
53
53
  // `requireCi` is only behaviorally configurable for the draft gate.
54
54
  // preApproval always requires CI even if config repeats `requireCi`.
55
55
  preApproval: GateConfig.optional(),
56
+ // Fail-closed enforcement that a gate verdict was produced by the
57
+ // fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
58
+ // durable findings-log ledger), not an inline single-agent run. Default
59
+ // true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
60
+ // unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
61
+ requireFanoutEvidence: z.boolean().default(true),
62
+ // Cap on how many scoped `review` reviewers the gate fan-out spawns in
63
+ // parallel. When the resolved angle set exceeds this cap, the overflow runs
64
+ // in sequential batches and the degradation is recorded in the gate evidence.
65
+ maxFanoutReviewers: z.number().int().min(1).max(64).default(8),
66
+ // Post the consolidated gate fan-out findings as a visible, marker-tagged PR
67
+ // comment so they are auditable and Copilot/humans are aware of them. Default
68
+ // true (opt-out). The disposition ledger is written regardless; this flag only
69
+ // suppresses the PR comment when explicitly false. See
70
+ // docs/gate-review-sub-loop-contract.md.
71
+ postFindingsComments: z.boolean().default(true),
56
72
  });
57
73
 
58
74
  const AutonomyConfig = z.strictObject({
@@ -85,6 +101,7 @@ const QueueConfig = z.strictObject({
85
101
  reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
86
102
  projectNumber: z.number().int().positive().optional(),
87
103
  boardTitle: z.string().trim().min(1).optional(),
104
+ archiveOlderThanDays: z.number().int().positive().optional(),
88
105
  });
89
106
 
90
107
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
@@ -106,6 +123,9 @@ const FileGateConfig = GateConfig.partial();
106
123
  const FileGatesConfig = z.strictObject({
107
124
  draft: FileGateConfig.optional(),
108
125
  preApproval: FileGateConfig.optional(),
126
+ requireFanoutEvidence: z.boolean().optional(),
127
+ maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
128
+ postFindingsComments: z.boolean().optional(),
109
129
  });
110
130
 
111
131
  // Partial persona entries for file-level config (allows omitting fields)
@@ -230,6 +250,8 @@ const BUILTIN_PERSONAS = Object.freeze({
230
250
  "state-concurrency": { persona: "review", defaultModel: null },
231
251
  "renderer-security": { persona: "review", defaultModel: null },
232
252
  determinism: { persona: "review", defaultModel: null },
253
+ "acceptance-criteria": { persona: "review", defaultModel: null },
254
+ "ac-dod": { persona: "review", defaultModel: null },
233
255
  });
234
256
 
235
257
  const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
@@ -814,6 +836,66 @@ export function resolveGateConfig(config, gate) {
814
836
  };
815
837
  }
816
838
 
839
+ /**
840
+ * Resolve whether fan-out/fan-in review evidence is required for a gate verdict.
841
+ *
842
+ * Default-on (opt-out): enforcement is ON unless `gates.requireFanoutEvidence`
843
+ * is explicitly set to false. When ON, the pre-merge evidence check fails
844
+ * closed unless a required gate's recorded executionMode is "fanout_fanin" and
845
+ * a durable findings-log ledger exists for that gate + head SHA. Using a
846
+ * `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
847
+ * for programmatically-built config objects that bypass schema defaulting. See
848
+ * docs/gate-review-sub-loop-contract.md.
849
+ *
850
+ * @param {DevLoopConfig} config
851
+ * @returns {boolean}
852
+ */
853
+ export function resolveRequireFanoutEvidence(config) {
854
+ return config?.gates?.requireFanoutEvidence !== false;
855
+ }
856
+
857
+ /** Default parallel fan-out reviewer cap (mirrors GatesConfig.maxFanoutReviewers). */
858
+ export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
859
+
860
+ /**
861
+ * Resolve the parallel fan-out reviewer cap for the gate sub-loop.
862
+ *
863
+ * Returns the configured `gates.maxFanoutReviewers` when it is an integer in
864
+ * the schema-bounded range 1..64; otherwise the built-in default (8). Clamping
865
+ * here (not just the Zod schema) keeps programmatically-constructed config
866
+ * objects that bypass schema validation within the same bound. The fan-out
867
+ * spawns at most this many scoped `review` reviewers in parallel; overflow runs
868
+ * sequentially.
869
+ *
870
+ * @param {DevLoopConfig} config
871
+ * @returns {number}
872
+ */
873
+ export function resolveMaxFanoutReviewers(config) {
874
+ const raw = config?.gates?.maxFanoutReviewers;
875
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
876
+ return raw;
877
+ }
878
+ return DEFAULT_MAX_FANOUT_REVIEWERS;
879
+ }
880
+
881
+ /**
882
+ * Resolve whether the consolidated gate fan-out findings should be posted as a
883
+ * visible, marker-tagged PR comment.
884
+ *
885
+ * Returns true (post the comment) unless `gates.postFindingsComments` is
886
+ * explicitly set to false. Using a `!== false` test (rather than `=== true`)
887
+ * keeps the opt-out semantics robust for programmatically-built config objects
888
+ * that bypass schema defaulting. The disposition ledger is written regardless;
889
+ * this flag only suppresses the auditable PR comment. See
890
+ * docs/gate-review-sub-loop-contract.md.
891
+ *
892
+ * @param {DevLoopConfig} config
893
+ * @returns {boolean}
894
+ */
895
+ export function resolveGatePostFindingsComments(config) {
896
+ return config?.gates?.postFindingsComments !== false;
897
+ }
898
+
817
899
  /**
818
900
  * Resolve local implementation light mode config.
819
901
  *
@@ -382,6 +382,18 @@ personas:
382
382
  Do not block on formatting preferences other than checkbox correctness.
383
383
  defaultModel: null
384
384
 
385
+ acceptance-criteria:
386
+ persona: review
387
+ prompt: >-
388
+ Verify that each acceptance criterion and definition-of-done item from the
389
+ linked issue/PR is actually satisfied by the implementation — not merely
390
+ listed. For every criterion, cite the concrete code/test/behavior evidence
391
+ that meets it; flag any criterion that is unmet, only partially met, or
392
+ unverifiable from the diff as a blocking finding. Confirm definition-of-done
393
+ items (tests, docs, validation) are done and that declared non-goals are
394
+ respected (no scope creep).
395
+ defaultModel: null
396
+
385
397
  pr-checklist-matrix:
386
398
  persona: review
387
399
  prompt: >-
@@ -9,6 +9,7 @@
9
9
  const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
10
10
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
11
11
  const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
12
+ const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
12
13
 
13
14
  export function isCopilotLogin(login) {
14
15
  return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
@@ -63,6 +64,11 @@ function normalizeGateReviewHeadSha(value) {
63
64
  return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
64
65
  }
65
66
 
67
+ function normalizeGateExecutionMode(value) {
68
+ const normalized = stripOptionalCodeTicks(value).toLowerCase();
69
+ return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
70
+ }
71
+
66
72
  function parseGateReviewCommentFields(body) {
67
73
  if (typeof body !== "string" || body.trim().length === 0) {
68
74
  return null;
@@ -74,6 +80,8 @@ function parseGateReviewCommentFields(body) {
74
80
  verdict: null,
75
81
  findingsSummary: null,
76
82
  nextAction: null,
83
+ executionMode: null,
84
+ inlineReason: null,
77
85
  };
78
86
 
79
87
  for (const rawLine of body.split(/\r?\n/u)) {
@@ -112,6 +120,24 @@ function parseGateReviewCommentFields(body) {
112
120
  fields.nextAction = match[1].trim();
113
121
  continue;
114
122
  }
123
+
124
+ match = line.match(/^(?:[-*]\s*)?execution\s+mode\s*:\s*(.+)$/iu);
125
+ if (match) {
126
+ const rest = match[1].trim();
127
+ // Split on the first em-dash / en-dash / " - " separator to recover an
128
+ // optional inline reason: "inline_single_agent — <reason>".
129
+ const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
130
+ const modeToken = sepMatch ? sepMatch[1].trim() : rest;
131
+ const reasonToken = sepMatch ? sepMatch[2].trim() : "";
132
+ fields.executionMode = normalizeGateExecutionMode(modeToken);
133
+ // Only record an inline reason for inline_single_agent. A trailing
134
+ // "— text" on a fanout_fanin (or invalid) mode line must not surface an
135
+ // inconsistent mode/reason pair, so leave inlineReason null otherwise.
136
+ if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
137
+ fields.inlineReason = reasonToken;
138
+ }
139
+ continue;
140
+ }
115
141
  }
116
142
 
117
143
  // Lenient fallback: detect gate name and head SHA anywhere in body
@@ -178,6 +204,8 @@ export function parseGateReviewCommentMarkerBody(body) {
178
204
  verdict: fields.verdict,
179
205
  findingsSummary: fields.findingsSummary,
180
206
  nextAction: fields.nextAction,
207
+ executionMode: fields.executionMode,
208
+ inlineReason: fields.inlineReason,
181
209
  contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
182
210
  };
183
211
  }
@@ -205,6 +233,8 @@ export function summarizeGateReviewComments(comments) {
205
233
  verdict: parsed.verdict,
206
234
  findingsSummary: parsed.findingsSummary,
207
235
  nextAction: parsed.nextAction,
236
+ executionMode: parsed.executionMode ?? null,
237
+ inlineReason: parsed.inlineReason ?? null,
208
238
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
209
239
  commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
210
240
  updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
@@ -253,6 +283,8 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
253
283
  verdict: parsed.verdict,
254
284
  findingsSummary: parsed.findingsSummary,
255
285
  nextAction: parsed.nextAction,
286
+ executionMode: parsed.executionMode ?? null,
287
+ inlineReason: parsed.inlineReason ?? null,
256
288
  contractComplete: parsed.contractComplete,
257
289
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
258
290
  commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
@@ -274,6 +306,39 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
274
306
  return summary;
275
307
  }
276
308
 
309
+ /**
310
+ * Resolve the draft-gate round-reset timestamp (ms) used to suppress stale Copilot
311
+ * review rounds from the count (#896 consistency).
312
+ *
313
+ * When the draft gate was re-passed clean on a DIFFERENT head than the current one,
314
+ * only Copilot reviews submitted after that re-pass should count toward the round
315
+ * cap. Returning the re-pass `updatedAt` (ms) lets {@link summarizeCopilotReviews}
316
+ * drop earlier rounds. Returns null when no reset applies (no clean draft gate, or
317
+ * the clean draft gate is already on the current head).
318
+ *
319
+ * Both detect-pr-gate-coordination-state and request-copilot-review must derive the
320
+ * reset identically, or the two scripts disagree on the completed round count and
321
+ * the cap (the inconsistency reported in #896). This is the single shared source.
322
+ *
323
+ * @param {object} params
324
+ * @param {{ verdict?: string|null, headSha?: string|null, updatedAt?: string|null }|null} params.draftGate
325
+ * @param {string|null} params.currentHeadSha
326
+ * @returns {number|null} reset timestamp in ms, or null
327
+ */
328
+ export function resolveDraftGateRoundResetMs({ draftGate, currentHeadSha } = {}) {
329
+ const draftGateHeadSha = typeof draftGate?.headSha === "string" ? draftGate.headSha : null;
330
+ const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
331
+ && typeof currentHeadSha === "string"
332
+ && currentHeadSha.startsWith(draftGateHeadSha);
333
+ if (draftGate?.verdict === "clean"
334
+ && typeof draftGateHeadSha === "string"
335
+ && !draftGateOnCurrentHead
336
+ && typeof draftGate?.updatedAt === "string") {
337
+ return normalizeTimestamp(draftGate.updatedAt);
338
+ }
339
+ return null;
340
+ }
341
+
277
342
  export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs } = {}) {
278
343
  const allReviews = Array.isArray(reviews) ? reviews : [];
279
344
  const copilotReviews = allReviews.filter((review) => isCopilotLogin(review?.author?.login));
@@ -0,0 +1,222 @@
1
+ /**
2
+ * gate-fanin.mjs — pure fan-in consolidation + cap/batch planning for the
3
+ * gate-review fork sub-loop (epic #867, Phase 3 / #878).
4
+ *
5
+ * IMPORTANT: this module is PURE. It performs no I/O and never spawns agents.
6
+ * Spawning the per-angle scoped `review` subagents is an agent-orchestrated
7
+ * skill procedure (a node script cannot spawn Claude subagents). This module
8
+ * only consolidates the structured per-angle findings artifacts the fan-out
9
+ * produced, decides the gate verdict, plans the parallel/sequential batching of
10
+ * the fan-out, and maps consolidated findings into the `--findings` JSON shape
11
+ * understood by scripts/github/write-gate-findings-log.mjs.
12
+ *
13
+ * Per-angle review artifact shape (produced by the scoped `review` agent):
14
+ * {
15
+ * angle: string,
16
+ * verdict: "clean" | "findings_present",
17
+ * findings: [{ severity, file?, line?, summary, recommendation? }]
18
+ * }
19
+ *
20
+ * Severity vocabulary (mirrors write-gate-findings-log.mjs):
21
+ * "must-fix" | "worth-fixing-now" | "defer"
22
+ */
23
+
24
+ const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
25
+ const VALID_VERDICTS = new Set(["clean", "findings_present"]);
26
+
27
+ /**
28
+ * Default cap on parallel fan-out reviewers when a caller does not supply one.
29
+ * Mirrors the config default (gates.maxFanoutReviewers).
30
+ */
31
+ export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
32
+
33
+ /**
34
+ * Validate a single per-angle review result. Returns an error string when the
35
+ * result is malformed, or null when it is well-formed.
36
+ *
37
+ * @param {unknown} result
38
+ * @returns {string|null}
39
+ */
40
+ function validateAngleResult(result) {
41
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
42
+ return "angle result must be an object";
43
+ }
44
+ const r = /** @type {Record<string, unknown>} */ (result);
45
+ if (typeof r.angle !== "string" || r.angle.trim().length === 0) {
46
+ return "angle result is missing a non-empty 'angle'";
47
+ }
48
+ if (typeof r.verdict !== "string" || !VALID_VERDICTS.has(r.verdict)) {
49
+ return `angle '${r.angle}' has invalid verdict (expected clean|findings_present)`;
50
+ }
51
+ if (!Array.isArray(r.findings)) {
52
+ return `angle '${r.angle}' is missing a 'findings' array`;
53
+ }
54
+ for (const f of r.findings) {
55
+ if (!f || typeof f !== "object" || Array.isArray(f)) {
56
+ return `angle '${r.angle}' has a non-object finding`;
57
+ }
58
+ const finding = /** @type {Record<string, unknown>} */ (f);
59
+ if (typeof finding.severity !== "string" || !VALID_SEVERITIES.has(finding.severity)) {
60
+ return `angle '${r.angle}' has a finding with invalid severity (expected must-fix|worth-fixing-now|defer)`;
61
+ }
62
+ if (typeof finding.summary !== "string" || finding.summary.trim().length === 0) {
63
+ return `angle '${r.angle}' has a finding without a summary`;
64
+ }
65
+ }
66
+ // findings_present must carry at least one finding; clean must carry none.
67
+ if (r.verdict === "findings_present" && r.findings.length === 0) {
68
+ return `angle '${r.angle}' reported findings_present but has no findings`;
69
+ }
70
+ if (r.verdict === "clean" && r.findings.length > 0) {
71
+ return `angle '${r.angle}' reported clean but carries findings`;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Consolidate the parallel per-angle review results into one gate verdict +
78
+ * a merged, flattened findings list. Pure.
79
+ *
80
+ * Verdict rules:
81
+ * - "blocked": any angle result is malformed/missing (the gate could not
82
+ * produce a trustworthy verdict).
83
+ * - "clean": all results valid AND no finding carries a severity present in
84
+ * `blockCleanOnFindingSeverities`.
85
+ * - "findings_present": all results valid AND at least one finding carries a
86
+ * blocking severity.
87
+ *
88
+ * @param {object} input
89
+ * @param {Array<unknown>} input.angleResults — per-angle review artifacts
90
+ * @param {string[]} [input.blockCleanOnFindingSeverities] — blocking severities (default ["must-fix"])
91
+ * @returns {{
92
+ * verdict: "clean"|"findings_present"|"blocked",
93
+ * findings: Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>,
94
+ * counts: { angles: number, findings: number, blocking: number, bySeverity: Record<string, number> },
95
+ * malformed: Array<{ index: number, reason: string }>
96
+ * }}
97
+ */
98
+ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities } = {}) {
99
+ const results = Array.isArray(angleResults) ? angleResults : [];
100
+ const blocking = new Set(
101
+ Array.isArray(blockCleanOnFindingSeverities) && blockCleanOnFindingSeverities.length > 0
102
+ ? blockCleanOnFindingSeverities
103
+ : ["must-fix"],
104
+ );
105
+
106
+ const malformed = [];
107
+ results.forEach((r, index) => {
108
+ const err = validateAngleResult(r);
109
+ if (err) malformed.push({ index, reason: err });
110
+ });
111
+
112
+ const bySeverity = { "must-fix": 0, "worth-fixing-now": 0, "defer": 0 };
113
+ /** @type {Array<{severity: string, angle: string, summary: string, file?: string, line?: number, recommendation?: string, disposition: string}>} */
114
+ const findings = [];
115
+ let blockingCount = 0;
116
+
117
+ if (malformed.length === 0) {
118
+ for (const r of results) {
119
+ const angle = r.angle.trim();
120
+ for (const f of r.findings) {
121
+ const isBlocking = blocking.has(f.severity);
122
+ if (isBlocking) blockingCount += 1;
123
+ bySeverity[f.severity] += 1;
124
+ const entry = {
125
+ severity: f.severity,
126
+ angle,
127
+ summary: String(f.summary).trim(),
128
+ // Blocking findings default to accepted-for-fix; non-blocking default
129
+ // to deferred. The fix cycle / operator can override the disposition.
130
+ disposition: isBlocking ? "accepted-for-fix" : "deferred",
131
+ };
132
+ if (typeof f.file === "string" && f.file.trim().length > 0) entry.file = f.file.trim();
133
+ if (typeof f.line === "number" && Number.isFinite(f.line)) entry.line = f.line;
134
+ if (typeof f.recommendation === "string" && f.recommendation.trim().length > 0) {
135
+ entry.recommendation = f.recommendation.trim();
136
+ }
137
+ findings.push(entry);
138
+ }
139
+ }
140
+ }
141
+
142
+ let verdict;
143
+ if (malformed.length > 0) {
144
+ verdict = "blocked";
145
+ } else if (blockingCount > 0) {
146
+ verdict = "findings_present";
147
+ } else {
148
+ verdict = "clean";
149
+ }
150
+
151
+ return {
152
+ verdict,
153
+ findings,
154
+ counts: {
155
+ angles: results.length,
156
+ findings: findings.length,
157
+ blocking: blockingCount,
158
+ bySeverity,
159
+ },
160
+ malformed,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Map consolidated findings into the `--findings` JSON shape consumed by
166
+ * scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
167
+ * disposition, optional files). Pure.
168
+ *
169
+ * @param {Array<{severity: string, angle: string, summary: string, file?: string, disposition?: string}>} findings
170
+ * @returns {Array<{severity: string, angle: string, summary: string, disposition?: string, files?: string[]}>}
171
+ */
172
+ export function toFindingsLogShape(findings) {
173
+ const list = Array.isArray(findings) ? findings : [];
174
+ return list.map((f) => {
175
+ const entry = {
176
+ severity: f.severity,
177
+ angle: f.angle,
178
+ summary: f.summary,
179
+ };
180
+ if (typeof f.disposition === "string" && f.disposition.trim().length > 0) {
181
+ entry.disposition = f.disposition.trim();
182
+ }
183
+ if (typeof f.file === "string" && f.file.trim().length > 0) {
184
+ entry.files = [f.file.trim()];
185
+ } else if (Array.isArray(f.files)) {
186
+ const files = f.files.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => x.trim());
187
+ if (files.length > 0) entry.files = files;
188
+ }
189
+ return entry;
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Plan how a resolved angle set fans out across the reviewer cap. Pure.
195
+ *
196
+ * When `angles.length <= maxReviewers`, all reviewers run in a single parallel
197
+ * batch (no degradation). When it exceeds the cap, the overflow is split into
198
+ * sequential batches of at most `maxReviewers` each, and `degraded` is true so
199
+ * the skill can record the sequential degradation in the gate evidence.
200
+ *
201
+ * @param {string[]} angles
202
+ * @param {number} [maxReviewers] — default DEFAULT_MAX_FANOUT_REVIEWERS (8)
203
+ * @returns {{ batches: string[][], degraded: boolean }}
204
+ */
205
+ export function planFanoutBatches(angles, maxReviewers = DEFAULT_MAX_FANOUT_REVIEWERS) {
206
+ const list = Array.isArray(angles)
207
+ ? angles.filter((a) => typeof a === "string" && a.trim().length > 0).map((a) => a.trim())
208
+ : [];
209
+ const cap = Number.isInteger(maxReviewers) && maxReviewers > 0
210
+ ? maxReviewers
211
+ : DEFAULT_MAX_FANOUT_REVIEWERS;
212
+
213
+ if (list.length === 0) {
214
+ return { batches: [], degraded: false };
215
+ }
216
+
217
+ const batches = [];
218
+ for (let i = 0; i < list.length; i += cap) {
219
+ batches.push(list.slice(i, i + cap));
220
+ }
221
+ return { batches, degraded: batches.length > 1 };
222
+ }
@@ -479,6 +479,11 @@ function buildResult({
479
479
  * @param {number} params.copilotReviewRoundCount
480
480
  * @param {number|null} params.maxCopilotRounds
481
481
  * @param {boolean} params.sameHeadCleanConverged
482
+ * @param {boolean} [params.roundCapCleanFallback=false] - interpreter resolved the
483
+ * round-cap clean fallback (#896): rounds exhausted + clean threads + green CI on
484
+ * the current head, including a post-cap head Copilot has not (and will not)
485
+ * re-review. No further Copilot round is permitted, so the formal-request guard
486
+ * must not fire — the pre_approval_gate reviews the post-cap head (per #848).
482
487
  * @param {string} params.gateBoundary - current gate boundary
483
488
  * @returns {boolean}
484
489
  */
@@ -488,6 +493,7 @@ export function shouldGuardCopilotReviewRequest({
488
493
  copilotReviewEverFormallyRequested = false,
489
494
  maxCopilotRounds = null,
490
495
  sameHeadCleanConverged = false,
496
+ roundCapCleanFallback = false,
491
497
  gateBoundary,
492
498
  }) {
493
499
  const gateBoundariesRequiringCopilotFormalRequest = new Set([
@@ -513,12 +519,17 @@ export function shouldGuardCopilotReviewRequest({
513
519
  if (copilotReviewEverFormallyRequested) {
514
520
  return false;
515
521
  }
516
- // Round-cap clean fallback: exhausted rounds + clean converged
517
- // does not require a formal re-request.
522
+ // Round-cap clean fallback: exhausted rounds + clean converged does not require
523
+ // a formal re-request. This covers two shapes of "clean at the cap":
524
+ // - sameHeadCleanConverged: the current head itself carries a clean Copilot review;
525
+ // - roundCapCleanFallback (#896): the head is clean (zero unresolved threads + green
526
+ // CI) but Copilot has NOT reviewed THIS head (e.g. a post-cap commit). No further
527
+ // Copilot round is permitted, so forcing a formal request would dead-end the loop;
528
+ // the pre_approval_gate reviews the post-cap head instead (per #848).
518
529
  const roundCapReached = maxCopilotRounds !== null
519
530
  && typeof copilotReviewRoundCount === "number"
520
531
  && copilotReviewRoundCount >= maxCopilotRounds;
521
- if (roundCapReached && sameHeadCleanConverged) {
532
+ if (roundCapReached && (sameHeadCleanConverged || roundCapCleanFallback)) {
522
533
  return false;
523
534
  }
524
535
  return true;
@@ -1266,6 +1277,175 @@ function evaluatePrGateCoordinationCore(input = {}) {
1266
1277
  });
1267
1278
  }
1268
1279
 
1280
+ // Round-cap clean fallback (#896, #848): the Copilot review round cap is
1281
+ // exhausted and the current head is clean (zero unresolved threads + green CI)
1282
+ // — including a POST-CAP head Copilot has not (and will not) re-review, since
1283
+ // no further Copilot round is permitted. Re-requesting review is illegal here,
1284
+ // so this MUST NOT dead-end at READY_TO_REREQUEST_REVIEW. It routes to the
1285
+ // pre_approval_gate, which reviews the post-cap head itself (per #848). The CI
1286
+ // guards below still hold (failing / credibly-green CI blocks), and conflicts /
1287
+ // blocked states are handled earlier, so genuinely-blocked states still forbid
1288
+ // pre_approval. Mirrors LOW_SIGNAL_CONVERGED routing with round-cap reasoning.
1289
+ if (effectiveLifecycleState === STATE.ROUND_CAP_CLEAN_FALLBACK) {
1290
+ if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1291
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
1292
+ pushUnique(forbiddenActions, postDraftForbidden);
1293
+ return buildResult({
1294
+ repo: input.repo ?? null,
1295
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1296
+ currentHeadSha,
1297
+ lifecycleState: STATE.BLOCKED_NEEDS_USER_DECISION,
1298
+ loopDisposition: DISPOSITION.BLOCKED,
1299
+ gateBoundary: PR_CHECKPOINT.BLOCKED,
1300
+ draftGateAlreadySatisfied: true,
1301
+ draftGate,
1302
+ preApprovalGate,
1303
+ allowedNextActions,
1304
+ forbiddenActions,
1305
+ nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
1306
+ reason: ciStatus === "crediblyGreen"
1307
+ ? "The Copilot round cap is exhausted, but the current head has unconfirmed CI (credibly green), so gate progression remains blocked until CI is confirmed green."
1308
+ : "The Copilot round cap is exhausted, but the current head still has failing CI, so gate progression remains blocked until the failing checks are fixed and revalidated.",
1309
+ mergeStateStatus,
1310
+ conflictFiles,
1311
+ refinementArtifact,
1312
+ });
1313
+ }
1314
+ if (ciStatus === "pending" || ciStatus === "none") {
1315
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
1316
+ pushUnique(forbiddenActions, postDraftForbidden);
1317
+ return buildResult({
1318
+ repo: input.repo ?? null,
1319
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1320
+ currentHeadSha,
1321
+ lifecycleState: STATE.WAITING_FOR_CI,
1322
+ loopDisposition: DISPOSITION.PENDING,
1323
+ gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
1324
+ draftGateAlreadySatisfied: true,
1325
+ draftGate,
1326
+ preApprovalGate,
1327
+ allowedNextActions,
1328
+ forbiddenActions,
1329
+ nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_CI,
1330
+ reason: "The Copilot round cap is exhausted, but the current head does not yet have green or credibly green CI, so `pre_approval_gate` remains illegal until CI settles.",
1331
+ mergeStateStatus,
1332
+ conflictFiles,
1333
+ refinementArtifact,
1334
+ });
1335
+ }
1336
+ if (preApprovalGate.currentHeadClean) {
1337
+ const titleMarkers = findBlockingTitleMarkers(prTitle);
1338
+ if (titleMarkers.length > 0) {
1339
+ return buildTitleMarkerBlockedResult({
1340
+ input,
1341
+ currentHeadSha,
1342
+ draftGateAlreadySatisfied: true,
1343
+ draftGate,
1344
+ preApprovalGate,
1345
+ mergeStateStatus,
1346
+ conflictFiles,
1347
+ markers: titleMarkers,
1348
+ refinementArtifact,
1349
+ });
1350
+ }
1351
+ if (requireRetrospectiveGate) {
1352
+ const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1353
+ if (!retrospectiveGate.approved) {
1354
+ return buildRetrospectiveGatePendingResult({
1355
+ input,
1356
+ currentHeadSha,
1357
+ draftGateAlreadySatisfied: true,
1358
+ draftGate,
1359
+ preApprovalGate,
1360
+ mergeStateStatus,
1361
+ conflictFiles,
1362
+ reason: `Merge remains blocked: retrospective_gate_pending. ${retrospectiveGate.reason}`,
1363
+ refinementArtifact,
1364
+ });
1365
+ }
1366
+ }
1367
+
1368
+ // Mirror LOW_SIGNAL_CONVERGED (#579): a clean current head with no clean
1369
+ // draft_gate evidence must reconcile the draft gate rather than jump to
1370
+ // final approval. This keeps the core handler consistent with the
1371
+ // detect-pr-gate-coordination-state #579 post-pass, which unconditionally
1372
+ // downgrades FINAL_APPROVAL_READY → DRAFT_GATE_NEEDED when
1373
+ // draftGate.cleanEvidenceExists is false (no ROUND_CAP_CLEAN_FALLBACK
1374
+ // exemption). Without this guard the final-approval-without-draft-gate
1375
+ // branch is dead through the real script and asserts behavior it never
1376
+ // produces.
1377
+ if (!draftGate.cleanEvidenceExists) {
1378
+ return buildDraftGateNeededForMergeResult({
1379
+ input,
1380
+ currentHeadSha,
1381
+ draftGate,
1382
+ preApprovalGate,
1383
+ mergeStateStatus,
1384
+ conflictFiles,
1385
+ underlyingReason: "Round-cap clean fallback has clean pre_approval_gate but no clean draft_gate evidence.",
1386
+ refinementArtifact,
1387
+ effectiveLifecycleState,
1388
+ });
1389
+ }
1390
+
1391
+ // Round-cap clean fallback with clean draft_gate evidence reaches final
1392
+ // approval when the current head also has clean pre_approval_gate evidence.
1393
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL]);
1394
+ pushUnique(forbiddenActions, [
1395
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1396
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
1397
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
1398
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1399
+ ]);
1400
+ return buildResult({
1401
+ repo: input.repo ?? null,
1402
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1403
+ currentHeadSha,
1404
+ lifecycleState: effectiveLifecycleState,
1405
+ loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1406
+ gateBoundary: PR_CHECKPOINT.FINAL_APPROVAL_READY,
1407
+ draftGateAlreadySatisfied: true,
1408
+ draftGate,
1409
+ preApprovalGate,
1410
+ allowedNextActions,
1411
+ forbiddenActions,
1412
+ nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
1413
+ reason: `Round-cap clean fallback accepted as draft gate equivalent (${copilotReviewRoundCount}/${maxCopilotRounds} rounds, zero unresolved threads, ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI). The current head has clean \`pre_approval_gate\` evidence, so the PR is at the final approval boundary.`,
1414
+ mergeStateStatus,
1415
+ conflictFiles,
1416
+ refinementArtifact,
1417
+ });
1418
+ }
1419
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE]);
1420
+ pushUnique(forbiddenActions, [
1421
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
1422
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
1423
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
1424
+ PR_CHECKPOINT_ACTION.REREQUEST_COPILOT_REVIEW,
1425
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
1426
+ ]);
1427
+ return buildResult({
1428
+ repo: input.repo ?? null,
1429
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1430
+ currentHeadSha,
1431
+ lifecycleState: effectiveLifecycleState,
1432
+ loopDisposition: loopDisposition ?? DISPOSITION.CLEAN_CONVERGED,
1433
+ gateBoundary: PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
1434
+ draftGateAlreadySatisfied: true,
1435
+ draftGate,
1436
+ preApprovalGate,
1437
+ allowedNextActions,
1438
+ forbiddenActions,
1439
+ nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
1440
+ reason: `The Copilot round limit is exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}), and the current head has zero unresolved threads with ${ciStatus === "crediblyGreen" ? "credibly green" : "green"} CI, so \`pre_approval_gate\` fallback is now the next legal boundary (it reviews the current post-cap head; no further Copilot re-request is permitted).`,
1441
+ mergeStateStatus,
1442
+ conflictFiles,
1443
+ refinementArtifact,
1444
+ gateEvidenceNote: buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopilotRounds }),
1445
+ copilotReviewRoundCount,
1446
+ });
1447
+ }
1448
+
1269
1449
  if (effectiveLifecycleState === STATE.LOW_SIGNAL_CONVERGED) {
1270
1450
  if (ciStatus === "failure" || ciStatus === "crediblyGreen") {
1271
1451
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
@@ -370,7 +370,10 @@ export async function syncBoardStatus(
370
370
  const moveItem = dependencies.moveQueueItem ?? moveQueueItemMain;
371
371
  try {
372
372
  const result = await moveItem(
373
- { repo, project: projectNumber, item: itemNumber, toColumn: targetColumn },
373
+ // move-queue-item validates project + item as string refs (CLI contract);
374
+ // resolveProjectNumber yields a number and itemNumber is numeric, so
375
+ // stringify both.
376
+ { repo, project: String(projectNumber), item: String(itemNumber), toColumn: targetColumn },
374
377
  { env, runChild: dependencies.runChild },
375
378
  );
376
379
  return { ok: true, skipped: false, result };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Queue membership reconciliation (issue #864).
3
+ *
4
+ * A configured GitHub Projects board is the authoritative source of queue
5
+ * MEMBERSHIP (which issues to work) and ordering — not just status. Before a
6
+ * queue run, this module folds the board's "Next Up" items into the local
7
+ * `.pi/dev-loop-queue.json` entries so the board drives membership, and reports
8
+ * a clear, non-misleading emptiness verdict.
9
+ *
10
+ * This is the testable orchestration seam used by `scripts/loop/run-queue.mjs`.
11
+ * Board config loading, Next Up resolution, and queue persistence are all
12
+ * injectable so the policy can be exercised without GitHub or the filesystem.
13
+ */
14
+
15
+ import { loadBoardConfig } from "./queue-board-sync.mjs";
16
+ import { resolveNextUpOrder } from "./queue-board-ordering.mjs";
17
+ import { reconcileEntriesFromBoard, writeQueue, pendingEntries } from "./queue-state.mjs";
18
+
19
+ /**
20
+ * Reconcile a configured board's Next Up membership into the queue, then
21
+ * classify emptiness.
22
+ *
23
+ * Behavior:
24
+ * - Board NOT configured: no reconcile. Emptiness is judged purely on the
25
+ * local queue (preserving the legacy "Queue is empty" behavior).
26
+ * - Board configured: resolve Next Up targets and reconcile them in. If the
27
+ * resolver fails-open (returns no targets) the board membership simply
28
+ * contributes nothing; we never crash. Newly added targets are persisted
29
+ * via `writeQueue`.
30
+ *
31
+ * Emptiness verdict (`emptiness`):
32
+ * - `null` — there is pending work; the caller should run the queue.
33
+ * - "queue_empty" — board not configured and the local queue is empty
34
+ * (legacy "Queue is empty" message).
35
+ * - "board_empty" — board IS configured, Next Up resolved successfully
36
+ * (reason == null) but is genuinely empty, and there is
37
+ * no pending local work. Distinct from the misleading
38
+ * generic empty case.
39
+ * - "board_unavailable" — board IS configured but Next Up resolution failed
40
+ * (fail-open: empty order WITH a non-null reason) and
41
+ * the local queue had no pending work to fall back to.
42
+ * This must NOT be reported as "board_empty" — the
43
+ * board may well have items we simply could not read.
44
+ *
45
+ * @param {string} repoRoot
46
+ * @param {string} repo - "owner/name"
47
+ * @param {{version:number, entries:Array}} queue
48
+ * @param {object} [deps]
49
+ * @param {(repoRoot:string)=>{enabled:boolean}} [deps.loadBoardConfig]
50
+ * @param {(repo:string, repoRoot:string, env:object, d:object)=>Promise<{ok:boolean, order:number[], reason:?string}>} [deps.resolveNextUpOrder]
51
+ * @param {(repoRoot:string, queue:object)=>Promise<void>} [deps.writeQueue]
52
+ * @param {(msg:string)=>void} [deps.log]
53
+ * @returns {Promise<{queue:object, added:number[], boardConfigured:boolean, emptiness:(null|"queue_empty"|"board_empty"|"board_unavailable"), reason:(string|null)}>}
54
+ */
55
+ export async function reconcileBoardMembership(repoRoot, repo, queue, deps = {}) {
56
+ const loadConfig = deps.loadBoardConfig ?? loadBoardConfig;
57
+ const resolveOrder = deps.resolveNextUpOrder ?? resolveNextUpOrder;
58
+ const persist = deps.writeQueue ?? writeQueue;
59
+ const log = typeof deps.log === "function" ? deps.log : (msg) => console.error(msg);
60
+
61
+ let boardConfigured = false;
62
+ try {
63
+ const config = loadConfig(repoRoot);
64
+ boardConfigured = Boolean(config?.enabled);
65
+ // loadBoardConfig does not throw on read/parse failures; it reports them
66
+ // via `{ enabled: false, reason: ... }`. Surface that reason so a genuine
67
+ // config read/parse error is visible instead of being silently treated as
68
+ // "board not configured". The ordinary "board not configured" case carries
69
+ // no reason and must stay quiet.
70
+ if (!boardConfigured && config?.reason) {
71
+ log(`[queue-membership] board config unavailable (fail-open): ${config.reason}`);
72
+ }
73
+ } catch (err) {
74
+ // Config read errors must not crash the queue; treat as unconfigured.
75
+ log(`[queue-membership] board config read failed (fail-open): ${err?.message ?? err}`);
76
+ boardConfigured = false;
77
+ }
78
+
79
+ let added = [];
80
+ let reason = null;
81
+ // True when the board is configured but Next Up could not be resolved: the
82
+ // resolver is fail-open, so this surfaces as an empty order WITH a non-null
83
+ // reason (or a thrown error caught below). We must not confuse this with a
84
+ // genuinely empty Next Up (empty order AND reason == null).
85
+ let resolutionFailed = false;
86
+
87
+ if (boardConfigured) {
88
+ let nextUp = [];
89
+ try {
90
+ // env/board dependencies use resolveNextUpOrder's own defaults
91
+ // (process.env / {}); the production caller never overrides them, and
92
+ // tests inject a stubbed resolveNextUpOrder.
93
+ const result = await resolveOrder(repo, repoRoot, process.env, {});
94
+ reason = result?.reason ?? null;
95
+ nextUp = Array.isArray(result?.order) ? result.order : [];
96
+ // Fail-open contract: an empty order paired with a non-null reason means
97
+ // resolution failed (API error, board lookup failure, etc.) — NOT that the
98
+ // board's Next Up is genuinely empty.
99
+ if (nextUp.length === 0 && reason != null) {
100
+ resolutionFailed = true;
101
+ log(`[queue-membership] Next Up resolution failed (fail-open), using local queue: ${reason}`);
102
+ }
103
+ } catch (err) {
104
+ // resolveNextUpOrder is itself fail-open, but guard anyway: a board
105
+ // resolution error falls back to the local queue without crashing.
106
+ reason = err?.message ?? "board resolution failed";
107
+ resolutionFailed = true;
108
+ log(`[queue-membership] Next Up resolution failed (fail-open), using local queue: ${reason}`);
109
+ nextUp = [];
110
+ }
111
+
112
+ if (nextUp.length > 0) {
113
+ const outcome = reconcileEntriesFromBoard(queue, nextUp);
114
+ added = outcome.added;
115
+ if (added.length > 0) {
116
+ log(`[queue-membership] added ${added.length} entr${added.length === 1 ? "y" : "ies"} from board Next Up: ${added.join(", ")}`);
117
+ try {
118
+ await persist(repoRoot, queue);
119
+ } catch (err) {
120
+ // A write failure should not crash the run; entries still drive this
121
+ // in-memory run, they just are not persisted for the next invocation.
122
+ log(`[queue-membership] failed to persist reconciled queue (continuing in-memory): ${err?.message ?? err}`);
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ const pending = pendingEntries(queue);
129
+ let emptiness = null;
130
+ if (pending.length === 0) {
131
+ if (!boardConfigured) {
132
+ emptiness = "queue_empty";
133
+ } else if (resolutionFailed) {
134
+ // Board configured but we could not read Next Up and the local queue is
135
+ // empty: the board may have items we simply failed to fetch. Report a
136
+ // distinct verdict instead of the misleading "board_empty".
137
+ emptiness = "board_unavailable";
138
+ } else {
139
+ // Board configured, Next Up resolved cleanly (reason == null) and empty.
140
+ emptiness = "board_empty";
141
+ }
142
+ }
143
+
144
+ return { queue, added, boardConfigured, emptiness, reason };
145
+ }
@@ -81,7 +81,7 @@ export async function writeQueue(repoRoot, queue) {
81
81
  export function createEntry(target, kind, dependsOn = []) {
82
82
  return {
83
83
  target,
84
- kind, // "issue" | "pr"
84
+ kind, // "issue" | "pr" | "board" (board = issue-or-PR, resolved by number at run time)
85
85
  status: "queued",
86
86
  dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
87
87
  pr: null,
@@ -211,6 +211,61 @@ export function pendingEntries(queue) {
211
211
  );
212
212
  }
213
213
 
214
+ // ── Board membership reconciliation ──────────────────────────────────
215
+
216
+ /**
217
+ * Reconcile board-driven membership into the local queue (issue #864).
218
+ *
219
+ * A configured GitHub Projects board is the authoritative source of queue
220
+ * MEMBERSHIP (which issues should be worked), not just ordering/status. This
221
+ * pure helper folds the board's "Next Up" targets into the queue: for each
222
+ * target not already present (by `findEntry`), it appends a fresh queued
223
+ * `createEntry(target, "board")`. Existing entries — and their status, order,
224
+ * and metadata — are preserved untouched, and targets already present are
225
+ * skipped (dedup).
226
+ *
227
+ * Entry kind is `"board"` rather than `"issue"`: the board's Next Up can hold
228
+ * issues OR PRs, and `resolveNextUpOrder` collapses each item to a bare number
229
+ * (`issueNumber ?? prNumber`), discarding the artifact kind. The queue driver
230
+ * never branches on `entry.kind`; it dispatches purely by `entry.target` and
231
+ * the per-entry startup resolver resolves the actual artifact (issue or PR) by
232
+ * number at run time. So a neutral `"board"` kind correctly records the
233
+ * provenance without falsely asserting "issue" for PR-backed board items.
234
+ *
235
+ * Pure: performs no I/O. Mutates and returns the passed `queue` object (its
236
+ * `entries` array is appended to in place) plus the list of newly added
237
+ * targets so the caller can log/report.
238
+ *
239
+ * @param {{version:number, entries:Array}} queue - the current queue.
240
+ * @param {Array<number>} nextUpTargets - board "Next Up" issue/PR numbers.
241
+ * @returns {{queue:{version:number, entries:Array}, added:number[]}}
242
+ */
243
+ export function reconcileEntriesFromBoard(queue, nextUpTargets) {
244
+ const added = [];
245
+ if (!queue || !Array.isArray(queue.entries)) {
246
+ return { queue, added };
247
+ }
248
+ if (!Array.isArray(nextUpTargets) || nextUpTargets.length === 0) {
249
+ return { queue, added };
250
+ }
251
+ for (const target of nextUpTargets) {
252
+ // Guard against malformed/duplicate board input: only positive-integer
253
+ // targets, and never add the same target twice (even if it repeats in the
254
+ // board list and was not yet in the queue at the start of this loop).
255
+ //
256
+ // The integer guard is critical for NaN: a NaN target never dedups via
257
+ // findEntry (NaN !== NaN), so accepting it would re-append a fresh NaN
258
+ // entry on every reconcile. Infinity, non-integers, negatives, and 0 are
259
+ // never valid issue/PR numbers either, so reject anything that is not a
260
+ // positive integer.
261
+ if (!Number.isInteger(target) || target <= 0) continue;
262
+ if (findEntry(queue, target)) continue;
263
+ queue.entries.push(createEntry(target, "board"));
264
+ added.push(target);
265
+ }
266
+ return { queue, added };
267
+ }
268
+
214
269
  // ── Bug injection ────────────────────────────────────────────────────
215
270
 
216
271
  export function appendBugIssue(queue, issueNumber, dependsOn = null) {