@dev-loops/core 1.0.0-rc.4 → 1.0.0-rc.6

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.
@@ -77,6 +77,93 @@ export function backoffMaxConcurrent(maxConcurrent) {
77
77
  return Math.max(1, Math.floor(cap / 2));
78
78
  }
79
79
 
80
+ /**
81
+ * Reviewer-budget preflight for a gate fan-out (issue #1507).
82
+ *
83
+ * Before the conductor dispatches any reviewer, it derives how many reviewers
84
+ * the round needs (one per dispatch unit — fresh angles + re-verifications) and
85
+ * compares against the harness's remaining reviewer budget. When the budget
86
+ * cannot cover the dispatch, the preflight reports the shortfall BEFORE any
87
+ * reviewer spawns, naming the shortfall; the shortfall is a recorded, resumable
88
+ * state (completed per-angle artifacts stay valid for their head, so a later
89
+ * session resumes the fan-out instead of restarting it). A budget shortfall
90
+ * NEVER downgrades a required gate to `inline_single_agent` and NEVER produces a
91
+ * clean verdict — no new gate-exemption path (#1507 AC4).
92
+ *
93
+ * Pure: takes the dispatch plan + available budget, returns the decision. The
94
+ * conductor reads `artifact.fanout.preflight` (emitted by `write-gate-context`)
95
+ * and dispatches wave-by-wave only when `dispatch === true`; on `false` it
96
+ * records the shortfall (the artifact itself is the resumable record) and
97
+ * stops without spawning a single reviewer. `availableReviewers` is `null` when
98
+ * the harness does not expose a budget — no shortfall can be proven, so the
99
+ * preflight proceeds (today's behavior); it only blocks on a PROVEN shortfall.
100
+ *
101
+ * The returned `verdict` and `executionMode` are ALWAYS `null`: a shortfall is
102
+ * not a verdict. `buildPreMergeGateCheck` / `evaluateInlineFanoutMode` reject a
103
+ * gate with no clean current-head marker and a non-`fanout_fanin` execution
104
+ * mode, so a shortfall state fails closed at merge rather than yielding a clean
105
+ * or inline verdict (#1507 DoD).
106
+ *
107
+ * @param {{ name: string, angles: string[] }[]} dispatchGroups — `resolveFanoutGroups` output (fresh angles + re-verifications)
108
+ * @param {number|null} [availableReviewers] — harness remaining reviewer budget; null/non-finite = unknown/unexposed
109
+ * @param {{ completedAngles?: Iterable<string> }} [options] — `completedAngles`: angle names that
110
+ * already have a clean per-angle findings artifact stamped for THIS head. A dispatch unit (group)
111
+ * whose angles are ALL complete is excluded from the required count and from `pendingGroups`, so a
112
+ * later session resumes the fan-out instead of restarting it (issue #1507 AC3): it re-runs the
113
+ * preflight and dispatches only the groups not already complete at this head.
114
+ * @returns {{ ok: boolean, dispatch: boolean, requiredReviewers: number, availableReviewers: number|null, shortfall: number|null, reason: string, verdict: null, executionMode: null, pendingGroups: { name: string, angles: string[] }[], skippedGroups: { name: string, angles: string[] }[], completedAngles: string[] }}
115
+ */
116
+ export function reviewerBudgetPreflight(dispatchGroups, availableReviewers, { completedAngles } = {}) {
117
+ const groups = Array.isArray(dispatchGroups) ? dispatchGroups : [];
118
+ const completedSet = new Set(
119
+ Array.isArray(completedAngles)
120
+ ? completedAngles
121
+ : completedAngles == null
122
+ ? []
123
+ : [...completedAngles],
124
+ );
125
+ // #1507 AC3: resume instead of restart. One reviewer per dispatch unit (a
126
+ // group of N angles is one reviewer's scoped dispatch — see resolveFanoutGroups /
127
+ // countFreshDispatchUnits), but a group already COMPLETE at this head — every
128
+ // one of its angles has a clean artifact stamped for this head — needs no
129
+ // reviewer and is excluded from the required count and the pending plan. The
130
+ // conductor dispatches only `pendingGroups`.
131
+ const groupIsComplete = (g) =>
132
+ Array.isArray(g?.angles) && g.angles.length > 0 && g.angles.every((a) => completedSet.has(a));
133
+ const pendingGroups = groups.filter((g) => !groupIsComplete(g));
134
+ const skippedGroups = groups.filter((g) => groupIsComplete(g));
135
+ // One reviewer per dispatch unit: a group of N angles is one reviewer's
136
+ // scoped dispatch, so the reviewer count is the pending dispatch-unit count,
137
+ // not the raw angle count.
138
+ const requiredReviewers = pendingGroups.length;
139
+ const verdict = null;
140
+ const executionMode = null;
141
+ const resume = { pendingGroups, skippedGroups, completedAngles: [...completedSet] };
142
+ if (typeof availableReviewers !== "number" || !Number.isFinite(availableReviewers)) {
143
+ return { ok: true, dispatch: true, requiredReviewers, availableReviewers: null, shortfall: null, reason: "budget_unknown", verdict, executionMode, ...resume };
144
+ }
145
+ // A negative/over-spent budget clamps to 0 (budget exhausted → shortfall for
146
+ // any non-empty round); a fractional budget truncates to the integer floor.
147
+ const available = Math.max(0, Math.trunc(availableReviewers));
148
+ if (requiredReviewers === 0) {
149
+ return { ok: true, dispatch: true, requiredReviewers: 0, availableReviewers: available, shortfall: null, reason: "no_reviewers_needed", verdict, executionMode, ...resume };
150
+ }
151
+ if (available >= requiredReviewers) {
152
+ return { ok: true, dispatch: true, requiredReviewers, availableReviewers: available, shortfall: null, reason: "budget_sufficient", verdict, executionMode, ...resume };
153
+ }
154
+ return {
155
+ ok: false,
156
+ dispatch: false,
157
+ requiredReviewers,
158
+ availableReviewers: available,
159
+ shortfall: requiredReviewers - available,
160
+ reason: "budget_shortfall",
161
+ verdict,
162
+ executionMode,
163
+ ...resume,
164
+ };
165
+ }
166
+
80
167
  // Exported so other tools (e.g. scripts/loop/consolidate-fanin.mjs,
