@dev-loops/core 0.2.6 → 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.
@@ -6,6 +6,107 @@ import { main as moveQueueItemMain } from "../../../../scripts/projects/move-que
6
6
 
7
7
  const DEFAULT_NON_SUCCESS_COLUMN = "Backlog";
8
8
 
9
+ // ── State → board column mapping (AC1, AC3, AC5) ─────────────────────────
10
+ //
11
+ // The mapping is intentionally stateless: it is a pure function of the loop
12
+ // state. Because of that, a reverted loop state (e.g. a merged PR reopened, or
13
+ // a ready PR demoted back to draft) maps backward to the earlier column for
14
+ // free (AC5) — there is no persisted "furthest reached" column to unwind.
15
+
16
+ /** Logical board columns. Display names are config-driven (AC3). */
17
+ export const LOGICAL_COLUMN = Object.freeze({
18
+ NEXT_UP: "next_up",
19
+ IN_PROGRESS: "in_progress",
20
+ READY_FOR_REVIEW: "ready_for_review",
21
+ DONE: "done",
22
+ });
23
+
24
+ /** Allow-list of recognized logical column tokens (for config validation). */
25
+ const KNOWN_LOGICAL_COLUMNS = new Set(Object.values(LOGICAL_COLUMN));
26
+
27
+ /** Keys that must never be copied from untrusted config (prototype pollution). */
28
+ const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
29
+
30
+ /** Default display name for each logical column (AC1 values). */
31
+ export const DEFAULT_STATE_COLUMN_NAMES = Object.freeze({
32
+ [LOGICAL_COLUMN.NEXT_UP]: "Next Up",
33
+ [LOGICAL_COLUMN.IN_PROGRESS]: "In Progress",
34
+ // Ready for Review is opt-in: by default it resolves to In Progress so that
35
+ // final_approval_ready keeps "In Progress" unless a board configures it.
36
+ [LOGICAL_COLUMN.READY_FOR_REVIEW]: "In Progress",
37
+ [LOGICAL_COLUMN.DONE]: "Done",
38
+ });
39
+
40
+ /**
41
+ * Default loop-state → logical-column map. Covers both the lifecycle states
42
+ * (lifecycle-state.mjs) and the inner Copilot loop states (copilot-loop-state.mjs),
43
+ * plus the conceptual names used by issue #793. Unknown states fall back to
44
+ * IN_PROGRESS (a safe, visible "work is happening" column) rather than throwing.
45
+ */
46
+ export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
47
+ // Next Up — work not yet actively in flight
48
+ issue_opened: LOGICAL_COLUMN.NEXT_UP,
49
+ issue_intake: LOGICAL_COLUMN.NEXT_UP,
50
+ refinement: LOGICAL_COLUMN.NEXT_UP,
51
+ no_pr: LOGICAL_COLUMN.NEXT_UP,
52
+ pr_draft: LOGICAL_COLUMN.NEXT_UP,
53
+
54
+ // In Progress — active implementation / review / feedback resolution
55
+ implementation: LOGICAL_COLUMN.IN_PROGRESS,
56
+ // Tolerated alias for `implementation` (conceptual name from issue #793);
57
+ // the queue driver passes the real `implementation` lifecycle state.
58
+ local_implementation_active: LOGICAL_COLUMN.IN_PROGRESS,
59
+ draft_gate: LOGICAL_COLUMN.IN_PROGRESS,
60
+ pr_ready_no_feedback: LOGICAL_COLUMN.IN_PROGRESS,
61
+ feedback_resolution: LOGICAL_COLUMN.IN_PROGRESS,
62
+ copilot_review: LOGICAL_COLUMN.IN_PROGRESS,
63
+ waiting_for_copilot_review: LOGICAL_COLUMN.IN_PROGRESS,
64
+ ready_to_rerequest_review: LOGICAL_COLUMN.IN_PROGRESS,
65
+ unresolved_feedback_present: LOGICAL_COLUMN.IN_PROGRESS,
66
+ already_fixed_needs_reply_resolve: LOGICAL_COLUMN.IN_PROGRESS,
67
+ waiting_for_ci: LOGICAL_COLUMN.IN_PROGRESS,
68
+ review_request_unavailable: LOGICAL_COLUMN.IN_PROGRESS,
69
+ round_cap_reached: LOGICAL_COLUMN.IN_PROGRESS,
70
+ round_cap_clean_fallback: LOGICAL_COLUMN.IN_PROGRESS,
71
+ internal_tooling_direct_gate: LOGICAL_COLUMN.IN_PROGRESS,
72
+ low_signal_converged: LOGICAL_COLUMN.IN_PROGRESS,
73
+ blocked_needs_user_decision: LOGICAL_COLUMN.IN_PROGRESS,
74
+
75
+ // Ready for Review — final approval gate. Resolves to In Progress unless a
76
+ // board configures a distinct "Ready for Review" column name (AC1).
77
+ pre_approval_gate: LOGICAL_COLUMN.READY_FOR_REVIEW,
78
+ final_approval_ready: LOGICAL_COLUMN.READY_FOR_REVIEW,
79
+
80
+ // Done — terminal (lifecycle MERGE = "merge", queue terminal = "done")
81
+ merge: LOGICAL_COLUMN.DONE,
82
+ done: LOGICAL_COLUMN.DONE,
83
+ // Tolerated aliases (conceptual names from issue #793).
84
+ merged: LOGICAL_COLUMN.DONE,
85
+ issue_closed: LOGICAL_COLUMN.DONE,
86
+ });
87
+
88
+ /** Safe default logical column for any state we do not explicitly map. */
89
+ const DEFAULT_LOGICAL_COLUMN = LOGICAL_COLUMN.IN_PROGRESS;
90
+
91
+ /**
92
+ * Pure mapping: loop state → board column display name.
93
+ *
94
+ * @param {string|null|undefined} loopState - a lifecycle or inner loop state
95
+ * name. `null`, `undefined`, and any unrecognized value fall through to the
96
+ * safe default logical column (IN_PROGRESS).
97
+ * @param {{stateColumnMap?:Object, columnNames?:Object}} [mapping]
98
+ * Optional overrides. `stateColumnMap` overrides state→logical-column;
99
+ * `columnNames` overrides logical-column→display-name. Both fall back to
100
+ * the AC1 defaults.
101
+ * @returns {string} the target board column display name.
102
+ */
103
+ export function boardColumnForLoopState(loopState, mapping = {}) {
104
+ const stateMap = { ...DEFAULT_STATE_LOGICAL_MAP, ...(mapping.stateColumnMap ?? {}) };
105
+ const columnNames = { ...DEFAULT_STATE_COLUMN_NAMES, ...(mapping.columnNames ?? {}) };
106
+ const logical = stateMap[loopState] ?? DEFAULT_LOGICAL_COLUMN;
107
+ return columnNames[logical] ?? columnNames[DEFAULT_LOGICAL_COLUMN];
108
+ }
109
+
9
110
  // ── Local config loader ─────────────────────────────────────────────────
