@dev-loops/core 0.2.7 → 0.4.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,7 +1,10 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.2.7",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
5
8
  "description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
6
9
  "exports": {
7
10
  "./bash-exit-one": "./src/bash-exit-one.mjs",
@@ -28,6 +31,7 @@
28
31
  "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
29
32
  "./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
30
33
  "./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
34
+ "./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
31
35
  "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
32
36
  "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
33
37
  "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
@@ -36,7 +40,9 @@
36
40
  "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
37
41
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
38
42
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
43
+ "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
39
44
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
45
+ "./loop/queue-membership": "./src/loop/queue-membership.mjs",
40
46
  "./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
41
47
  "./loop/queue-state": "./src/loop/queue-state.mjs",
42
48
  "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
@@ -53,12 +53,52 @@ 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({
59
75
  stopAt: z.array(
60
76
  z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
61
77
  ),
78
+ // When true, merge is a fixed, non-overridable human action: the agent never
79
+ // runs `gh pr merge`, `resolveAutonomyStopAt` always includes "merge", and
80
+ // any per-run merge authorization (envelope flag / explicit instruction) is
81
+ // ignored — it fails closed. See resolveHumanMergeOnly / resolveEffectiveMergeAuthorized.
82
+ humanMergeOnly: z.boolean().optional(),
83
+ });
84
+
85
+ /**
86
+ * Human-handoff config (#920, Request B of #910): at the pre-approval /
87
+ * merge-handoff boundary, OFFER to assign the PR to a named human
88
+ * reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
89
+ * `candidatesFrom` selects which sources the resolver queries; `assignees` is a
90
+ * static highest-priority candidate list. Absent/empty = disabled no-op.
91
+ */
92
+ const HumanHandoffConfig = z.strictObject({
93
+ enabled: z.boolean().default(false),
94
+ candidatesFrom: z
95
+ .array(z.enum(["codeowners", "recent-committers"]))
96
+ .optional(),
97
+ assignees: z.array(z.string().trim().min(1)).optional(),
98
+ });
99
+
100
+ const ApprovalConfig = z.strictObject({
101
+ humanHandoff: HumanHandoffConfig.optional(),
62
102
  });
63
103
 
64
104
  const WorkflowConfig = z.strictObject({
@@ -85,6 +125,19 @@ const QueueConfig = z.strictObject({
85
125
  reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
86
126
  projectNumber: z.number().int().positive().optional(),
87
127
  boardTitle: z.string().trim().min(1).optional(),
128
+ archiveOlderThanDays: z.number().int().positive().optional(),
129
+ });
130
+
131
+ /**
132
+ * Worktree lifecycle config (#909): which gitignored files/dirs to provision
133
+ * into a fresh worktree from the main checkout. Entries are repo-relative
134
+ * literal paths OR glob patterns. `copyOnInit` → `fs.cp` (isolated per
135
+ * worktree); `linkOnInit` → absolute symlink into the main checkout (read-only
136
+ * data). Both optional; empty/absent is a valid no-op.
137
+ */
138
+ const WorktreeConfig = z.strictObject({
139
+ copyOnInit: z.array(z.string().trim().min(1)).optional(),
140
+ linkOnInit: z.array(z.string().trim().min(1)).optional(),
88
141
  });
89
142
 
90
143
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
@@ -106,6 +159,9 @@ const FileGateConfig = GateConfig.partial();
106
159
  const FileGatesConfig = z.strictObject({
107
160
  draft: FileGateConfig.optional(),
108
161
  preApproval: FileGateConfig.optional(),
162
+ requireFanoutEvidence: z.boolean().optional(),
163
+ maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
164
+ postFindingsComments: z.boolean().optional(),
109
165
  });
110
166
 
111
167
  // Partial persona entries for file-level config (allows omitting fields)
@@ -127,11 +183,13 @@ export const DevLoopConfigSchema = z.strictObject({
127
183
  refinement: RefinementConfig.optional(),
128
184
  gates: GatesConfig.optional(),
129
185
  autonomy: AutonomyConfig.optional(),
186
+ approval: ApprovalConfig.optional(),
130
187
  workflow: WorkflowConfig.optional(),
131
188
  localImplementation: LocalImplementationConfig.optional(),
132
189
  queue: QueueConfig.optional(),
133
190
  personas: PersonasConfig.optional(),
134
191
  internalPathPatterns: InternalPatternsConfig.optional(),
192
+ worktree: WorktreeConfig.optional(),
135
193
  });
136
194
 
137
195
  // ============================================================================
@@ -145,7 +203,14 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
145
203
  models: Object.freeze({}),
146
204
  refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
147
205
  gates: Object.freeze({}),
148
- autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]) }),
206
+ autonomy: Object.freeze({ stopAt: Object.freeze(["merge"]), humanMergeOnly: false }),
207
+ approval: Object.freeze({
208
+ humanHandoff: Object.freeze({
209
+ enabled: false,
210
+ candidatesFrom: Object.freeze([]),
211
+ assignees: Object.freeze([]),
212
+ }),
213
+ }),
149
214
  workflow: Object.freeze({
150
215
  asyncStartMode: "required",
151
216
  requireRetrospective: false,
@@ -173,6 +238,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
173
238
  "^\\.github/",
174
239
  "^test/",
175
240
  ]),
