@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.2.6",
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",
@@ -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);
@@ -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 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) {