@dev-loops/core 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -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/config.mjs +82 -0
- package/src/config/extension-defaults.yaml +12 -0
- package/src/github/copilot-helpers.mjs +65 -0
- package/src/github/review-threads.mjs +8 -26
- package/src/loop/copilot-loop-state.mjs +35 -21
- package/src/loop/gate-fanin.mjs +222 -0
- package/src/loop/phase-files.mjs +11 -35
- package/src/loop/pr-gate-coordination.mjs +183 -3
- package/src/loop/queue-board-sync.mjs +182 -2
- package/src/loop/queue-driver.mjs +47 -12
- package/src/loop/queue-membership.mjs +145 -0
- package/src/loop/queue-state.mjs +56 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
|
|
6
6
|
"exports": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
|
|
29
29
|
"./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
|
|
30
30
|
"./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
|
|
31
|
+
"./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
|
|
31
32
|
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
32
33
|
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
33
34
|
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
@@ -36,7 +37,9 @@
|
|
|
36
37
|
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
37
38
|
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
38
39
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
40
|
+
"./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
|
|
39
41
|
"./loop/queue-driver": "./src/loop/queue-driver.mjs",
|
|
42
|
+
"./loop/queue-membership": "./src/loop/queue-membership.mjs",
|
|
40
43
|
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
41
44
|
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
42
45
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.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);
|
package/src/config/config.mjs
CHANGED
|
@@ -53,6 +53,22 @@ const GatesConfig = z.strictObject({
|
|
|
53
53
|
// `requireCi` is only behaviorally configurable for the draft gate.
|
|
54
54
|
// preApproval always requires CI even if config repeats `requireCi`.
|
|
55
55
|
preApproval: GateConfig.optional(),
|
|
56
|
+
// Fail-closed enforcement that a gate verdict was produced by the
|
|
57
|
+
// fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
|
|
58
|
+
// durable findings-log ledger), not an inline single-agent run. Default
|
|
59
|
+
// true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
|
|
60
|
+
// unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
|
|
61
|
+
requireFanoutEvidence: z.boolean().default(true),
|
|
62
|
+
// Cap on how many scoped `review` reviewers the gate fan-out spawns in
|
|
63
|
+
// parallel. When the resolved angle set exceeds this cap, the overflow runs
|
|
64
|
+
// in sequential batches and the degradation is recorded in the gate evidence.
|
|
65
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).default(8),
|
|
66
|
+
// Post the consolidated gate fan-out findings as a visible, marker-tagged PR
|
|
67
|
+
// comment so they are auditable and Copilot/humans are aware of them. Default
|
|
68
|
+
// true (opt-out). The disposition ledger is written regardless; this flag only
|
|
69
|
+
// suppresses the PR comment when explicitly false. See
|
|
70
|
+
// docs/gate-review-sub-loop-contract.md.
|
|
71
|
+
postFindingsComments: z.boolean().default(true),
|
|
56
72
|
});
|
|
57
73
|
|
|
58
74
|
const AutonomyConfig = z.strictObject({
|
|
@@ -85,6 +101,7 @@ const QueueConfig = z.strictObject({
|
|
|
85
101
|
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
|
|
86
102
|
projectNumber: z.number().int().positive().optional(),
|
|
87
103
|
boardTitle: z.string().trim().min(1).optional(),
|
|
104
|
+
archiveOlderThanDays: z.number().int().positive().optional(),
|
|
88
105
|
});
|
|
89
106
|
|
|
90
107
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
@@ -106,6 +123,9 @@ const FileGateConfig = GateConfig.partial();
|
|
|
106
123
|
const FileGatesConfig = z.strictObject({
|
|
107
124
|
draft: FileGateConfig.optional(),
|
|
108
125
|
preApproval: FileGateConfig.optional(),
|
|
126
|
+
requireFanoutEvidence: z.boolean().optional(),
|
|
127
|
+
maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
|
|
128
|
+
postFindingsComments: z.boolean().optional(),
|
|
109
129
|
});
|
|
110
130
|
|
|
111
131
|
// Partial persona entries for file-level config (allows omitting fields)
|
|
@@ -230,6 +250,8 @@ const BUILTIN_PERSONAS = Object.freeze({
|
|
|
230
250
|
"state-concurrency": { persona: "review", defaultModel: null },
|
|
231
251
|
"renderer-security": { persona: "review", defaultModel: null },
|
|
232
252
|
determinism: { persona: "review", defaultModel: null },
|
|
253
|
+
"acceptance-criteria": { persona: "review", defaultModel: null },
|
|
254
|
+
"ac-dod": { persona: "review", defaultModel: null },
|
|
233
255
|
});
|
|
234
256
|
|
|
235
257
|
const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
|
|
@@ -814,6 +836,66 @@ export function resolveGateConfig(config, gate) {
|
|
|
814
836
|
};
|
|
815
837
|
}
|
|
816
838
|
|
|
839
|
+
/**
|
|
840
|
+
* Resolve whether fan-out/fan-in review evidence is required for a gate verdict.
|
|
841
|
+
*
|
|
842
|
+
* Default-on (opt-out): enforcement is ON unless `gates.requireFanoutEvidence`
|
|
843
|
+
* is explicitly set to false. When ON, the pre-merge evidence check fails
|
|
844
|
+
* closed unless a required gate's recorded executionMode is "fanout_fanin" and
|
|
845
|
+
* a durable findings-log ledger exists for that gate + head SHA. Using a
|
|
846
|
+
* `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
|
|
847
|
+
* for programmatically-built config objects that bypass schema defaulting. See
|
|
848
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
849
|
+
*
|
|
850
|
+
* @param {DevLoopConfig} config
|
|
851
|
+
* @returns {boolean}
|
|
852
|
+
*/
|
|
853
|
+
export function resolveRequireFanoutEvidence(config) {
|
|
854
|
+
return config?.gates?.requireFanoutEvidence !== false;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Default parallel fan-out reviewer cap (mirrors GatesConfig.maxFanoutReviewers). */
|
|
858
|
+
export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Resolve the parallel fan-out reviewer cap for the gate sub-loop.
|
|
862
|
+
*
|
|
863
|
+
* Returns the configured `gates.maxFanoutReviewers` when it is an integer in
|
|
864
|
+
* the schema-bounded range 1..64; otherwise the built-in default (8). Clamping
|
|
865
|
+
* here (not just the Zod schema) keeps programmatically-constructed config
|
|
866
|
+
* objects that bypass schema validation within the same bound. The fan-out
|
|
867
|
+
* spawns at most this many scoped `review` reviewers in parallel; overflow runs
|
|
868
|
+
* sequentially.
|
|
869
|
+
*
|
|
870
|
+
* @param {DevLoopConfig} config
|
|
871
|
+
* @returns {number}
|
|
872
|
+
*/
|
|
873
|
+
export function resolveMaxFanoutReviewers(config) {
|
|
874
|
+
const raw = config?.gates?.maxFanoutReviewers;
|
|
875
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
|
|
876
|
+
return raw;
|
|
877
|
+
}
|
|
878
|
+
return DEFAULT_MAX_FANOUT_REVIEWERS;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Resolve whether the consolidated gate fan-out findings should be posted as a
|
|
883
|
+
* visible, marker-tagged PR comment.
|
|
884
|
+
*
|
|
885
|
+
* Returns true (post the comment) unless `gates.postFindingsComments` is
|
|
886
|
+
* explicitly set to false. Using a `!== false` test (rather than `=== true`)
|
|
887
|
+
* keeps the opt-out semantics robust for programmatically-built config objects
|
|
888
|
+
* that bypass schema defaulting. The disposition ledger is written regardless;
|
|
889
|
+
* this flag only suppresses the auditable PR comment. See
|
|
890
|
+
* docs/gate-review-sub-loop-contract.md.
|
|
891
|
+
*
|
|
892
|
+
* @param {DevLoopConfig} config
|
|
893
|
+
* @returns {boolean}
|
|
894
|
+
*/
|
|
895
|
+
export function resolveGatePostFindingsComments(config) {
|
|
896
|
+
return config?.gates?.postFindingsComments !== false;
|
|
897
|
+
}
|
|
898
|
+
|
|
817
899
|
/**
|
|
818
900
|
* Resolve local implementation light mode config.
|
|
819
901
|
*
|
|
@@ -382,6 +382,18 @@ personas:
|
|
|
382
382
|
Do not block on formatting preferences other than checkbox correctness.
|
|
383
383
|
defaultModel: null
|
|
384
384
|
|
|
385
|
+
acceptance-criteria:
|
|
386
|
+
persona: review
|
|
387
|
+
prompt: >-
|
|
388
|
+
Verify that each acceptance criterion and definition-of-done item from the
|
|
389
|
+
linked issue/PR is actually satisfied by the implementation — not merely
|
|
390
|
+
listed. For every criterion, cite the concrete code/test/behavior evidence
|
|
391
|
+
that meets it; flag any criterion that is unmet, only partially met, or
|
|
392
|
+
unverifiable from the diff as a blocking finding. Confirm definition-of-done
|
|
393
|
+
items (tests, docs, validation) are done and that declared non-goals are
|
|
394
|
+
respected (no scope creep).
|
|
395
|
+
defaultModel: null
|
|
396
|
+
|
|
385
397
|
pr-checklist-matrix:
|
|
386
398
|
persona: review
|
|
387
399
|
prompt: >-
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
|
|
10
10
|
const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
|
|
11
11
|
const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
|
|
12
|
+
const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
|
|
12
13
|
|
|
13
14
|
export function isCopilotLogin(login) {
|
|
14
15
|
return typeof login === "string" && /^copilot(?:[^a-z]|$)/i.test(login);
|
|
@@ -63,6 +64,11 @@ function normalizeGateReviewHeadSha(value) {
|
|
|
63
64
|
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function normalizeGateExecutionMode(value) {
|
|
68
|
+
const normalized = stripOptionalCodeTicks(value).toLowerCase();
|
|
69
|
+
return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
function parseGateReviewCommentFields(body) {
|
|
67
73
|
if (typeof body !== "string" || body.trim().length === 0) {
|
|
68
74
|
return null;
|
|
@@ -74,6 +80,8 @@ function parseGateReviewCommentFields(body) {
|
|
|
74
80
|
verdict: null,
|
|
75
81
|
findingsSummary: null,
|
|
76
82
|
nextAction: null,
|
|
83
|
+
executionMode: null,
|
|
84
|
+
inlineReason: null,
|
|
77
85
|
};
|
|
78
86
|
|
|
79
87
|
for (const rawLine of body.split(/\r?\n/u)) {
|
|
@@ -112,6 +120,24 @@ function parseGateReviewCommentFields(body) {
|
|
|
112
120
|
fields.nextAction = match[1].trim();
|
|
113
121
|
continue;
|
|
114
122
|
}
|
|
123
|
+
|
|
124
|
+
match = line.match(/^(?:[-*]\s*)?execution\s+mode\s*:\s*(.+)$/iu);
|
|
125
|
+
if (match) {
|
|
126
|
+
const rest = match[1].trim();
|
|
127
|
+
// Split on the first em-dash / en-dash / " - " separator to recover an
|
|
128
|
+
// optional inline reason: "inline_single_agent — <reason>".
|
|
129
|
+
const sepMatch = rest.match(/^(.*?)\s*(?:[—–]|\s-\s)\s*(.*)$/u);
|
|
130
|
+
const modeToken = sepMatch ? sepMatch[1].trim() : rest;
|
|
131
|
+
const reasonToken = sepMatch ? sepMatch[2].trim() : "";
|
|
132
|
+
fields.executionMode = normalizeGateExecutionMode(modeToken);
|
|
133
|
+
// Only record an inline reason for inline_single_agent. A trailing
|
|
134
|
+
// "— text" on a fanout_fanin (or invalid) mode line must not surface an
|
|
135
|
+
// inconsistent mode/reason pair, so leave inlineReason null otherwise.
|
|
136
|
+
if (reasonToken.length > 0 && fields.executionMode === "inline_single_agent") {
|
|
137
|
+
fields.inlineReason = reasonToken;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
// Lenient fallback: detect gate name and head SHA anywhere in body
|
|
@@ -178,6 +204,8 @@ export function parseGateReviewCommentMarkerBody(body) {
|
|
|
178
204
|
verdict: fields.verdict,
|
|
179
205
|
findingsSummary: fields.findingsSummary,
|
|
180
206
|
nextAction: fields.nextAction,
|
|
207
|
+
executionMode: fields.executionMode,
|
|
208
|
+
inlineReason: fields.inlineReason,
|
|
181
209
|
contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
|
|
182
210
|
};
|
|
183
211
|
}
|
|
@@ -205,6 +233,8 @@ export function summarizeGateReviewComments(comments) {
|
|
|
205
233
|
verdict: parsed.verdict,
|
|
206
234
|
findingsSummary: parsed.findingsSummary,
|
|
207
235
|
nextAction: parsed.nextAction,
|
|
236
|
+
executionMode: parsed.executionMode ?? null,
|
|
237
|
+
inlineReason: parsed.inlineReason ?? null,
|
|
208
238
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
209
239
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
210
240
|
updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
|
|
@@ -253,6 +283,8 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
253
283
|
verdict: parsed.verdict,
|
|
254
284
|
findingsSummary: parsed.findingsSummary,
|
|
255
285
|
nextAction: parsed.nextAction,
|
|
286
|
+
executionMode: parsed.executionMode ?? null,
|
|
287
|
+
inlineReason: parsed.inlineReason ?? null,
|
|
256
288
|
contractComplete: parsed.contractComplete,
|
|
257
289
|
commentId: Number.isInteger(comment?.id) ? comment.id : null,
|
|
258
290
|
commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
|
|
@@ -274,6 +306,39 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
|
|
|
274
306
|
return summary;
|
|
275
307
|
}
|
|
276
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Resolve the draft-gate round-reset timestamp (ms) used to suppress stale Copilot
|
|
311
|
+
* review rounds from the count (#896 consistency).
|
|
312
|
+
*
|
|
313
|
+
* When the draft gate was re-passed clean on a DIFFERENT head than the current one,
|
|
314
|
+
* only Copilot reviews submitted after that re-pass should count toward the round
|
|
315
|
+
* cap. Returning the re-pass `updatedAt` (ms) lets {@link summarizeCopilotReviews}
|
|
316
|
+
* drop earlier rounds. Returns null when no reset applies (no clean draft gate, or
|
|
317
|
+
* the clean draft gate is already on the current head).
|
|
318
|
+
*
|
|
319
|
+
* Both detect-pr-gate-coordination-state and request-copilot-review must derive the
|
|
320
|
+
* reset identically, or the two scripts disagree on the completed round count and
|
|
321
|
+
* the cap (the inconsistency reported in #896). This is the single shared source.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} params
|
|
324
|
+
* @param {{ verdict?: string|null, headSha?: string|null, updatedAt?: string|null }|null} params.draftGate
|
|
325
|
+
* @param {string|null} params.currentHeadSha
|
|
326
|
+
* @returns {number|null} reset timestamp in ms, or null
|
|
327
|
+
*/
|
|
328
|
+
export function resolveDraftGateRoundResetMs({ draftGate, currentHeadSha } = {}) {
|
|
329
|
+
const draftGateHeadSha = typeof draftGate?.headSha === "string" ? draftGate.headSha : null;
|
|
330
|
+
const draftGateOnCurrentHead = typeof draftGateHeadSha === "string"
|
|
331
|
+
&& typeof currentHeadSha === "string"
|
|
332
|
+
&& currentHeadSha.startsWith(draftGateHeadSha);
|
|
333
|
+
if (draftGate?.verdict === "clean"
|
|
334
|
+
&& typeof draftGateHeadSha === "string"
|
|
335
|
+
&& !draftGateOnCurrentHead
|
|
336
|
+
&& typeof draftGate?.updatedAt === "string") {
|
|
337
|
+
return normalizeTimestamp(draftGate.updatedAt);
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
277
342
|
export function summarizeCopilotReviews(reviews, { headSha, draftGateResetAtMs } = {}) {
|
|
278
343
|
const allReviews = Array.isArray(reviews) ? reviews : [];
|
|
279
344
|
const copilotReviews = allReviews.filter((review) => isCopilotLogin(review?.author?.login));
|
|
@@ -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) {
|