241
+ worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
176
242
  });
177
243
 
178
244
  // ============================================================================
@@ -187,11 +253,13 @@ export const FileConfigSchema = z.strictObject({
187
253
  refinement: RefinementConfig.partial().optional(),
188
254
  gates: FileGatesConfig.optional(),
189
255
  autonomy: AutonomyConfig.partial().optional(),
256
+ approval: ApprovalConfig.partial().optional(),
190
257
  workflow: WorkflowConfig.partial().optional(),
191
258
  localImplementation: LocalImplementationConfig.partial().optional(),
192
259
  queue: QueueConfig.partial().optional(),
193
260
  personas: FilePersonasConfig.optional(),
194
261
  internalPathPatterns: InternalPatternsConfig.optional(),
262
+ worktree: WorktreeConfig.partial().optional(),
195
263
  });
196
264
 
197
265
  // ============================================================================
@@ -230,6 +298,8 @@ const BUILTIN_PERSONAS = Object.freeze({
230
298
  "state-concurrency": { persona: "review", defaultModel: null },
231
299
  "renderer-security": { persona: "review", defaultModel: null },
232
300
  determinism: { persona: "review", defaultModel: null },
301
+ "acceptance-criteria": { persona: "review", defaultModel: null },
302
+ "ac-dod": { persona: "review", defaultModel: null },
233
303
  });
234
304
 
235
305
  const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
