@dev-loops/core 0.2.5 → 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.5",
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": {
@@ -34,6 +34,7 @@
34
34
  "./loop/phase-files": "./src/loop/phase-files.mjs",
35
35
  "./loop/policy-constants": "./src/loop/policy-constants.mjs",
36
36
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
37
+ "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
37
38
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
38
39
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
39
40
  "./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
@@ -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);
@@ -76,10 +76,18 @@ autonomy:
76
76
  # Workflow enforcement defaults.
77
77
  workflow:
78
78
  asyncStartMode: required
79
- requireRetrospective: true
80
- requireRetrospectiveGate: true
79
+ # The retrospective is a dev-loop-development artifact; shipped defaults stay permissive so an
80
+ # ordinary consumer's product PRs do not carry the meta-process gate (#841). Matches the code
81
+ # default (DEFAULT_WORKFLOW_CONFIG) and the contract. The dev-loops repo opts in via its own
82
+ # repo-root .devloops, which takes precedence over these extension defaults.
83
+ requireRetrospective: false
84
+ requireRetrospectiveGate: false
81
85
  requireDraftFirst: true
82
- devModeDefault: true
86
+ # Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
87
+ # after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
88
+ # it on consumers' product phases (#846). Matches the code default; the dev-loops repo opts in
89
+ # via its own repo-root .devloops (which takes precedence over these extension defaults).
90
+ devModeDefault: false
83
91
 
84
92
  # Light-mode threshold for small local changes.
85
93
  localImplementation:
@@ -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) {
@@ -20,6 +20,7 @@ import {
20
20
  } from "./public-dev-loop-routing-contract.mjs";
21
21
  import { normalizeRepoSlug } from "../github/repo-slug.mjs";
22
22
  import { COPILOT_REVIEW_WAIT_TIMEOUT_MS } from "./policy-constants.mjs";
23
+ import { resolveEffectiveAsyncStartMode } from "./async-start-contract.mjs";
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Constants
@@ -430,6 +431,16 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
430
431
  ? { ...options.overrides }
431
432
  : undefined;
432
433
 
434
+ // Surface the *effective* async-start posture alongside the *configured* one (#834). The
435
+ // configured `asyncStartMode` is echoed verbatim from settings (back-compat), but the contract
436
+ // is relaxed at validation time under the Claude harness (resolveEffectiveAsyncStartMode →
437
+ // "allowed" when CLAUDECODE=1). Without surfacing the effective value, a `required` envelope
438
+ // reads as if it should still block even though the resolver correctly proceeds.
439
+ const env = options.env ?? (typeof process !== "undefined" ? process.env : {});
440
+ const configuredAsyncStartMode = settings?.workflow?.asyncStartMode ?? "required";
441
+ const effectiveAsyncStartMode = resolveEffectiveAsyncStartMode(configuredAsyncStartMode, env);
442
+ const asyncStartRelaxedBy = effectiveAsyncStartMode !== configuredAsyncStartMode ? "claude-harness" : null;
443
+
433
444
  const envelope = {
434
445
  handoffVersion: ENVELOPE_HANDOFF_VERSION,
435
446
  derivedAt: (now ?? new Date()).toISOString(),
@@ -447,7 +458,9 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
447
458
  requiredReads,
448
459
 
449
460
  stopRules,
450
- asyncStartMode: settings?.workflow?.asyncStartMode ?? "required",
461
+ asyncStartMode: configuredAsyncStartMode,
462
+ asyncStartEffective: effectiveAsyncStartMode,
463
+ asyncStartRelaxedBy,
451
464
  requireDraftFirst: settings?.workflow?.requireDraftFirst ?? false,
452
465
 
453
466
  cwd: derivedCwd,
@@ -697,6 +710,21 @@ export function validateHandoffEnvelope(envelope) {
697
710
  });
698
711
  }
699
712
 