81
168
  // scripts/github/upsert-checkpoint-verdict.mjs) sort/rank/validate against
82
169
  // this single ordered copy of the severity vocabulary instead of each
@@ -736,6 +823,148 @@ export function consolidateFanin({ angleResults, blockCleanOnFindingSeverities }
736
823
  };
737
824
  }
738
825
 
826
+ /**
827
+ * The judge's relevance-based disposition vocabulary — distinct from the
828
+ * severity-based `disposition` (accepted-for-fix/deferred/needs-answer) that
829
+ * `deriveDisposition` owns. The judge decides *where* a finding is acted on
830
+ * (this PR or a follow-up), never *whether* it is real: a `reject` is a
831
+ * relevance verdict (out-of-scope against a named non-goal or scope
832
+ * boundary), not a reproduction verdict. The fixer retains reproduction-based
833
+ * rejection; the judge owns relevance (#1525).
834
+ */
835
+ export const JUDGE_DISPOSITIONS = Object.freeze(["act", "defer", "reject"]);
836
+
837
+ /**
838
+ * Validate a judge verdict artifact shape (the dedicated `judge` agent's only
839
+ * write). Pure; throws on a malformed verdict rather than silently enriching
840
+ * findings with garbage. The judge is the designated memory across rounds, so
841
+ * its artifact is the authoritative relevance record — a malformed one fails
842
+ * closed rather than degrading to severity-only disposition.
843
+ *
844
+ * Shape:
845
+ * ```
846
+ * {
847
+ * headSha: "<sha>",
848
+ * scopeDrift: { verdict: "within_scope"|"drift_detected", rationale: "...", driftedAreas: ["..."] },
849
+ * dispositions: [{ index, disposition: "act"|"defer"|"reject", rationale, criterion?, followUpDraft? }]
850
+ * }
851
+ * ```
852
+ *
853
+ * @param {unknown} verdict
854
+ * @returns {{ headSha: string, scopeDrift: object, dispositions: Array<object> }}
855
+ */
856
+ export function validateJudgeVerdict(verdict) {
857
+ if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
858
+ throw new Error("judge verdict must be a JSON object");
859
+ }
860
+ const v = /** @type {Record<string, unknown>} */ (verdict);
861
+ if (typeof v.headSha !== "string" || v.headSha.trim().length === 0) {
862
+ throw new Error("judge verdict.headSha must be a non-empty string");
863
+ }
864
+ if (!v.scopeDrift || typeof v.scopeDrift !== "object" || Array.isArray(v.scopeDrift)) {
865
+ throw new Error("judge verdict.scopeDrift must be an object");
866
+ }
867
+ const sd = /** @type {Record<string, unknown>} */ (v.scopeDrift);
868
+ if (sd.verdict !== "within_scope" && sd.verdict !== "drift_detected") {
869
+ throw new Error("judge verdict.scopeDrift.verdict must be 'within_scope' or 'drift_detected'");
870
+ }
871
+ if (typeof sd.rationale !== "string" || sd.rationale.trim().length === 0) {
872
+ throw new Error("judge verdict.scopeDrift.rationale must be a non-empty string");
873
+ }
874
+ if (!Array.isArray(sd.driftedAreas)) {
875
+ throw new Error("judge verdict.scopeDrift.driftedAreas must be an array");
876
+ }
877
+ for (const [di, area] of sd.driftedAreas.entries()) {
878
+ if (typeof area !== "string" || area.trim().length === 0) {
879
+ throw new Error(`judge verdict.scopeDrift.driftedAreas[${di}] must be a non-empty string`);
880
+ }
881
+ }
882
+ if (!Array.isArray(v.dispositions)) {
883
+ throw new Error("judge verdict.dispositions must be an array");
884
+ }
885
+ const seenIndices = new Set();
886
+ for (const [i, d] of v.dispositions.entries()) {
887
+ if (!d || typeof d !== "object" || Array.isArray(d)) {
888
+ throw new Error(`judge verdict.dispositions[${i}] must be an object`);
889
+ }
890
+ const entry = /** @type {Record<string, unknown>} */ (d);
891
+ if (!Number.isInteger(entry.index) || entry.index < 0) {
892
+ throw new Error(`judge verdict.dispositions[${i}].index must be a non-negative integer`);
893
+ }
894
+ if (seenIndices.has(entry.index)) {
895
+ throw new Error(`judge verdict.dispositions[${i}].index ${entry.index} is a duplicate — the contract is one disposition per finding`);
896
+ }
897
+ seenIndices.add(entry.index);
898
+ if (!JUDGE_DISPOSITIONS.includes(entry.disposition)) {
899
+ throw new Error(`judge verdict.dispositions[${i}].disposition must be one of: ${JUDGE_DISPOSITIONS.join(", ")}`);
900
+ }
901
+ if (typeof entry.rationale !== "string" || entry.rationale.trim().length === 0) {
902
+ throw new Error(`judge verdict.dispositions[${i}].rationale must be a non-empty string naming the criterion, non-goal, or scope boundary`);
903
+ }
904
+ // followUpDraft is REQUIRED on a defer disposition (soft-cap contract: a
905
+ // deferred finding carries a fileable follow-up draft). Optional otherwise.
906
+ if (entry.disposition === "defer") {
907
+ if (!entry.followUpDraft || typeof entry.followUpDraft !== "object" || Array.isArray(entry.followUpDraft)) {
908
+ throw new Error(`judge verdict.dispositions[${i}].followUpDraft is required on a defer disposition`);
909
+ }
910
+ const draft = /** @type {Record<string, unknown>} */ (entry.followUpDraft);
911
+ if (typeof draft.title !== "string" || draft.title.trim().length === 0 || typeof draft.body !== "string") {
912
+ throw new Error(`judge verdict.dispositions[${i}].followUpDraft must have a non-empty title and a body string`);
913
+ }
914
+ }
915
+ }
916
+ return { headSha: v.headSha, scopeDrift: v.scopeDrift, dispositions: v.dispositions };
917
+ }
918
+
919
+ /**
920
+ * Merge the judge's relevance-based dispositions into the consolidated findings
921
+ * array (the flat per-finding shape `consolidateFanin` / `toFindingsLogShape`
922
+ * produce). The judge runs AFTER fan-in and BEFORE the fix pass (#1525): it
923
+ * receives the consolidated ledger, the issue's AC/DoD/non-goals, the PR's
924
+ * declared scope, and prior-round ledgers, and emits a per-finding disposition
925
+ * (`act` / `defer` / `reject`) plus a scope-drift verdict on the PR as a whole.
926
+ *
927
+ * This function enriches each finding with `judgeDisposition`, `judgeRationale`,
928
+ * and (for `defer`) `followUpDraft` so the disposition ledger and posted findings
929
+ * comment carry what was consciously not acted on and why. The severity-based
930
+ * `disposition` (accepted-for-fix/deferred/needs-answer) is LEFT INTACT — the
931
+ * judge's relevance axis is complementary, not a replacement (a real defect
932
+ * stays a real defect; the judge decides *where* it is fixed, not *whether* it
933
+ * is real).
934
+ *
935
+ * The fix pass consumes only the `act` list; the fixer retains reproduction-
936
+ * based rejection (a finding that does not reproduce is dead regardless of the
937
+ * judge's verdict) but stops deciding relevance.
938
+ *
939
+ * Pure. Fails closed (throws) when a disposition references an out-of-range
940
+ * index — a judge verdict that names a finding that is not in the ledger is a
941
+ * mismatch, never a silent enrichment.
942
+ *
943
+ * @param {Array<object>} findings — the flat consolidated findings array
944
+ * @param {object} judgeVerdict — the validated judge verdict artifact
945
+ * @returns {{ findings: Array<object>, scopeDrift: object }}
946
+ */
947
+ export function applyJudgeDispositions(findings, judgeVerdict) {
948
+ const validated = validateJudgeVerdict(judgeVerdict);
949
+ const list = Array.isArray(findings) ? findings : [];
950
+ const enriched = list.map((f) => ({ ...f }));
951
+ for (const d of validated.dispositions) {
952
+ if (d.index >= enriched.length) {
953
+ throw new Error(`judge disposition index ${d.index} is out of range (findings has ${enriched.length} entries)`);
954
+ }
955
+ const target = enriched[d.index];
956
+ target.judgeDisposition = d.disposition;
957
+ target.judgeRationale = d.rationale;
958
+ if (typeof d.criterion === "string" && d.criterion.trim().length > 0) {
959
+ target.judgeCriterion = d.criterion.trim();
960
+ }
961
+ if (d.disposition === "defer" && d.followUpDraft) {
962
+ target.followUpDraft = d.followUpDraft;
963
+ }
964
+ }
965
+ return { findings: enriched, scopeDrift: validated.scopeDrift };
966
+ }
967
+
739
968
  /**
740
969
  * Map consolidated findings into the `--findings` JSON shape consumed by
741
970
  * scripts/github/write-gate-findings-log.mjs (severity, angle, summary,
@@ -767,6 +996,21 @@ export function toFindingsLogShape(findings) {
767
996
  if (Number.isInteger(f.line) && f.line > 0) {
768
997
  entry.line = f.line;
769
998
  }
999
+ // Carry the judge's relevance-based dispositions through (#1525) so the
1000
+ // durable ledger and posted findings comment show what was consciously not
1001
+ // acted on and why.
1002
+ if (typeof f.judgeDisposition === "string" && f.judgeDisposition.trim().length > 0) {
1003
+ entry.judgeDisposition = f.judgeDisposition.trim();
1004
+ }
1005
+ if (typeof f.judgeRationale === "string" && f.judgeRationale.trim().length > 0) {
1006
+ entry.judgeRationale = f.judgeRationale.trim();
1007
+ }
1008
+ if (typeof f.judgeCriterion === "string" && f.judgeCriterion.trim().length > 0) {
1009
+ entry.judgeCriterion = f.judgeCriterion.trim();
1010
+ }
1011
+ if (f.followUpDraft && typeof f.followUpDraft === "object" && !Array.isArray(f.followUpDraft)) {
1012
+ entry.followUpDraft = f.followUpDraft;
1013
+ }
770
1014
  return entry;
771
1015
  });
772
1016
  }
@@ -130,6 +130,21 @@ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
130
130
  activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
131
131
  });
132
132
 
133
+ // local_implementation · spike run (SPIKE-RELAXED-GATE-PROFILE, #1628): a
134
+ // spike-mode spin resolves the relaxed `spike` gate profile instead of the
135
+ // default local-implementation gate. Kept as its own acceptance key so the
136
+ // generic default can stay approach-agnostic.
137
+ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "spike", {
138
+ criteria: [
139
+ { id: "spike-recorded", must: "The spike exploration and its recommendation are recorded (spike file + summary).", severity: "required" },
140
+ { id: "verify-green", must: "`npm run verify` passes with no failures.", severity: "required" },
141
+ ],
142
+ evidence: ["commands-run", "validation-output", "changed-files"],
143
+ maxFinalizationTurns: 6,
144
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
145
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
146
+ });
147
+
133
148
  // wait_watch — dedicated window matching external healthy wait budget (policy-constants)
134
149
  register(INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH, "default", {
135
150
  criteria: [
@@ -545,6 +560,11 @@ function resolveSubGate(strategy, gateState) {
545
560
  return "default";
546
561
  }
547
562
 
563
+ /** True when the resolver output identifies a spike-mode run (#1628). */
564
+ function isSpikeRun(resolverOutput) {
565
+ return Boolean(resolverOutput && resolverOutput.spikeIntakeState);
566
+ }
567
+
548
568
 
