@dev-loops/core 0.2.7 → 0.4.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.
@@ -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) {
@@ -1,15 +1,11 @@
1
1
  /**
2
2
  * Neutral run-id / async-context contract.
3
3
  *
4
- * The dev-loop async path historically keyed off Pi's `PI_SUBAGENT_RUN_ID` env var to
4
+ * The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
5
5
  * identify an inspectable per-subagent run (runner ownership, async-start enforcement,
6
- * human-comment gating). This module generalizes that into a harness-neutral
7
- * `DEVLOOPS_RUN_ID`, keeping `PI_SUBAGENT_RUN_ID` as a backward-compatible alias, and
8
- * provides a mint-and-propagate path for harnesses (e.g. Claude Code) that inject no
9
- * native per-subagent run id.
10
- *
11
- * Marker precedence is neutral-first: a present `DEVLOOPS_RUN_ID` wins; otherwise the Pi
12
- * alias is honored. Existing Pi runs that set only `PI_SUBAGENT_RUN_ID` behave identically.
6
+ * human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
7
+ * Code) that inject no native per-subagent run id. The harness sets `DEVLOOPS_RUN_ID` when
8
+ * dispatching an async subagent.
13
9
  *
14
10
  * This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
15
11
  * which take an injectable `fs` and `root` for testability.
@@ -21,16 +17,13 @@ import path from "node:path";
21
17
 
22
18
  /**
23
19
  * Env var names that carry the async-context run id, in resolution precedence order.
24
- * Neutral `DEVLOOPS_RUN_ID` first; Pi `PI_SUBAGENT_RUN_ID` retained as a compatibility alias.
20
+ * The neutral `DEVLOOPS_RUN_ID` is the sole marker.
25
21
  */
26
- export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
22
+ export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
27
23
 
28
24
  /** Neutral env var name used when minting/propagating a run id. */
29
25
  export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
30
26
 
31
- /** Pi-compatibility alias env var name. */
32
- export const PI_RUN_ID_ALIAS_VAR = "PI_SUBAGENT_RUN_ID";
33
-
34
27
  /** State-file name (under `.pi/`, consistent with existing dev-loop checkpoint files). */
35
28
  export const RUN_CONTEXT_FILENAME = "dev-loop-run-context.json";
36
29
 
@@ -56,7 +49,7 @@ export function isClaudeHarness(env = process.env) {
56
49
  }
57
50
 
58
51
  /**
59
- * Resolve the active run id from the environment, neutral marker first.
52
+ * Resolve the active run id from the environment.
60
53
  *
61
54
  * @param {Record<string, string|undefined>} [env]
62
55
  * @returns {string|null} The trimmed run id, or null when none is set.
@@ -154,8 +147,8 @@ export function readRunContext({ root, fs = fsDefault }) {
154
147
  * Resolve the active run id, or mint one and persist a run-context state file.
155
148
  *
156
149
  * This is the "mint at startup and propagate" primitive a Claude dev-loop agent (or a
157
- * headless entry) calls before dispatching child work. When the env already carries a run
158
- * id (Pi alias or neutral), it is reused and no new id is minted.
150
+ * headless entry) calls before dispatching child work. When the env already carries a
151
+ * `DEVLOOPS_RUN_ID`, it is reused and no new id is minted.
159
152
  *
160
153
  * @param {object} [params]
161
154
  * @param {Record<string, string|undefined>} [params.env]
@@ -121,32 +121,22 @@ export function isListedWorktree(cwd, worktreePaths) {
121
121
  * Neutral environment variable name checked by `detectSubagentAvailability`.
122
122
  *
123
123
  * Set `DEVLOOPS_SUBAGENT_AVAILABLE=1` when the runtime supports subagent dispatch.
124
- * This is consistent with the `PI_WORKTREE_BYPASS` pattern and other repo-local
124
+ * This is consistent with the `DEVLOOPS_WORKTREE_BYPASS` pattern and other repo-local
125
125
  * runtime configuration gates already present in the repo.
126
126
  */
127
127
  export const DEVLOOPS_SUBAGENT_AVAILABLE_VAR = "DEVLOOPS_SUBAGENT_AVAILABLE";
128
128
 
129
- /**
130
- * Pi-compatibility alias for {@link DEVLOOPS_SUBAGENT_AVAILABLE_VAR}; honored when the
131
- * neutral var is unset so existing Pi runtimes keep working unchanged.
132
- */
133
- export const PI_SUBAGENT_AVAILABLE_VAR = "PI_SUBAGENT_AVAILABLE";
134
-
135
- /** Availability env var names, neutral-first. */
136
- export const SUBAGENT_AVAILABLE_VARS = Object.freeze([
137
- DEVLOOPS_SUBAGENT_AVAILABLE_VAR,
138
- PI_SUBAGENT_AVAILABLE_VAR,
139
- ]);
129
+ /** Availability env var names. */
130
+ export const SUBAGENT_AVAILABLE_VARS = Object.freeze([DEVLOOPS_SUBAGENT_AVAILABLE_VAR]);
140
131
 
141
132
  /**
142
133
  * Detect whether subagent dispatch is available in the current runtime.
143
134
  *
144
135
  * This is an env-var-based heuristic, consistent with other bypass/availability
145
136
  * patterns in the repo. It is intentionally simple — the gate's subagent check
146
- * is advisory (fails-open) and never hard-blocks on subagent absence. Precedence is
147
- * neutral-first: the first var that is *set* (non-blank) is authoritative — so an explicit
148
- * `DEVLOOPS_SUBAGENT_AVAILABLE=0` is respected even when `PI_SUBAGENT_AVAILABLE=1`. The Pi
149
- * alias is only consulted when the neutral var is unset/blank.
137
+ * is advisory (fails-open) and never hard-blocks on subagent absence. The var that is
138
+ * *set* (non-blank) is authoritative — so an explicit `DEVLOOPS_SUBAGENT_AVAILABLE=0` is
139
+ * respected as a hard "not available".
150
140
  *
151
141
  * @param {{ env?: Record<string, string | undefined> }} [options]
152
142
  * @returns {boolean}