713
+ // ----- asyncStartEffective (required field; the harness-resolved posture, #834) -----
714
+ if (envelope.asyncStartEffective === undefined || envelope.asyncStartEffective === null) {
715
+ errors.push({
716
+ field: "asyncStartEffective",
717
+ reason: "must be present",
718
+ got: envelope.asyncStartEffective,
719
+ });
720
+ } else if (!VALID_ASYNC_START_MODES.includes(envelope.asyncStartEffective)) {
721
+ errors.push({
722
+ field: "asyncStartEffective",
723
+ reason: `must be one of: ${VALID_ASYNC_START_MODES.join(", ")}`,
724
+ got: envelope.asyncStartEffective,
725
+ });
726
+ }
727
+
700
728
  // ----- refinementContract (optional) -----
701
729
  if (envelope.refinementContract !== undefined && envelope.refinementContract !== null) {
702
730
  if (typeof envelope.refinementContract !== "object" || Array.isArray(envelope.refinementContract)) {
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import { DISPOSITION, STATE } from "./copilot-loop-state.mjs";
2
+ import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
2
3
 
3
4
  export const PR_CHECKPOINT = Object.freeze({
4
5
  DRAFT_REVIEW: "draft_review",
@@ -324,6 +325,56 @@ function buildRetrospectiveGatePendingResult({
324
325
  }
325
326
 
326
327
 
328
+ /**
329
+ * Blocked result for a PR that would otherwise reach final_approval_ready but
330
+ * still carries a merge-blocking marker in its title (issue #842). The title is
331
+ * the most visible contract surface, so a WIP/DRAFT/DO NOT MERGE title must
332
+ * block the final-approval boundary just like the mark-ready transition does.
333
+ */
334
+ function buildTitleMarkerBlockedResult({
335
+ input,
336
+ currentHeadSha,
337
+ draftGateAlreadySatisfied,
338
+ draftGate,
339
+ preApprovalGate,
340
+ mergeStateStatus,
341
+ conflictFiles,
342
+ markers,
343
+ refinementArtifact = null,
344
+ }) {
345
+ const allowedNextActions = [];
346
+ const forbiddenActions = [];
347
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
348
+ pushUnique(forbiddenActions, [
349
+ PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
350
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
351
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
352
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
353
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
354
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
355
+ ]);
356
+
357
+ return buildResult({
358
+ repo: input.repo ?? null,
359
+ pr: Number.isInteger(input.pr) ? input.pr : null,
360
+ currentHeadSha,
361
+ lifecycleState: "title_marker_blocked",
362
+ loopDisposition: DISPOSITION.BLOCKED,
363
+ gateBoundary: PR_CHECKPOINT.BLOCKED,
364
+ draftGateAlreadySatisfied,
365
+ draftGate,
366
+ preApprovalGate,
367
+ allowedNextActions,
368
+ forbiddenActions,
369
+ nextAction: PR_CHECKPOINT_ACTION.REPORT_BLOCKED,
370
+ reason: `Blocked: the PR title contains merge-blocking marker(s): ${markers.join(", ")}. Remove them from the title before the PR can leave draft, enter the pre-approval gate, or reach final approval.`,
371
+ mergeStateStatus,
372
+ conflictFiles,
373
+ refinementArtifact,
374
+ });
375
+ }
376
+
377
+
327
378
  function buildDraftGateNeededForMergeResult({
328
379
  input,
329
380
  currentHeadSha,
@@ -473,7 +524,63 @@ export function shouldGuardCopilotReviewRequest({
473
524
  return true;
474
525
  }
475
526
 
527
+ /**
528
+ * Boundaries at which a non-draft PR must NOT carry a merge-blocking title
529
+ * marker (issue #842 / AC2). A WIP/DRAFT/DO NOT MERGE/🚧 title is acceptable
530
+ * while the PR is still in draft, but the moment the PR leaves draft and reaches
531
+ * the pre-approval gate boundary (entry) or the final-approval boundary, the
532
+ * title is a live merge-contract surface and must be clean. The guard is applied
533
+ * once, as a post-pass over the core evaluation result, so no individual return
534
+ * site can be missed even if a PR was un-drafted externally (bypassing
535
+ * ready-for-review).
536
+ */
537
+ const TITLE_MARKER_GUARDED_BOUNDARIES = Object.freeze([
538
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_NEEDED,
539
+ PR_CHECKPOINT.PRE_APPROVAL_GATE_WINDOW,
540
+ PR_CHECKPOINT.FINAL_APPROVAL_READY,
541
+ ]);
542
+
543
+ /**
544
+ * Evaluates PR gate coordination, then re-asserts the merge-blocking title guard
545
+ * (issue #842) at the pre-approval / final-approval boundary for non-draft PRs.
546
+ *
547
+ * The title check is also performed inline at the three FINAL_APPROVAL_READY
548
+ * sites (defense in depth); this wrapper additionally covers the pre-approval
549
+ * gate boundary, which is reached before any pre-approval evidence exists and so
550
+ * is not protected by the inline checks.
551
+ */
476
552
  export function evaluatePrGateCoordination(input = {}) {
553
+ const result = evaluatePrGateCoordinationCore(input);
554
+
555
+ const prDraft = input.prDraft === true;
556
+ const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
557
+ // Draft PRs may legitimately carry a WIP title; the marker only blocks once
558
+ // the PR has left draft and is at a pre-approval/final-approval boundary.
559
+ if (prDraft || !result || typeof result !== "object") {
560
+ return result;
561
+ }
562
+ if (!TITLE_MARKER_GUARDED_BOUNDARIES.includes(result.gateBoundary)) {
563
+ return result;
564
+ }
565
+ const markers = findBlockingTitleMarkers(prTitle);
566
+ if (markers.length === 0) {
567
+ return result;
568
+ }
569
+
570
+ return buildTitleMarkerBlockedResult({
571
+ input,
572
+ currentHeadSha: result.currentHeadSha ?? null,
573
+ draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
574
+ draftGate: result.draftGate,
575
+ preApprovalGate: result.preApprovalGate,
576
+ mergeStateStatus: result.mergeStateStatus ?? null,
577
+ conflictFiles: result.conflictFiles ?? [],
578
+ markers,
579
+ refinementArtifact: result.refinementArtifact ?? null,
580
+ });
581
+ }
582
+
583
+ function evaluatePrGateCoordinationCore(input = {}) {
477
584
  const currentHeadSha = typeof input.currentHeadSha === "string" && input.currentHeadSha.trim().length > 0
478
585
  ? input.currentHeadSha.trim()
479
586
  : null;
@@ -500,6 +607,7 @@ export function evaluatePrGateCoordination(input = {}) {
500
607
  const roundCapReached = maxCopilotRounds !== null && copilotReviewRoundCount >= maxCopilotRounds;
501
608
  const requireRetrospectiveGate = input.requireRetrospectiveGate === true;
502
609
  const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
610
+ const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
503
611
  const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
504
612
  ? input.refinementArtifact
505
613
  : null;
@@ -777,6 +885,20 @@ export function evaluatePrGateCoordination(input = {}) {
777
885
  });
778
886
  }
779
887
  if (preApprovalGate.currentHeadClean) {
888
+ const titleMarkers = findBlockingTitleMarkers(prTitle);
889
+ if (titleMarkers.length > 0) {
890
+ return buildTitleMarkerBlockedResult({
891
+ input,
892
+ currentHeadSha,
893
+ draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
894
+ draftGate,
895
+ preApprovalGate,
896
+ mergeStateStatus,
897
+ conflictFiles,
898
+ markers: titleMarkers,
899
+ refinementArtifact,
900
+ });
901
+ }
780
902
  if (requireRetrospectiveGate) {
781
903
  const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
782
904
  if (!retrospectiveGate.approved) {
@@ -1035,6 +1157,20 @@ export function evaluatePrGateCoordination(input = {}) {
1035
1157
  }
1036
1158
 
1037
1159
  if (preApprovalGate.currentHeadClean) {
1160
+ const titleMarkers = findBlockingTitleMarkers(prTitle);
1161
+ if (titleMarkers.length > 0) {
1162
+ return buildTitleMarkerBlockedResult({
1163
+ input,
1164
+ currentHeadSha,
1165
+ draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1166
+ draftGate,
1167
+ preApprovalGate,
1168
+ mergeStateStatus,
1169
+ conflictFiles,
1170
+ markers: titleMarkers,
1171
+ refinementArtifact,
1172
+ });
1173
+ }
1038
1174
  if (requireRetrospectiveGate) {
1039
1175
  const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1040
1176
  if (!retrospectiveGate.approved) {
@@ -1178,6 +1314,20 @@ export function evaluatePrGateCoordination(input = {}) {
1178
1314
  });
1179
1315
  }
1180
1316
  if (preApprovalGate.currentHeadClean) {
1317
+ const titleMarkers = findBlockingTitleMarkers(prTitle);
1318
+ if (titleMarkers.length > 0) {
1319
+ return buildTitleMarkerBlockedResult({
1320
+ input,
1321
+ currentHeadSha,
1322
+ draftGateAlreadySatisfied: roundCapReached ? true : draftGateAlreadySatisfied,
1323
+ draftGate,
1324
+ preApprovalGate,
1325
+ mergeStateStatus,
1326
+ conflictFiles,
1327
+ markers: titleMarkers,
1328
+ refinementArtifact,
1329
+ });
1330
+ }
1181
1331
  if (requireRetrospectiveGate) {
1182
1332
  const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
1183
1333
  if (!retrospectiveGate.approved) {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Merge-blocking marker detection for PR titles (issue #842).
3
+ *
4
+ * The PR title is the single most visible contract surface of a pull request:
5
+ * it shows up in the PR list, in notifications, in the merge commit, and in the
6
+ * changelog. A "WIP"/"DRAFT"/"DO NOT MERGE" title on an otherwise merge-ready PR
7
+ * directly contradicts the gate's assertion that the work is done. The gate
8
+ * pipeline historically only inspected the PR body, so a stale work-in-progress
9
+ * title could slip through both the mark-ready transition and the final-approval
10
+ * boundary. This module provides the pure detection seam used at both points.
11
+ *
12
+ * It is intentionally pure and side-effect free.
13
+ */
14
+
15
+ /**
16
+ * Canonical merge-blocking markers and how to detect them.
17
+ *
18
+ * Word-boundary matching is used for the alphabetic markers so that real words
19
+ * are not false-positives (e.g. "swipe"/"wiped" must not match WIP;
20
+ * "drafting"/"redraft" must not match DRAFT). Bracket/paren/colon punctuation
21
+ * (`[WIP]`, `(wip)`, `WIP:`) are non-word characters, so `\b` boundaries still
22
+ * match those variants. The construction emoji has no word boundary, so it is
23
+ * matched literally anywhere in the title.
24
+ */
25
+ const MARKER_MATCHERS = [
26
+ { label: "WIP", pattern: /\bWIP\b/i },
27
+ { label: "DRAFT", pattern: /\bDRAFT\b/i },
28
+ // Flexible (any) whitespace between the phrase words, case-insensitive.
29
+ { label: "DO NOT MERGE", pattern: /\bDO\s+NOT\s+MERGE\b/i },
30
+ { label: "🚧", pattern: /🚧/u },
31
+ ];
32
+
33
+ /**
34
+ * Finds merge-blocking markers in a PR title.
35
+ *
36
+ * Returns the canonical labels of every matched marker, de-duped and in a
37
+ * stable order (the declaration order of {@link MARKER_MATCHERS}). Returns an
38
+ * empty array when the title is clean, empty, or not a string.
39
+ *
40
+ * @param {unknown} title - The PR title to inspect.
41
+ * @returns {string[]} Canonical labels of matched markers, e.g. ["WIP"] or
42
+ * ["DO NOT MERGE", "🚧"]. Empty when no markers are present.
43
+ */
44
+ export function findBlockingTitleMarkers(title) {
45
+ if (typeof title !== "string" || title.length === 0) {
46
+ return [];
47
+ }
48
+
49
+ const matched = [];
50
+ for (const { label, pattern } of MARKER_MATCHERS) {
51
+ if (pattern.test(title) && !matched.includes(label)) {
52
+ matched.push(label);
53
+ }
54
+ }
55
+ return matched;
56
+ }
@@ -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 {