@dev-loops/core 0.2.6 → 0.2.7

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.6",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
6
6
  "exports": {
@@ -1,5 +1,6 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { parseArgs } from "node:util";
3
4
 
4
5
  export const DEFAULT_OUTPUT_LIMIT = 4000;
5
6
 
@@ -79,23 +80,40 @@ export async function appendBashExitOneRecord(logPath, record) {
79
80
  }
80
81
 
81
82
  export function parseCliArgs(argv) {
82
- const args = [...argv];
83
+ const { tokens } = parseArgs({
84
+ args: [...argv],
85
+ options: {
86
+ log: { type: "string" },
87
+ record: { type: "string" },
88
+ },
89
+ allowPositionals: true,
90
+ strict: false,
91
+ tokens: true,
92
+ });
93
+
83
94
  let logPath;
84
95
  let recordJson;
85
96
 
86
- while (args.length > 0) {
87
- const token = args.shift();
88
- if (token === "--log") {
89
- logPath = args.shift();
97
+ for (const token of tokens) {
98
+ if (token.kind === "positional") {
99
+ throw new Error(`Unknown argument: ${token.value}`);
100
+ }
101
+
102
+ if (token.kind !== "option") {
103
+ continue;
104
+ }
105
+
106
+ if (token.name === "log") {
107
+ logPath = token.value;
90
108
  continue;
91
109
  }
92
110
 
93
- if (token === "--record") {
94
- recordJson = args.shift();
111
+ if (token.name === "record") {
112
+ recordJson = token.value;
95
113
  continue;
96
114
  }
97
115
 
98
- throw new Error(`Unknown argument: ${token}`);
116
+ throw new Error(`Unknown argument: ${token.rawName}`);
99
117
  }
100
118
 
101
119
  if (!logPath) {
@@ -72,6 +72,22 @@ export function stripPiOnlyBlocks(body) {
72
72
  .replace(/\n{3,}/g, "\n\n");
73
73
  }
74
74
 
75
+ /**
76
+ * Rewrite the Pi package-local CLI invocation into the Claude version-pinned `npx` form (#801,
77
+ * #833). The Pi runtime sources invoke the CLI as `node <dev-loops-package-root>/cli/index.mjs`
78
+ * (resolves unambiguously from the installed package). The Claude plugin does NOT bundle `cli/`,
79
+ * so for the generated tree those tokens become `npx dev-loops@<version>` — pinning the version
80
+ * keeps the CLI from drifting against the published plugin version (#833). The Pi-only
81
+ * package-root resolution note is removed separately by `stripPiOnlyBlocks`.
82
+ *
83
+ * @param {string} body
84
+ * @param {string} version dev-loops package version to pin (e.g. "0.2.6").
85
+ * @returns {string}
86
+ */
87
+ export function rewriteCliInvocation(body, version) {
88
+ return String(body).split("node <dev-loops-package-root>/cli/index.mjs").join(`npx dev-loops@${version}`);
89
+ }
90
+
75
91
  /**
76
92
  * Map a single Pi tool name to its Claude tool name(s).
77
93
  * @param {string} name
@@ -131,12 +147,12 @@ function normalizeToolList(value) {
131
147
 
132
148
  /**
133
149
  * Transform a canonical `agents/*.agent.md` into a Claude `.claude/agents/*.md` document.
134
- * @param {{ source: string, raw: string }} input
150
+ * @param {{ source: string, raw: string, version?: string }} input
135
151
  * @returns {string} Full generated file content.
136
152
  */
137
- export function transformAgent({ source, raw }) {
153
+ export function transformAgent({ source, raw, version = "latest" }) {
138
154
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
139
- const body = stripPiOnlyBlocks(rawBody);
155
+ const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
140
156
  const tools = mapTools(normalizeToolList(frontmatter.tools));
141
157
 
142
158
  const lines = ["---"];
@@ -155,12 +171,12 @@ export function transformAgent({ source, raw }) {
155
171
 
156
172
  /**
157
173
  * Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
158
- * @param {{ source: string, raw: string }} input
174
+ * @param {{ source: string, raw: string, version?: string }} input
159
175
  * @returns {string} Full generated file content.
160
176
  */
161
- export function transformSkill({ source, raw }) {
177
+ export function transformSkill({ source, raw, version = "latest" }) {
162
178
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
163
- const body = stripPiOnlyBlocks(rawBody);
179
+ const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
164
180
  const tools = mapTools(normalizeToolList(frontmatter["allowed-tools"]));
165
181
 
166
182
  const lines = ["---"];
@@ -1,10 +1,75 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { parseArgs } from "node:util";
2
3
 
3
4
  /**
4
5
  * Shared CLI primitives for arg parsing, validation, and child process execution.
5
6
  * Extracted from scripts/_cli-primitives.mjs per issue #548 Phase 2.
6
7
  */
7
8
 
9
+ /**
10
+ * Parse argv with node:util parseArgs while preserving the legacy hand-rolled
11
+ * parser semantics that several callers depend on:
12
+ *
13
+ * - Unknown options/positionals throw `Unknown argument: <raw>`.
14
+ * - A string option whose value is missing or looks like another flag throws
15
+ * `Missing value for <--flag>` (matching {@link requireOptionValue}).
16
+ *
17
+ * Returns a Map of canonical option name -> value (last-wins for repeats, which
18
+ * matches the legacy while/shift loops that simply reassigned on each match).
19
+ *
20
+ * @param {string[]} argv
21
+ * @param {Record<string, { type: "string" | "boolean", short?: string }>} options
22
+ * @param {(message: string) => Error} [parseError]
23
+ * @param {{ allowPositionals?: boolean, flagPattern?: RegExp }} [config]
24
+ * @returns {{ values: Map<string, string | boolean>, positionals: string[] }}
25
+ */
26
+ export function parseCliTokens(argv, options, parseError = null, { allowPositionals = false, flagPattern = /^--/u } = {}) {
27
+ const { tokens } = parseArgs({
28
+ args: [...argv],
29
+ options,
30
+ allowPositionals: true,
31
+ strict: false,
32
+ tokens: true,
33
+ });
34
+
35
+ const values = new Map();
36
+ const positionals = [];
37
+
38
+ for (const token of tokens) {
39
+ if (token.kind === "positional") {
40
+ if (allowPositionals) {
41
+ positionals.push(token.value);
42
+ continue;
43
+ }
44
+ throw toCliError(`Unknown argument: ${token.value}`, parseError);
45
+ }
46
+
47
+ if (token.kind !== "option") {
48
+ continue;
49
+ }
50
+
51
+ const spec = options[token.name];
52
+ if (!spec) {
53
+ throw toCliError(`Unknown argument: ${token.rawName}`, parseError);
54
+ }
55
+
56
+ if (spec.type === "boolean") {
57
+ // A bare boolean flag carries no value (parseArgs → undefined) and means true;
58
+ // an explicit inline value (e.g. --flag=false) is honored rather than forced true.
59
+ values.set(token.name, token.value === undefined ? true : token.value !== "false");
60
+ continue;
61
+ }
62
+
63
+ const value = token.value;
64
+ if (typeof value !== "string" || value.length === 0 || flagPattern.test(value)) {
65
+ throw toCliError(`Missing value for ${token.rawName}`, parseError);
66
+ }
67
+ values.set(token.name, value);
68
+ }
69
+
70
+ return { values, positionals };
71
+ }
72
+
8
73
  function toCliError(message, parseError) {
9
74
  if (typeof parseError === "function") {
10
75
  return parseError(message);
@@ -20,6 +85,25 @@ export function requireOptionValue(args, flag, parseError = null, { flagPattern
20
85
  return value;
21
86
  }
22
87
 
88
+ /**
89
+ * Token-based equivalent of {@link requireOptionValue} for callers that have
90
+ * migrated to node:util parseArgs with `tokens: true`. Validates the value
91
+ * attached to a parsed option token, rejecting missing or flag-like values with
92
+ * the same `Missing value for <--flag>` message the legacy parsers emitted.
93
+ *
94
+ * @param {{ value?: string, rawName?: string }} token - a parseArgs option token
95
+ * @param {(message: string) => Error} [parseError]
96
+ * @param {{ flagPattern?: RegExp }} [config]
97
+ * @returns {string}
98
+ */
99
+ export function requireTokenValue(token, parseError = null, { flagPattern = /^--/u } = {}) {
100
+ const value = token?.value;
101
+ if (typeof value !== "string" || value.length === 0 || flagPattern.test(value)) {
102
+ throw toCliError(`Missing value for ${token?.rawName}`, parseError);
103
+ }
104
+ return value;
105
+ }
106
+
23
107
  export function parsePositiveInteger(value, flag, parseError = null) {
24
108
  if (!/^\d+$/.test(value) || Number(value) === 0) {
25
109
  throw toCliError(`${flag} must be a positive integer`, parseError);
@@ -1,5 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ import { parseCliTokens } from "../cli/primitives.mjs";
4
+
3
5
  function normalizeId(value, fallback) {
4
6
  if (typeof value === "string" && value.trim().length > 0) {
5
7
  return value.trim();
@@ -266,34 +268,14 @@ export function classifyReviewThreadsSignal(parsedResult, isCopilotLoginFn) {
266
268
  }
267
269
 
268
270
 
269
- function requireOptionValue(args, flag) {
270
- const value = args.shift();
271
-
272
- if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
273
- throw new Error(`Missing value for ${flag}`);
274
- }
275
-
276
- return value;
277
- }
278
-
279
271
  export function parseCliArgs(argv) {
280
- const args = [...argv];
281
- const options = {
282
- inputPath: undefined,
283
- };
284
-
285
- while (args.length > 0) {
286
- const token = args.shift();
287
-
288
- if (token === "--input") {
289
- options.inputPath = requireOptionValue(args, "--input");
290
- continue;
291
- }
292
-
293
- throw new Error(`Unknown argument: ${token}`);
294
- }
272
+ const { values } = parseCliTokens(argv, {
273
+ input: { type: "string" },
274
+ });
295
275
 
296
- return options;
276
+ return {
277
+ inputPath: values.get("input"),
278
+ };
297
279
  }
298
280
 
299
281
  export async function readInput({ inputPath, stdin = process.stdin } = {}) {
@@ -229,12 +229,6 @@ function isAutoRerequestEligible(snapshot, state) {
229
229
  */
230
230
  const VALID_SIGNAL_LEVELS = new Set(["high", "mid", "low"]);
231
231
 
232
- function hasExplicitCurrentHeadReviewSignal(raw) {
233
- return Boolean(raw)
234
- && typeof raw === "object"
235
- && Object.prototype.hasOwnProperty.call(raw, "copilotReviewOnCurrentHead");
236
- }
237
-
238
232
  export function normalizeSnapshot(raw) {
239
233
  if (!raw || typeof raw !== "object") {
240
234
  throw new Error("Snapshot must be a non-null object");
@@ -351,34 +345,54 @@ export function interpretLoopState(snapshot, refinementConfig) {
351
345
 
352
346
  // Round-cap enforcement: when maxCopilotRounds is configured and the review-round
353
347
  // count has been exhausted, stop re-requests before entering fix/reply-resolve routing.
354
- // Gating here (before unresolved-thread checks) ensures round cap takes priority over
355
- // the normal fix loop, including unresolved threads, pending CI, and CI failures.
356
- // Clean PRs are usually eligible for pre_approval_gate fallback; the only automatic
357
- // exception is when the head has advanced since the last submitted Copilot review,
358
- // all prior feedback is resolved, and CI is green/credibly green again. In that case
359
- // the state re-opens to READY_TO_REREQUEST_REVIEW instead of terminating as clean fallback.
360
- // Does NOT interrupt an in-flight review request (requested/already-requested).
348
+ // Gating here (before unresolved-thread checks) lets a CLEAN PR at the cap terminate as
349
+ // ROUND_CAP_CLEAN_FALLBACK ahead of the normal fix/wait routing. It does NOT blanket-
350
+ // override that routing: a NOT-clean PR (unresolved threads or non-green CI) with an
351
+ // in-flight request deliberately falls through to the normal fix/wait routing below
352
+ // (see the `!reviewInFlight` branch), and only a not-clean PR with no in-flight request
353
+ // hard-stops at ROUND_CAP_REACHED.
354
+ //
355
+ // Precedence at the cap: copilotReviewRoundCount counts COMPLETED rounds, so at
356
+ // `>= maxRounds` every permitted Copilot round is already done and any lingering
357
+ // in-flight request (requested/already-requested) is for a forbidden over-cap round.
358
+ // A stale Copilot reviewer assignment must therefore NOT block the clean fallback:
359
+ // when threads are clean and CI is green, route to ROUND_CAP_CLEAN_FALLBACK even if
360
+ // copilotReviewRequestStatus is requested/already-requested. Otherwise a lingering
361
+ // assignment would dead-end the loop at WAITING_FOR_COPILOT_REVIEW waiting for a
362
+ // review that can never come (no further round is permitted past the cap). The
363
+ // pre_approval_gate (current-head clean evidence, enforced elsewhere) reviews any
364
+ // post-cap head change, so this proceeds without skipping review of new code.
365
+ //
366
+ // An in-flight request only still blocks the cap block when the PR is NOT clean
367
+ // (unresolved threads or non-green CI) — that legitimately stays in the fix/wait
368
+ // routing below rather than terminating as a clean fallback.
369
+ //
370
+ // Head-advanced handling: even when the head has advanced past the last submitted
371
+ // Copilot review with clean threads and green CI, re-requesting another Copilot pass
372
+ // is forbidden at the cap, so this routes to ROUND_CAP_CLEAN_FALLBACK (not
373
+ // READY_TO_REREQUEST_REVIEW, which would trigger an illegal auto re-request). The
374
+ // pre_approval_gate handles the current head.
361
375
  const maxRounds = refinementConfig?.maxCopilotRounds;
362
376
  const reviewInFlight = s.copilotReviewRequestStatus === "requested"
363
377
  || s.copilotReviewRequestStatus === "already-requested";
364
378
  if (typeof maxRounds === "number" && maxRounds > 0
365
379
  && s.copilotReviewRoundCount >= maxRounds
366
- && !reviewInFlight
367
380
  && state !== STATE.NO_PR && state !== STATE.DONE
368
381
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
369
382
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
370
383
  const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen";
371
384
  const cleanThreads = s.unresolvedThreadCount === 0;
372
- const headAdvancedSinceLastSubmittedCopilotReview = s.copilotReviewPresent
373
- && hasExplicitCurrentHeadReviewSignal(snapshot)
374
- && !s.copilotReviewOnCurrentHead;
375
- if (cleanThreads && ciClean && headAdvancedSinceLastSubmittedCopilotReview) {
376
- state = STATE.READY_TO_REREQUEST_REVIEW;
377
- } else if (cleanThreads && ciClean) {
385
+ if (cleanThreads && ciClean) {
386
+ // Clean PR at the cap: proceed to the pre_approval_gate fallback regardless of a
387
+ // lingering Copilot reviewer assignment or an advanced head — no further Copilot
388
+ // round is permitted, so never re-open for re-request or wait on Copilot here.
378
389
  state = STATE.ROUND_CAP_CLEAN_FALLBACK;
379
- } else {
390
+ } else if (!reviewInFlight) {
391
+ // Not clean and no in-flight request: hard stop at the cap.
380
392
  state = STATE.ROUND_CAP_REACHED;
381
393
  }
394
+ // Not clean WITH an in-flight request: leave state undecided so the normal
395
+ // fix/reply-resolve/wait routing below handles it (do not force a clean fallback).
382
396
  }
383
397
 
384
398
  if (state === undefined) {
@@ -1,6 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { parseCliTokens } from "../cli/primitives.mjs";
5
+
4
6
  export function createDefaultPhaseManifest(phase) {
5
7
  return {
6
8
  phase,
@@ -134,45 +136,19 @@ export async function ensurePhaseFiles(projectRoot, phase, patch = {}) {
134
136
  };
135
137
  }
136
138
 
137
- function requireOptionValue(args, flag) {
138
- const value = args.shift();
139
-
140
- if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
141
- throw new Error(`Missing value for ${flag}`);
142
- }
143
-
144
- return value;
145
- }
146
-
147
139
  export function parseCliArgs(argv) {
148
- const args = [...argv];
140
+ const { values } = parseCliTokens(argv, {
141
+ "project-root": { type: "string" },
142
+ phase: { type: "string" },
143
+ patch: { type: "string" },
144
+ });
145
+
149
146
  const options = {
150
- projectRoot: process.cwd(),
151
- phase: undefined,
152
- patch: {},
147
+ projectRoot: values.has("project-root") ? values.get("project-root") : process.cwd(),
148
+ phase: values.get("phase"),
149
+ patch: values.has("patch") ? JSON.parse(values.get("patch")) : {},
153
150
  };
154
151
 
155
- while (args.length > 0) {
156
- const token = args.shift();
157
-
158
- if (token === "--project-root") {
159
- options.projectRoot = requireOptionValue(args, "--project-root");
160
- continue;
161
- }
162
-
163
- if (token === "--phase") {
164
- options.phase = requireOptionValue(args, "--phase");
165
- continue;
166
- }
167
-
168
- if (token === "--patch") {
169
- options.patch = JSON.parse(requireOptionValue(args, "--patch"));
170
- continue;
171
- }
172
-
173
- throw new Error(`Unknown argument: ${token}`);
174
- }
175
-
176
152
  if (!options.phase) {
177
153
  throw new Error("Missing required --phase <phase-name> argument");
178
154
  }
@@ -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" };
@@ -210,7 +375,19 @@ export async function syncBoardStatus(
210
375
  );
211
376
  return { ok: true, skipped: false, result };
212
377
  } catch (err) {
213
- return { ok: true, skipped: true, reason: err.message ?? "board sync failed" };
378
+ // Fail-open: a board hiccup (rate limit, missing column, item not on board)
379
+ // must never break the loop.
380
+ const reason = err?.message ?? "board sync failed";
381
+ // AC4: the explicit "item not on board" case is a clean, logged no-op.
382
+ // Other fail-open failures (rate limit, missing column, etc.) get a
383
+ // distinct, distinguishable message so they are not conflated with AC4.
384
+ const notOnBoard = err?.code === "ITEM_NOT_FOUND" || err?.code === "ITEM_NOT_ON_BOARD";
385
+ if (notOnBoard) {
386
+ log(`[board-sync] no-op: item ${itemNumber} is not on the board (${reason})`);
387
+ } else {
388
+ log(`[board-sync] sync failed (fail-open) for item ${itemNumber} → "${targetColumn}": ${reason}`);
389
+ }
390
+ return { ok: true, skipped: true, reason };
214
391
  }
215
392
  }
216
393
 
@@ -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 {