@@ -705,10 +775,66 @@ export function resolveConductorModel(config) {
705
775
  * @returns {string[]}
706
776
  */
707
777
  export function resolveAutonomyStopAt(config) {
708
- if (config?.autonomy?.stopAt && Array.isArray(config.autonomy.stopAt)) {
709
- return [...config.autonomy.stopAt];
778
+ const base = (config?.autonomy?.stopAt && Array.isArray(config.autonomy.stopAt))
779
+ ? [...config.autonomy.stopAt]
780
+ : ["merge"];
781
+ // Fail closed: humanMergeOnly forces a human stop at merge regardless of
782
+ // what stopAt is configured (even an explicit []).
783
+ if (resolveHumanMergeOnly(config) && !base.includes("merge")) {
784
+ base.push("merge");
710
785
  }
711
- return ["merge"];
786
+ return base;
787
+ }
788
+
789
+ /**
790
+ * Resolve the fixed human-merge-only invariant from the merged dev-loop config.
791
+ *
792
+ * When true, the agent must never perform the merge itself: `gh pr merge` is a
793
+ * human-only action and any per-run merge authorization is ignored. Defaults to
794
+ * false (the agent may merge once authorized).
795
+ *
796
+ * @param {DevLoopConfig} config
797
+ * @returns {boolean}
798
+ */
799
+ export function resolveHumanMergeOnly(config) {
800
+ return config?.autonomy?.humanMergeOnly === true;
801
+ }
802
+
803
+ /**
804
+ * Authoritative gate: resolve the effective merge authorization for the agent.
805
+ *
806
+ * This is the single chokepoint that decides whether the agent is cleared to
807
+ * run `gh pr merge`. When `humanMergeOnly` is set on the repo config, this
808
+ * ALWAYS returns false — the per-run `mergeAuthorized` flag (envelope flag or
809
+ * explicit "merge" instruction) cannot override the repo invariant. Fails
810
+ * closed: a non-boolean `mergeAuthorized` is treated as not authorized.
811
+ *
812
+ * @param {boolean} mergeAuthorized per-run authorization signal
813
+ * @param {DevLoopConfig} config merged dev-loop config
814
+ * @returns {boolean}
815
+ */
816
+ export function resolveEffectiveMergeAuthorized(mergeAuthorized, config) {
817
+ if (resolveHumanMergeOnly(config)) return false;
818
+ return mergeAuthorized === true;
819
+ }
820
+
821
+ /**
822
+ * Authoritative gate for callers that load the config themselves and hold its
823
+ * `{ config, errors }` load result. FAILS CLOSED on any config load/validation
824
+ * error: `loadDevLoopConfig` never throws (it returns an `errors` array), so a
825
+ * caller must not assume "no exception" means "config is safe". If the config
826
+ * could not be loaded/validated, the `.devloops` file declaring `humanMergeOnly`
827
+ * may be the very one that failed — so merge authorization is denied rather than
828
+ * silently granted from a fallback config that lacks the invariant.
829
+ *
830
+ * @param {boolean} mergeAuthorized per-run authorization signal
831
+ * @param {{ config?: DevLoopConfig, errors?: Array<unknown> }} loadResult result of `loadDevLoopConfig`
832
+ * @returns {boolean}
833
+ */
834
+ export function resolveEffectiveMergeAuthorizedFromLoad(mergeAuthorized, loadResult) {
835
+ const errors = loadResult?.errors ?? [];
836
+ if (errors.length > 0) return false;
837
+ return resolveEffectiveMergeAuthorized(mergeAuthorized, loadResult?.config);
712
838
  }
713
839
 
714
840
  const DEFAULT_REFINEMENT_CONFIG = BUILT_IN_DEFAULTS.refinement;
@@ -814,6 +940,66 @@ export function resolveGateConfig(config, gate) {
814
940
  };
815
941
  }
816
942
 
943
+ /**
944
+ * Resolve whether fan-out/fan-in review evidence is required for a gate verdict.
945
+ *
946
+ * Default-on (opt-out): enforcement is ON unless `gates.requireFanoutEvidence`
947
+ * is explicitly set to false. When ON, the pre-merge evidence check fails
948
+ * closed unless a required gate's recorded executionMode is "fanout_fanin" and
949
+ * a durable findings-log ledger exists for that gate + head SHA. Using a
950
+ * `!== false` test (rather than `=== true`) keeps the opt-out semantics robust
951
+ * for programmatically-built config objects that bypass schema defaulting. See
952
+ * docs/gate-review-sub-loop-contract.md.
953
+ *
954
+ * @param {DevLoopConfig} config
955
+ * @returns {boolean}
956
+ */
957
+ export function resolveRequireFanoutEvidence(config) {
958
+ return config?.gates?.requireFanoutEvidence !== false;
959
+ }
960
+
961
+ /** Default parallel fan-out reviewer cap (mirrors GatesConfig.maxFanoutReviewers). */
962
+ export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
963
+
964
+ /**
965
+ * Resolve the parallel fan-out reviewer cap for the gate sub-loop.
966
+ *
967
+ * Returns the configured `gates.maxFanoutReviewers` when it is an integer in
968
+ * the schema-bounded range 1..64; otherwise the built-in default (8). Clamping
969
+ * here (not just the Zod schema) keeps programmatically-constructed config
970
+ * objects that bypass schema validation within the same bound. The fan-out
971
+ * spawns at most this many scoped `review` reviewers in parallel; overflow runs
972
+ * sequentially.
973
+ *
974
+ * @param {DevLoopConfig} config
975
+ * @returns {number}
976
+ */
977
+ export function resolveMaxFanoutReviewers(config) {
978
+ const raw = config?.gates?.maxFanoutReviewers;
979
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
980
+ return raw;
981
+ }
982
+ return DEFAULT_MAX_FANOUT_REVIEWERS;
983
+ }
984
+
985
+ /**
986
+ * Resolve whether the consolidated gate fan-out findings should be posted as a
987
+ * visible, marker-tagged PR comment.
988
+ *
989
+ * Returns true (post the comment) unless `gates.postFindingsComments` is
990
+ * explicitly set to false. Using a `!== false` test (rather than `=== true`)
991
+ * keeps the opt-out semantics robust for programmatically-built config objects
992
+ * that bypass schema defaulting. The disposition ledger is written regardless;
993
+ * this flag only suppresses the auditable PR comment. See
994
+ * docs/gate-review-sub-loop-contract.md.
995
+ *
996
+ * @param {DevLoopConfig} config
997
+ * @returns {boolean}
998
+ */
999
+ export function resolveGatePostFindingsComments(config) {
1000
+ return config?.gates?.postFindingsComments !== false;
1001
+ }
1002
+
817
1003
  /**
818
1004
  * Resolve local implementation light mode config.
819
1005
  *
@@ -971,6 +1157,61 @@ const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
971
1157
  * @param {DevLoopConfig} config
972
1158
  * @returns {string[]}
973
1159
  */
