@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 +2 -1
- package/src/bash-exit-one.mjs +26 -8
- package/src/claude/asset-generation.mjs +22 -6
- package/src/cli/primitives.mjs +84 -0
- package/src/config/extension-defaults.yaml +11 -3
- package/src/github/review-threads.mjs +8 -26
- package/src/loop/copilot-loop-state.mjs +35 -21
- package/src/loop/handoff-envelope.mjs +29 -1
- package/src/loop/phase-files.mjs +11 -35
- package/src/loop/pr-gate-coordination.mjs +150 -0
- package/src/loop/pr-title-markers.mjs +56 -0
- package/src/loop/queue-board-sync.mjs +178 -1
- package/src/loop/queue-driver.mjs +47 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.2.
|
|
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",
|
package/src/bash-exit-one.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 === "
|
|
94
|
-
recordJson =
|
|
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 = ["---"];
|
package/src/cli/primitives.mjs
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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)
|
|
355
|
-
// the normal fix
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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:
|
|
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)) {
|
package/src/loop/phase-files.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
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 {
|