10
111
 
11
112
  function readDevloopsSettings(repoRoot) {
@@ -46,6 +147,62 @@ export function loadBoardConfig(repoRoot) {
46
147
  return { enabled: false };
47
148
  }
48
149
 
150
+ /**
151
+ * Load the config-driven state→column mapping from `.devloops` `queue` (AC3).
152
+ *
153
+ * Reads two optional config keys, both gated behind the same opt-in `queue`
154
+ * section as `loadBoardConfig` (AC2/AC6):
155
+ * - `queue.statusColumns` — logical-column → display-name overrides
156
+ * (keys: next_up, in_progress, ready_for_review, done)
157
+ * - `queue.stateColumnMap` — loop-state → logical-column overrides
158
+ *
159
+ * Returns a `{ stateColumnMap, columnNames }` shape consumable by
160
+ * `boardColumnForLoopState`. Missing config yields the AC1 defaults.
161
+ *
162
+ * Hardened against untrusted `.devloops` input:
163
+ * - `statusColumns` keys are allow-listed to the known logical columns;
164
+ * unrecognized keys are ignored.
165
+ * - `stateColumnMap` entries whose value is not a known logical column are
166
+ * ignored.
167
+ * - Dangerous keys (`__proto__`, `prototype`, `constructor`) are skipped and
168
+ * results are built on null-prototype objects, so a malicious config key
169
+ * cannot pollute Object.prototype.
170
+ */
171
+ export function loadStateColumnMap(repoRoot) {
172
+ const { settings: queue } = readDevloopsSettings(repoRoot);
173
+ // Null-prototype objects: untrusted keys can never reach Object.prototype.
174
+ const columnNames = Object.assign(Object.create(null), DEFAULT_STATE_COLUMN_NAMES);
175
+ const stateColumnMap = Object.create(null);
176
+
177
+ const statusColumns = queue?.statusColumns;
178
+ if (statusColumns && typeof statusColumns === "object") {
179
+ for (const logical of Object.keys(statusColumns)) {
180
+ if (DANGEROUS_KEYS.has(logical)) continue;
181
+ // Allow-list: only recognized logical columns may be renamed.
182
+ if (!KNOWN_LOGICAL_COLUMNS.has(logical)) continue;
183
+ const name = statusColumns[logical];
184
+ if (typeof name === "string" && name.trim().length > 0) {
185
+ columnNames[logical] = name.trim();
186
+ }
187
+ }
188
+ }
189
+
190
+ const stateMap = queue?.stateColumnMap;
191
+ if (stateMap && typeof stateMap === "object") {
192
+ for (const state of Object.keys(stateMap)) {
193
+ if (DANGEROUS_KEYS.has(state)) continue;
194
+ const logical = stateMap[state];
195
+ // Ignore values that are not a recognized logical column.
196
+ if (typeof logical !== "string") continue;
197
+ const trimmed = logical.trim();
198
+ if (!KNOWN_LOGICAL_COLUMNS.has(trimmed)) continue;
199
+ stateColumnMap[state] = trimmed;
200
+ }
201
+ }
202
+
203
+ return { columnNames, stateColumnMap };
204
+ }
205
+
49
206
  // ── Minimal project lookup (read-only, no create/repair) ────────────────
50
207
 
51
208
  const GET_USER_ID = [
@@ -187,6 +344,14 @@ export async function syncBoardStatus(
187
344
  env = process.env,
188
345
  dependencies = {},
189
346
  ) {
347
+ // AC4: the not-on-board / fail-open path is a logged no-op. Default to
348
+ // console.error so it logs in real runs; tests inject their own stub. The
349
+ // log fires at most once per syncBoardStatus call (single catch, no internal
350
+ // retry), so it cannot spam.
351
+ const log = typeof dependencies.log === "function"
352
+ ? dependencies.log
353
+ : (msg) => console.error(msg);
354
+
190
355
  const config = loadBoardConfig(repoRoot);
191
356
  if (!config.enabled) {
192
357
  return { ok: true, skipped: true, reason: config.reason ?? "board not configured" };
@@ -205,12 +370,27 @@ export async function syncBoardStatus(
205
370
  const moveItem = dependencies.moveQueueItem ?? moveQueueItemMain;
206
371
  try {
207
372
  const result = await moveItem(
208
- { 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 },
209
377
  { env, runChild: dependencies.runChild },
210
378
  );
211
379
  return { ok: true, skipped: false, result };
212
380
  } catch (err) {
213
- return { ok: true, skipped: true, reason: err.message ?? "board sync failed" };
381
+ // Fail-open: a board hiccup (rate limit, missing column, item not on board)
382
+ // must never break the loop.
383
+ const reason = err?.message ?? "board sync failed";
384
+ // AC4: the explicit "item not on board" case is a clean, logged no-op.
385
+ // Other fail-open failures (rate limit, missing column, etc.) get a
386
+ // distinct, distinguishable message so they are not conflated with AC4.
387
+ const notOnBoard = err?.code === "ITEM_NOT_FOUND" || err?.code === "ITEM_NOT_ON_BOARD";
388
+ if (notOnBoard) {
389
+ log(`[board-sync] no-op: item ${itemNumber} is not on the board (${reason})`);
390
+ } else {
391
+ log(`[board-sync] sync failed (fail-open) for item ${itemNumber} → "${targetColumn}": ${reason}`);
392
+ }
393
+ return { ok: true, skipped: true, reason };
214
394
  }
215
395
  }
216
396
 
@@ -13,7 +13,12 @@ import {
13
13
  RECOVERABLE_FAILURES,
14
14
  appendBugIssue,
15
15
  } from "./queue-state.mjs";
16
- import { syncBoardStatus, nonSuccessBoardColumn } from "./queue-board-sync.mjs";
16
+ import {
17
+ syncBoardStatus,
18
+ nonSuccessBoardColumn,
19
+ boardColumnForLoopState,
20
+ loadStateColumnMap,
21
+ } from "./queue-board-sync.mjs";
17
22
  import { resolveNextUpOrder } from "./queue-board-ordering.mjs";
18
23
 
19
24
  export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
@@ -52,6 +57,19 @@ export async function runQueue(repoRoot, repo, options = {}) {
52
57
  const opts = { ...DEFAULT_QUEUE_DRIVER_OPTIONS, ...options };
53
58
  const queue = await readQueue(repoRoot);
54
59
 
60
+ // Config-driven loop-state → board-column mapping (#793, AC1/AC3). Loaded
61
+ // once per run; resolves logical columns to configured display names, with
62
+ // the AC1 defaults when no `queue.statusColumns`/`queue.stateColumnMap` is set.
63
+ const stateColumnMap = loadStateColumnMap(repoRoot);
64
+ const columnFor = (loopState) => boardColumnForLoopState(loopState, stateColumnMap);
65
+
66
+ // Per-item dedup: a single run may resolve consecutive loop states to the
67
+ // same display column (e.g. implementation → final_approval_ready both
68
+ // default to "In Progress"). Skip the redundant board write/API call when the
69
+ // target column is unchanged for that item; a genuinely different column
70
+ // (e.g. a configured "Ready for Review") still syncs. (#793 round-1 #1)
71
+ const lastSyncedColumn = new Map();
72
+
55
73
  // Optional board-aware ordering: fetch Next Up order before processing.
56
74
  // Fail-open: if the board is unreachable, orderHint stays empty and the
57
75
  // driver falls back to the existing queue order.
@@ -95,15 +113,26 @@ export async function runQueue(repoRoot, repo, options = {}) {
95
113
  boardSync.push(r);
96
114
  return r;
97
115
  };
116
+ // Sync a target to a column, short-circuiting (no API call) when the column
117
+ // is unchanged from the last sync for the same item in this run.
118
+ const syncColumn = async (target, column) => {
119
+ if (lastSyncedColumn.get(target) === column) {
120
+ return recordBoardSync(Promise.resolve({
121
+ ok: true, skipped: true, reason: "column unchanged",
122
+ }));
123
+ }
124
+ const r = await recordBoardSync(syncBoardStatus(
125
+ repo, repoRoot, target, column, opts.env ?? process.env, boardSyncDeps,
126
+ ));
127
+ // Only remember the column when the move actually landed, so a fail-open
128
+ // skip does not suppress a later retry to the same column.
129
+ if (r.ok && r.skipped !== true) lastSyncedColumn.set(target, column);
130
+ return r;
131
+ };
98
132
 
99
- await recordBoardSync(syncBoardStatus(
100
- repo,
101
- repoRoot,
102
- entry.target,
103
- "In Progress",
104
- opts.env ?? process.env,
105
- boardSyncDeps,
106
- ));
133
+ // Entry has been picked up and is actively running: implementation phase
134
+ // (real lifecycle state, lifecycle-state.mjs LIFECYCLE_STATE.IMPLEMENTATION).
135
+ await syncColumn(entry.target, columnFor("implementation"));
107
136
 
108
137
  try {
109
138
  const entryResult = opts.runEntry
@@ -117,12 +146,18 @@ export async function runQueue(repoRoot, repo, options = {}) {
117
146
  if (opts.mergeAuthorized) {
118
147
  await doTransition(entry, "merging", queue, repoRoot, opts);
119
148
  await doTransition(entry, "done", queue, repoRoot, opts, { retrospectiveWritten: true });
120
- await recordBoardSync(syncBoardStatus(repo, repoRoot, entry.target, "Done", opts.env ?? process.env, boardSyncDeps));
149
+ await syncColumn(entry.target, columnFor("done"));
150
+ } else {
151
+ // PR is up with gates passing but merge is not authorized: the work
152
+ // is awaiting final approval/merge. Map to the final-approval column
153
+ // (configured "Ready for Review" if present, else "In Progress").
154
+ // Deduped: when this resolves to the same column already synced for
155
+ // this item, no extra board write/API call is made.
156
+ await syncColumn(entry.target, columnFor("final_approval_ready"));
121
157
  }
122
- // else: stays at gates_passing for future merge run
123
158
  } else {
124
159
  await doTransition(entry, "done", queue, repoRoot, opts);
125
- await recordBoardSync(syncBoardStatus(repo, repoRoot, entry.target, "Done", opts.env ?? process.env, boardSyncDeps));
160
+ await syncColumn(entry.target, columnFor("done"));
126
161
  }
127
162
  results.push({ target: entry.target, ok: true, entry: snapshotEntry(entry), boardSync });
128
163
  } else {
@@ -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) {