1160
+ /**
1161
+ * Resolve the worktree lifecycle config from the merged dev-loop config.
1162
+ *
1163
+ * Returns `{ copyOnInit, linkOnInit }` with empty-array defaults when the
1164
+ * config omits the `worktree` section or either list. Entries are trimmed,
1165
+ * repo-relative literal paths or glob patterns expanded against the main
1166
+ * checkout at provision time. See scripts/loop/provision-worktree.mjs.
1167
+ *
1168
+ * @param {DevLoopConfig} config
1169
+ * @returns {{ copyOnInit: string[], linkOnInit: string[] }}
1170
+ */
1171
+ export function resolveWorktreeConfig(config) {
1172
+ const wt = config?.worktree;
1173
+ const list = (v) =>
1174
+ Array.isArray(v)
1175
+ ? v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0)
1176
+ : [];
1177
+ return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1178
+ }
1179
+
1180
+ /**
1181
+ * Resolve the human-handoff config from the merged dev-loop config (#920).
1182
+ *
1183
+ * Returns a normalized `{ enabled, candidatesFrom, assignees }`. Defaults to
1184
+ * disabled with empty arrays when the `approval.humanHandoff` section is absent.
1185
+ * When disabled (default), this is a no-op: callers must not source candidates
1186
+ * or assign anyone. Pairs with `autonomy.humanMergeOnly`: when human-merge is
1187
+ * enforced, this names who should take the merge.
1188
+ *
1189
+ * @param {DevLoopConfig} config
1190
+ * @returns {{ enabled: boolean, candidatesFrom: ("codeowners"|"recent-committers")[], assignees: string[] }}
1191
+ */
1192
+ export function resolveHumanHandoffConfig(config) {
1193
+ const hh = config?.approval?.humanHandoff;
1194
+ const enabled = hh?.enabled === true;
1195
+ const list = (v) =>
1196
+ Array.isArray(v)
1197
+ ? v.map((s) => (typeof s === "string" ? s.trim() : "")).filter((s) => s.length > 0)
1198
+ : [];
1199
+ const candidatesFrom = list(hh?.candidatesFrom).filter(
1200
+ (s) => s === "codeowners" || s === "recent-committers"
1201
+ );
1202
+ // Normalize assignees: strip a leading `@`, trim, and drop empties so an empty
1203
+ // login (e.g. config value of `"@"` or `""`) can never leak downstream into
1204
+ // `gh pr edit --add-assignee ""`.
1205
+ const assignees = list(hh?.assignees)
1206
+ .map((s) => s.replace(/^@/, "").trim())
1207
+ .filter((s) => s.length > 0);
1208
+ return {
1209
+ enabled,
1210
+ candidatesFrom: enabled ? candidatesFrom : [],
1211
+ assignees: enabled ? assignees : [],
1212
+ };
1213
+ }
1214
+
974
1215
  export function resolveInternalPathPatterns(config) {
975
1216
  if (
976
1217
  config?.internalPathPatterns &&
@@ -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));
@@ -10,10 +10,9 @@
10
10
  * async context marker. When the marker is absent, the check fails closed