549
569
  // ---------------------------------------------------------------------------
550
570
  // Deep freeze helper
@@ -580,7 +600,14 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
580
600
  if (!repo) throw new Error("handoff-envelope: repo slug is required (owner/name)");
581
601
 
582
602
  const gs = normalizeGateState(gateState);
583
- const subGate = resolveSubGate(strategy, gs);
603
+ // SPIKE-RELAXED-GATE-PROFILE (#1628): a spike-mode spin (startup resolver
604
+ // result carrying `spikeIntakeState`) resolves the relaxed `spike` gate
605
+ // profile instead of the default local-implementation gate. The spike
606
+ // marker lives at the TOP level of the resolver output (the bundle does not
607
+ // carry it), so it is read off `resolverOutput` directly.
608
+ const subGate = (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
609
+ ? "spike"
610
+ : resolveSubGate(strategy, gs);
584
611
  // Normalize each source independently, then fall back on the normalized result
585
612
  // (not the raw value): a present-but-invalid gateState value must NOT shadow a
586
613
  // valid options.retrospectiveFindings fallback (issue #1077 review finding).
@@ -148,20 +148,25 @@ function findSectionByPatterns(sections, patterns) {
148
148
  }
149
149
 
150
150
  /**
151
- * Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
152
- * checklist items and top-level plain `- ` bullets (dash at column 0, so
153
- * nested/indented sub-bullets are not counted). Empty checkbox placeholders
154
- * (`- [ ]` / `- [x]` with no trailing text) are skipped, not counted, so a
155
- * section of only unfilled placeholders reports as unrefined. Returns the
156
- * trimmed item text for each matching line. The checkbox state (checked vs
157
- * unchecked) is intentionally not preserved: callers only need the item
158
- * text to satisfy the refinement-artifact contract.
151
+ * Parse bullet/checkbox items from a section body into item states. Each
152
+ * checkbox item (`- [ ]`/`- [x]`/`- [X]`) becomes `{ text, checked }`
153
+ * (`checked` true only for a ticked `[x]`/`[X]`); a top-level plain bullet
154
+ * (`- text`, dash at column 0 so nested/indented sub-bullets are not counted)
155
+ * becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
156
+ * checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
157
+ * not counted, so a section of only unfilled placeholders reports as unrefined.
158
+ * Code-fenced lines are skipped (same fence logic as parseMarkdownSections,
159
+ * issue #1025) so a body cannot spoof the AC/DoD gate with code-fenced
160
+ * checkboxes.
159
161
  *
160
- * This is only ever called on the body of an already-recognized AC/DoD
161
- * section (see `detectIssueRefinementArtifact`), so counting plain bullets
162
- * is scoped to those sections and never affects prose sections.
162
+ * Shared by `extractChecklistItems` (text-only) and the unticked-AC check
163
+ * (`extractUncheckedChecklistItems`) so the two never drift on what counts as
164
+ * a checklist item or on the checkbox-state read (#1621). Only ever called on
165
+ * the body of an already-recognized AC/DoD section (see
166
+ * `detectIssueRefinementArtifact`), so counting plain bullets is scoped to
167
+ * those sections and never affects prose sections.
163
168
  */
164
- export function extractChecklistItems(sectionBody) {
169
+ function parseChecklistItems(sectionBody) {
165
170
  if (typeof sectionBody !== "string" || sectionBody.length === 0) {
166
171
  return [];
167
172
  }
@@ -171,9 +176,6 @@ export function extractChecklistItems(sectionBody) {
171
176
  let fence = null;
172
177
 
173
178
  for (const line of lines) {
174
- // Checkboxes/bullets inside a fenced code span are non-interactive text, not
175
- // real items — skip them so a body cannot spoof the AC/DoD gate with
176
- // code-fenced checkboxes (issue #1025). Same fence logic as parseMarkdownSections.
177
179
  const step = stepFence(fence, line);
178
180
  fence = step.fence;
179
181
  if (step.insideFence) {
@@ -186,7 +188,10 @@ export function extractChecklistItems(sectionBody) {
186
188
  if (checkboxMatch) {
187
189
  const text = (checkboxMatch[1] ?? "").trim();
188
190
  if (text.length > 0) {
189
- items.push(text);
191
+ // `checked` is true only for a ticked box; `[ ]` (space) is false.
192
+ // A plain bullet has no checkbox, so it stays `null` below — it is
193
+ // neither ticked nor unticked and does not count as an unticked AC.
194
+ items.push({ text, checked: /^\s*-\s+\[[xX]\]/u.test(line) });
190
195
  }
191
196
  continue;
192
197
  }
@@ -196,7 +201,7 @@ export function extractChecklistItems(sectionBody) {
196
201
  if (bulletMatch) {
197
202
  const text = bulletMatch[1].trim();
198
203
  if (text.length > 0) {
199
- items.push(text);
204
+ items.push({ text, checked: null });
200
205
  }
201
206
  }
202
207
  }
@@ -204,12 +209,39 @@ export function extractChecklistItems(sectionBody) {
204
209
  return items;
205
210
  }
206
211
 
212
+ /**
213
+ * Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
214
+ * checklist items and top-level plain `- ` bullets. Empty checkbox placeholders
215
+ * are skipped. Returns the trimmed item text for each matching line; the
216
+ * checkbox state is not preserved (use `extractUncheckedChecklistItems` for
217
+ * that). Thin wrapper over `parseChecklistItems` so the text-only contract
218
+ * stays byte-identical to its pre-#1621 shape.
219
+ */
220
+ export function extractChecklistItems(sectionBody) {
221
+ return parseChecklistItems(sectionBody).map((item) => item.text);
222
+ }
223
+
224
+ /**
225
+ * Extract the text of UNCHECKED checkbox items (`- [ ]`) from a section body.
226
+ * A ticked box (`- [x]`/`- [X]`) and a plain bullet (no checkbox) are both
227
+ * excluded — only an actual unticked checkbox is an "unticked AC item"
228
+ * (#1621, ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
229
+ * Thin wrapper over `parseChecklistItems` so the unticked read never drifts
230
+ * from `extractChecklistItems` on what counts as a checklist item.
231
+ */
232
+ export function extractUncheckedChecklistItems(sectionBody) {
233
+ return parseChecklistItems(sectionBody)
234
+ .filter((item) => item.checked === false)
235
+ .map((item) => item.text);
236
+ }
237
+
207
238
  /**
208
239
  * Detect a linked refinement doc path from the issue body.
209
240
  * Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
210
241
  * `## Refinement` / `## Plan` / `## Refinement doc` sections.
211
242
  */
212
243
  export function detectLinkedRefinementDoc(body) {
244
+
213
245
  if (typeof body !== "string" || body.length === 0) {
214
246
  return { found: false, path: null, reason: "empty-body" };
215
247
  }
@@ -246,6 +278,7 @@ export function detectLinkedRefinementDoc(body) {
246
278
  * hasACs: boolean,
247
279
  * source: string,
248
280
  * acItems: string[],
281
+ * uncheckedAcItems: string[],
249
282
  * dodItems: string[],
250
283
  * sections: string[],
251
284
  * linkedDoc: { found: boolean, path: string|null, reason: string },
@@ -259,6 +292,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
259
292
  hasACs: false,
260
293
  source: REFINEMENT_SOURCE.MISSING,
261
294
  acItems: [],
295
+ uncheckedAcItems: [],
262
296
  dodItems: [],
263
297
  sections: [],
264
298
  linkedDoc: { found: false, path: null, reason: "empty-body" },
@@ -274,6 +308,11 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
274
308
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
275
309
 
276
310
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
311
+ // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
312
+ // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
313
+ // must refuse on (#1621). Only actual unticked checkboxes count; a ticked
314
+ // box and a plain bullet (no checkbox) are both excluded.
315
+ const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
277
316
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
278
317
 
279
318
  const linkedDoc = detectLinkedRefinementDoc(body);
@@ -283,6 +322,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
283
322
  hasACs: true,
284
323
  source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
285
324
  acItems,
325
+ uncheckedAcItems,
286
326
  dodItems,
287
327
  sections: sectionNames,
288
328
  linkedDoc,
@@ -296,6 +336,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
296
336
  hasACs: true,
297
337
  source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
298
338
  acItems,
339
+ uncheckedAcItems,
299
340
  dodItems,
300
341
  sections: sectionNames,
301
342
  linkedDoc,
@@ -309,6 +350,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
309
350
  hasACs: true,
310
351
  source: REFINEMENT_SOURCE.LINKED_DOC,
311
352
  acItems: [],
353
+ uncheckedAcItems: [],
312
354
  dodItems: [],
313
355
  sections: sectionNames,
314
356
  linkedDoc,
@@ -321,6 +363,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
321
363
  hasACs: false,
322
364
  source: REFINEMENT_SOURCE.MISSING,
323
365
  acItems: [],
366
+ uncheckedAcItems: [],
324
367
  dodItems: [],
325
368
  sections: sectionNames,
326
369
  linkedDoc,
@@ -444,6 +487,59 @@ function sectionHasBody(section) {
444
487
  * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
445
488
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
446
489
  */
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Grill sub-loop body predicates (GRILL-SUBLOOP-*, #1628)
493
+ // ---------------------------------------------------------------------------
494
+ // The loop-grill skill writes its raw Q&A transcript and synthesis to an
495
+ // ephemeral tmp artifact and keeps only the canonical synthesized sections
496
+ // (Acceptance criteria / Definition of done / Non-goals) plus the sanctioned
497
+ // `<!-- loop-grill: ... -->` marker in the durable issue/PR body. The body
498
+ // MUST NOT embed the raw grill transcript/synthesis/Q&A headings
499
+ // (GRILL-SUBLOOP-NO-EMBED-SYNTHESIS). These pure predicates are the only
500
+ // mechanically-enforceable part of that contract; the judgment-bound clauses
501
+ // ("resolve every gap the grill decided", "stale contradicting prose") stay
502
+ // agent-level.
503
+
504
+ export const GRILL_MARKER_PATTERN = /<!--\s*loop-grill:\s*.*?-->/iu;
505
+
506
+ /** Case-insensitive **section heading names** that embed grill material. */
507
+ export const GRILL_EMBED_HEADING_PATTERNS = Object.freeze([
508
+ /^grill\s+findings$/iu,
509
+ /^grill\s+transcript$/iu,
510
+ /^grill\s+synthesis$/iu,
511
+ /^grill\s+q&a$/iu,
512
+ /^grill\s+qa$/iu,
513
+ ]);
514
+
515
+ /**
516
+ * Detect the sanctioned `<!-- loop-grill: ... -->` marker. Pure predicate.
517
+ * @param {string} [body]
518
+ * @returns {boolean} true when the marker is present.
519
+ */
520
+ export function detectGrillMarker(body = "") {
521
+ return typeof body === "string" && GRILL_MARKER_PATTERN.test(body);
522
+ }
523
+
524
+ /**
525
+ * Detect a grill transcript/synthesis/Q&A embed heading in the body. Pure
526
+ * predicate; returns the first offending heading name at any markdown level
527
+ * (# through ######) or null.
528
+ * @param {string} [body]
529
+ * @returns {string|null} the offending heading name, or null when none.
530
+ */
531
+ export function detectGrillEmbedHeading(body = "") {
532
+ if (typeof body !== "string" || body.length === 0) return null;
533
+ for (const section of parseMarkdownSections(body)) {
534
+ for (const pattern of GRILL_EMBED_HEADING_PATTERNS) {
535
+ if (pattern.test(String(section.name))) {
536
+ return String(section.name);
537
+ }
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+
447
543
  export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
448
544
  if (issueLess && Number.isInteger(expectedIssue)) {
449
545
  // Fail closed at the library boundary too (not just the CLI): the two modes
@@ -537,10 +633,50 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
537
633
  const reason =
538
634
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
539
635
  "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
540
- "(e.g. run `/loop-grill <issue> --auto`, or the refiner) — before it enters the pickup queue.";
636
+ "(e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself), or the refiner) — before it enters the pickup queue.";
541
637
  return { action: auto ? "divert" : "block", reason, missing };
542
638
  }
543
639
 
640
+ /**
641
+ * Apply the pickup-column refinement gate to one issue: fetch the issue body,
642
+ * run `decideEnqueueRefinementGate`, and throw the canonical `GH_API_ERROR` /
643
+ * `MISSING_REFINEMENT_ARTIFACT` on failure. This is the single
644
+ * gate-application orchestration shared by `queue add` (enqueue-time) and
645
+ * `queue move` (move-time) — never a second copy. It returns the gate decision
646
+ * so add-only (divert/park) and move-only (refined-flag) handling stays with
647
+ * each caller.
648
+ *
649
+ * @param {{ issueNumber: number, repo: string, env: object, runChild: Function, auto?: boolean }} input
650
+ * @returns {Promise<{ action: "enqueue" } | { action: "divert"|"block", reason: string, missing: string[] }>}
651
+ */
652
+ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false }) {
653
+ const bodyResult = await runChild(
654
+ "gh",
655
+ ["issue", "view", String(issueNumber), "--repo", repo, "--json", "body"],
656
+ env,
657
+ );
658
+ if (bodyResult.code !== 0) {
659
+ const detail = bodyResult.stderr?.trim() || `exit code ${bodyResult.code}`;
660
+ throw Object.assign(new Error(`gh issue view failed: ${detail}`), { code: "GH_API_ERROR" });
661
+ }
662
+ let bodyPayload;
663
+ try {
664
+ bodyPayload = JSON.parse(bodyResult.stdout);
665
+ } catch {
666
+ throw new Error("Invalid JSON input");
667
+ }
668
+ const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
669
+ const artifact = detectIssueRefinementArtifact({ body, issueNumber });
670
+ const decision = decideEnqueueRefinementGate({ artifact, targetIsPickup: true, auto });
671
+ if (decision.action === "block") {
672
+ throw Object.assign(new Error(decision.reason), {
673
+ code: "MISSING_REFINEMENT_ARTIFACT",
674
+ missing: decision.missing,
675
+ });
676
+ }
677
+ return decision;
678
+ }
679
+
544
680
  /**
545
681
  * Map a draft-gate refinement check to the result surface consumed by
546
682
  * `evaluatePrGateCoordination`. The mapping keeps the contract
@@ -24,6 +24,7 @@
24
24
  * No imports so this file vendors into the `.claude/hooks/` bundle unchanged
25
25
  * (vendored modules may only import `node:` builtins or relative paths).
26
26
  */
27
+ import path from "node:path";
27
28
 
28
29
  /**
29
30
  * Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
@@ -56,3 +57,41 @@ export function buildMainCheckoutFastForwardCommand(mainCheckout) {
56
57
  // wrong branch. No state change, no git switch.
57
58
  return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
58
59
  }
60
+
61
+ /**
62
+ * Worktree-cleanup timeout (ms) for the post-merge `git worktree remove` half.
63
+ */
64
+ export const WORKTREE_CLEANUP_TIMEOUT_MS = 60_000;
65
+
66
+ /**
67
+ * Build the best-effort post-merge worktree-removal command string (#1627).
68
+ *
69
+ * The dev-loop mandates removing the branch's worktree after merge, but neither
70
+ * the merge procedure nor the post-merge hooks performed it. This builds the
71
+ * shell command that runs the shared `cleanup-worktree.mjs` script FROM the main
72
+ * checkout (the hook's cwd can be inside the worktree being removed, which makes
73
+ * `git worktree remove` fail), and stays non-fatal: the script itself is fail-soft
74
+ * (refuses any path outside tmp/worktrees/dev-loops/, exits 0 on git errors), and
75
+ * the surrounding guard makes a consumer checkout without the script a silent no-op.
76
+ * `prNumber` is shell-escaped as a double-quoted argument; `mainCheckout` and the
77
+ * script path are POSIX single-quoted. Returns an empty string when no PR number
78
+ * (or no meaningful target) is available, so callers can skip cleanly.
79
+ *
80
+ * @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
81
+ * @param {string | number | undefined} prNumber - Merged PR number (drives `--pr`).
82
+ * @returns {string} the cleanup command, or "" when `prNumber` is absent.
83
+ */
84
+ export function buildWorktreeCleanupCommand(mainCheckout, prNumber) {
85
+ const pr = String(prNumber ?? "").trim();
86
+ // Validate the PR number is a positive integer BEFORE embedding it into the
87
+ // shell string; a caller passing a non-numeric string (could carry command
88
+ // substitution) is refused by returning "" — defense-in-depth in a public helper.
89
+ if (!/^[0-9]+$/u.test(pr)) {
90
+ return "";
91
+ }
92
+ const quotedMain = shellQuotePath(mainCheckout);
93
+ const script = shellQuotePath(path.join(mainCheckout, "scripts", "loop", "cleanup-worktree.mjs"));
94
+ // Guard the script's existence (consumer no-op) and keep the whole thing
95
+ // non-fatal with `|| true` — removal must never break a merge-completion flow.
96
+ return `if [ -f ${script} ]; then node ${script} --repo-root ${quotedMain} --pr "${pr}"; fi || true`;
97
+ }