11
11
  * and returns a machine-readable rejection rather than silently proceeding.
12
12
  *
13
- * Async context markers (required when workflow.asyncStartMode is `required`),
14
- * neutral-first — see `@dev-loops/core/loop/run-context`:
13
+ * Async context marker (required when workflow.asyncStartMode is `required`)
14
+ * — see `@dev-loops/core/loop/run-context`:
15
15
  * - DEVLOOPS_RUN_ID env var (neutral, harness-agnostic)
16
- * - PI_SUBAGENT_RUN_ID env var (Pi subagent framework; retained as a compatibility alias)
17
16
  *
18
17
  * Allowed modes:
19
18
  * - workflow.asyncStartMode: required | allowed
@@ -30,11 +29,10 @@ import { RUN_ID_MARKERS, isClaudeHarness } from "./run-context.mjs";
30
29
  // ---------------------------------------------------------------------------
31
30
 
32
31
  /**
33
- * Environment variable names that indicate an async context, neutral-first.
32
+ * Environment variable names that indicate an async context.
34
33
  * Sourced from the shared run-context contract so the markers stay in one place.
35
- * The historical name is kept for back-compat; it now includes DEVLOOPS_RUN_ID.
36
34
  */
37
- export const PI_ASYNC_CONTEXT_MARKERS = RUN_ID_MARKERS;
35
+ export const ASYNC_CONTEXT_MARKERS = RUN_ID_MARKERS;
38
36
 
39
37
  /** Supported workflow async-start modes. */
40
38
  export const ASYNC_START_MODE = Object.freeze({
@@ -130,8 +128,8 @@ export function validateAsyncStartContext({
130
128
  };
131
129
  }
132
130
 
133
- // Check for any async context marker (neutral DEVLOOPS_RUN_ID or the Pi alias)
134
- for (const marker of PI_ASYNC_CONTEXT_MARKERS) {
131
+ // Check for any async context marker (DEVLOOPS_RUN_ID)
132
+ for (const marker of ASYNC_CONTEXT_MARKERS) {
135
133
  const value = env[marker];
136
134
  if (typeof value === "string" && value.trim().length > 0) {
137
135
  return {
@@ -150,24 +148,7 @@ export function validateAsyncStartContext({
150
148
  };
151
149
  }
152
150
 
153
- const sessionOnlyMarker =
154
- (typeof env.PI_SESSION_ID === "string" && env.PI_SESSION_ID.trim().length > 0)
155
- ? "PI_SESSION_ID"
156
- : ((typeof env.PI_ASYNC_CONTEXT === "string" && env.PI_ASYNC_CONTEXT.trim().length > 0)
157
- ? "PI_ASYNC_CONTEXT"
158
- : null);
159
- if (sessionOnlyMarker !== null) {
160
- return {
161
- status: ASYNC_START_STATUS.REJECTED,
162
- reason:
163
- `Detected ${sessionOnlyMarker}, but GitHub-first async-start requires a visible ` +
164
- "subagent run id for inspectable startup/resume evidence. " +
165
- "Set DEVLOOPS_RUN_ID (or the PI_SUBAGENT_RUN_ID alias) to proceed. Any exception must come from repository-maintained workflow policy.",
166
- detectedMarker: null,
167
- };
168
- }
169
-
170
- if (env.PI_DEV_LOOP_DETACHED === "1") {
151
+ if (env.DEVLOOPS_DETACHED === "1") {
171
152
  return {
172
153
  status: ASYNC_START_STATUS.REJECTED,
173
154
  reason:
@@ -185,7 +166,7 @@ export function validateAsyncStartContext({
185
166
  "No async context detected. " +
186
167
  "The dev-loop must run within a visible async subagent session, " +
187
168
  "not as a detached local process. " +
188
- `Set ${PI_ASYNC_CONTEXT_MARKERS[0]} (or the PI_SUBAGENT_RUN_ID alias) to proceed. ` +
169
+ `Set ${ASYNC_CONTEXT_MARKERS[0]} to proceed. ` +
189
170
  "Repository-maintained workflow policy controls any exceptions.",
190
171
  detectedMarker: null,
191
172
  };