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

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": "1.0.0-rc.4",
3
+ "version": "1.0.0-rc.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -424,7 +424,7 @@ const WorkflowConfig = z.strictObject({
424
424
  // it here would also mean renaming a shipped artifact contract, not just a
425
425
  // config key. Out of scope for this config-shape RFC; revisit as its own
426
426
  // change against skills/docs/gate-review-comment-contract.md + the envelope schema.
427
- requireRetrospective: z.boolean().describe("Require a retrospective checkpoint before a loop completes."),
427
+ requireRetrospective: z.boolean().describe("Require a retrospective checkpoint for the previous qualifying async completion before the next dev-loop start/resume."),
428
428
  requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
429
429
  devModeDefault: z.boolean().describe("Default new loops to dev mode."),
430
430
  // No default here and absent from BUILT_IN_DEFAULTS — unset means "keep
@@ -1431,6 +1431,35 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1431
1431
  // is an existing, separate concern.
1432
1432
  const validation = FileConfigSchema.safeParse(data);
1433
1433
  if (!validation.success) {
1434
+ // Surface a visible WARNING (not just the structured error) so the
1435
+ // whole-layer drop is never silent (#1578): many consumers destructure
1436
+ // only `config` (or `config` + `warnings`) and never read `errors`, so a
1437
+ // schema-rejected layer would vanish without a trace. Naming the
1438
+ // offending keys here lets a stale raw-key config (e.g.
1439
+ // gates.<gate>.mandatoryAngles/excludeAngles) point at the canonical
1440
+ // angle-entry migration path.
1441
+ const offendingKeys = validation.error.issues
1442
+ .flatMap((i) => {
1443
+ if (i.code === "unrecognized_keys" && Array.isArray(i.keys) && i.keys.length) {
1444
+ const prefix = i.path.length ? `${i.path.join(".")}.` : "";
1445
+ return i.keys.map((k) => `${prefix}${k}`);
1446
+ }
1447
+ return i.path.length ? [i.path.join(".")] : [];
1448
+ });
1449
+ // Gate the raw-key migration hint: only append it when the offending
1450
+ // keys actually include the pre-redesign mandatoryAngles/excludeAngles
1451
+ // names, so an unrelated schema failure (e.g. a type error) does not get
1452
+ // misleading raw-key migration guidance. (#1578)
1453
+ const hasRawGateKey = offendingKeys.some((k) => /mandatoryAngles|excludeAngles/.test(k));
1454
+ const migrationHint = hasRawGateKey
1455
+ ? ` Migrate raw gates.<gate>.mandatoryAngles/excludeAngles to the canonical angle-entry shape ` +
1456
+ `(gates.<gate>.angles with { name, mandatory: true } / { name, enabled: false }).`
1457
+ : ` Fix or remove the offending key(s) to restore this config layer.`;
1458
+ warnings.push(
1459
+ `${path.basename(filePath)}: config layer rejected by schema — the whole layer was dropped, so this layer's overrides are not applied (previously merged layers remain in effect). ` +
1460
+ `Offending key(s): ${offendingKeys.length ? offendingKeys.join(", ") : "(unknown)"}.` +
1461
+ migrationHint
1462
+ );
1434
1463
  errors.push({
1435
1464
  path: filePath,
1436
1465
  message: `${path.basename(filePath)}: Schema validation failed: ${validation.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
@@ -144,25 +144,36 @@ export function normalizeStatusCheckRollupStatus(rollup) {
144
144
  /**
145
145
  * Summarize the GitHub check-runs API payload for one head SHA.
146
146
  *
147
+ * `allQueued` is the zero-allocation stall signal (#1631): true when at least one
148
+ * check-run is present AND every one is still in the `queued` status — i.e. no
149
+ * runner has been allocated to any job (no job picked up / in_progress / completed).
150
+ * The CI watcher uses it to bail early on a stuck GitHub Actions queue instead
151
+ * of burning the full watch budget.
152
+ *
147
153
  * @param {object} payload
148
- * @returns {{ status: "success"|"failure"|"pending"|"none", unsupportedCompleted: boolean, failureDetails?: Array<string> }}
154
+ * @returns {{ status: "success"|"failure"|"pending"|"none", unsupportedCompleted: boolean, allQueued: boolean, failureDetails?: Array<string> }}
149
155
  */
150
156
  export function summarizeHeadScopedCheckRunsSignal(payload) {
151
157
  const runs = Array.isArray(payload?.check_runs) ? payload.check_runs : [];
152
158
  if (runs.length === 0) {
153
- return { status: "none", unsupportedCompleted: false };
159
+ return { status: "none", unsupportedCompleted: false, allQueued: false };
154
160
  }
155
161
 
156
162
  let hasPending = false;
157
163
  let hasFailure = false;
158
164
  let hasSuccess = false;
159
165
  let hasUnsupportedCompleted = false;
166
+ let allQueued = true; // every run is status "queued" (zero runner allocation)
160
167
  const failureDetails = [];
161
168
 
162
169
  for (const run of runs) {
163
170
  const status = typeof run?.status === "string" ? run.status.toUpperCase() : "";
164
171
  const conclusion = typeof run?.conclusion === "string" ? run.conclusion.toUpperCase() : "";
165
172
 
173
+ if (status !== "QUEUED") {
174
+ allQueued = false;
175
+ }
176
+
166
177
  if (status !== "COMPLETED") {
167
178
  hasPending = true;
168
179
  continue;
@@ -183,11 +194,11 @@ export function summarizeHeadScopedCheckRunsSignal(payload) {
183
194
  hasUnsupportedCompleted = true;
184
195
  }
185
196
 
186
- if (hasFailure) return { status: "failure", unsupportedCompleted: hasUnsupportedCompleted, failureDetails };
187
- if (hasPending) return { status: "pending", unsupportedCompleted: hasUnsupportedCompleted, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
188
- if (hasUnsupportedCompleted) return { status: "none", unsupportedCompleted: true, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
189
- if (hasSuccess) return { status: "success", unsupportedCompleted: false, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
190
- return { status: "none", unsupportedCompleted: false, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
197
+ if (hasFailure) return { status: "failure", unsupportedCompleted: hasUnsupportedCompleted, allQueued, failureDetails };
198
+ if (hasPending) return { status: "pending", unsupportedCompleted: hasUnsupportedCompleted, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
199
+ if (hasUnsupportedCompleted) return { status: "none", unsupportedCompleted: true, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
200
+ if (hasSuccess) return { status: "success", unsupportedCompleted: false, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
201
+ return { status: "none", unsupportedCompleted: false, allQueued, failureDetails: failureDetails.length > 0 ? failureDetails : undefined };
191
202
  }
192
203
 
193
204
  /**
@@ -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
  }
@@ -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,6 +209,32 @@ 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
@@ -246,6 +277,7 @@ export function detectLinkedRefinementDoc(body) {
246
277
  * hasACs: boolean,
247
278
  * source: string,
248
279
  * acItems: string[],
280
+ * uncheckedAcItems: string[],
249
281
  * dodItems: string[],
250
282
  * sections: string[],
251
283
  * linkedDoc: { found: boolean, path: string|null, reason: string },
@@ -259,6 +291,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
259
291
  hasACs: false,
260
292
  source: REFINEMENT_SOURCE.MISSING,
261
293
  acItems: [],
294
+ uncheckedAcItems: [],
262
295
  dodItems: [],
263
296
  sections: [],
264
297
  linkedDoc: { found: false, path: null, reason: "empty-body" },
@@ -274,6 +307,11 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
274
307
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
275
308
 
276
309
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
310
+ // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
311
+ // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
312
+ // must refuse on (#1621). Only actual unticked checkboxes count; a ticked
313
+ // box and a plain bullet (no checkbox) are both excluded.
314
+ const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
277
315
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
278
316
 
279
317
  const linkedDoc = detectLinkedRefinementDoc(body);
@@ -283,6 +321,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
283
321
  hasACs: true,
284
322
  source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
285
323
  acItems,
324
+ uncheckedAcItems,
286
325
  dodItems,
287
326
  sections: sectionNames,
288
327
  linkedDoc,
@@ -296,6 +335,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
296
335
  hasACs: true,
297
336
  source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
298
337
  acItems,
338
+ uncheckedAcItems,
299
339
  dodItems,
300
340
  sections: sectionNames,
301
341
  linkedDoc,
@@ -309,6 +349,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
309
349
  hasACs: true,
310
350
  source: REFINEMENT_SOURCE.LINKED_DOC,
311
351
  acItems: [],
352
+ uncheckedAcItems: [],
312
353
  dodItems: [],
313
354
  sections: sectionNames,
314
355
  linkedDoc,
@@ -321,6 +362,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
321
362
  hasACs: false,
322
363
  source: REFINEMENT_SOURCE.MISSING,
323
364
  acItems: [],
365
+ uncheckedAcItems: [],
324
366
  dodItems: [],
325
367
  sections: sectionNames,
326
368
  linkedDoc,
@@ -537,7 +579,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
537
579
  const reason =
538
580
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
539
581
  "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.";
582
+ "(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
583
  return { action: auto ? "divert" : "block", reason, missing };
542
584
  }
543
585
 
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  evaluateRetrospectiveGate,
3
3
  normalizeRetrospectiveCheckpointState,
4
+ normalizeCheckpointCycleIdentity,
5
+ resolveCheckpointStateFromArtifact,
4
6
  } from "./retrospective-checkpoint.mjs";
5
7
  import {
6
8
  EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
@@ -32,6 +34,16 @@ import {
32
34
 
33
35
  export * from "./public-dev-loop-routing-contract.mjs";
34
36
 
37
+ // Re-exported so script-layer callers (e.g. resolve-dev-loop-startup.mjs and
38
+ // checkpoint-contract.mjs) can normalize a checkpoint cycle identity and
39
+ // resolve a durable checkpoint artifact's state through the public routing
40
+ // surface, without retrospective-checkpoint.mjs itself becoming a public
41
+ // package export (see skills/docs/retrospective-checkpoint-contract.md).
42
+ export {
43
+ normalizeCheckpointCycleIdentity,
44
+ resolveCheckpointStateFromArtifact,
45
+ };
46
+
35
47
  const COPILOT_ISSUE_ASSIGNEE = "copilot-swe-agent";
36
48
 
37
49
  const TARGET_KIND_SET = new Set(Object.values(DEV_LOOP_TARGET_KIND));
@@ -73,22 +73,86 @@ export function normalizeRetrospectiveCheckpointState(value) {
73
73
  }
74
74
 
75
75
  /**
76
- * Returns true if a routing result represents a qualifying GitHub-first async
77
- * dev-loop completion that requires a post-run behavioral retrospective before
78
- * the next start/resume.
76
+ * Normalizes a dev-loop cycle identity — the minimum facts that pin a
77
+ * checkpoint record to one specific qualifying completion: repo, PR number,
78
+ * and merge commit. Returns null when any field is missing or malformed, so a
79
+ * partial/garbled identity can never be mistaken for a valid one.
79
80
  *
80
- * A qualifying completion is one that:
81
- * - has a `selectedGate` in RETROSPECTIVE_QUALIFYING_GATES
82
- * - with `routeKind === "route"` (inspect/status-only results do not qualify)
81
+ * @param {unknown} identity
82
+ * @returns {{repo: string, prNumber: number, mergeCommit: string}|null}
83
83
  */
84
- export function isQualifyingAsyncCompletion(routingResult) {
85
- if (!routingResult || typeof routingResult !== "object") return false;
86
- const { routeKind, selectedGate } = routingResult;
87
- if (routeKind !== "route") {
88
- return false;
84
+ export function normalizeCheckpointCycleIdentity(identity) {
85
+ if (!identity || typeof identity !== "object") {
86
+ return null;
87
+ }
88
+ const repo = typeof identity.repo === "string" ? identity.repo.trim() : "";
89
+ const prNumber = Number.isInteger(identity.prNumber) && identity.prNumber > 0 ? identity.prNumber : null;
90
+ const mergeCommit = typeof identity.mergeCommit === "string" ? identity.mergeCommit.trim() : "";
91
+ if (repo.length === 0 || prNumber === null || mergeCommit.length === 0) {
92
+ return null;
93
+ }
94
+ return { repo, prNumber, mergeCommit };
95
+ }
96
+
97
+ /**
98
+ * Resolves the RETROSPECTIVE_CHECKPOINT_STATE for a durable checkpoint
99
+ * artifact, scoped to the recorded cycle's recency (issue: a one-time
100
+ * `complete`/`skipped` checkpoint must not satisfy every later qualifying
101
+ * cycle forever).
102
+ *
103
+ * A `complete` or `skipped` artifact is scoped by `hasNewerMergeSinceCheckpoint`:
104
+ * when true, something has merged since the checkpoint's recorded discharge
105
+ * point (or that point could not be verified at all), so the checkpoint
106
+ * cannot cover the newer cycle — it fails closed to MISSING. The caller
107
+ * derives `hasNewerMergeSinceCheckpoint` itself (this module stays
108
+ * pure/I/O-free) by checking local git ancestry between the checkpoint's
109
+ * recorded merge commit and the base branch, so this runs fresh on every
110
+ * evaluation rather than depending on anything having written a fresh
111
+ * `required` record for the new cycle.
112
+ *
113
+ * `required`/`none` are not scoped by this comparison: `required` already
114
+ * maps to MISSING regardless of recency (an outstanding requirement blocks
115
+ * the gate no matter which cycle triggered it), and `none` means no
116
+ * completion has ever been observed.
117
+ *
118
+ * @param {object|null|undefined} artifact - Parsed checkpoint JSON, or
119
+ * `undefined` when the durable artifact is genuinely ABSENT (no file). Any
120
+ * other non-plain-object value — including the JSON literal `null` (a file
121
+ * that IS present but contains malformed content) and a corrupt-but-valid
122
+ * scalar/array — is treated as present-but-malformed and fails closed to
123
+ * MISSING; only a genuinely absent artifact resolves to NONE.
124
+ * @param {object} [options]
125
+ * @param {boolean} [options.hasNewerMergeSinceCheckpoint] - True when the
126
+ * caller has determined (or could not rule out) that something has merged
127
+ * to the base branch since the checkpoint's recorded discharge point.
128
+ * Ignored for states other than `complete`/`skipped`. Defaults to `false`
129
+ * (trust the recorded state) so callers that never verify recency (e.g.
130
+ * `workflow.requireRetrospective` disabled) see unchanged behavior.
131
+ * @returns {"none"|"complete"|"skipped"|"missing"}
132
+ */
133
+ export function resolveCheckpointStateFromArtifact(artifact, { hasNewerMergeSinceCheckpoint = false } = {}) {
134
+ if (artifact === undefined) {
135
+ return RETROSPECTIVE_CHECKPOINT_STATE.NONE;
136
+ }
137
+ if (artifact === null || typeof artifact !== "object" || Array.isArray(artifact)) {
138
+ // Present but malformed — fail closed, do not treat as "nothing observed".
139
+ return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
140
+ }
141
+ const rawState = typeof artifact.state === "string" ? artifact.state.trim().toLowerCase() : null;
142
+ if (rawState === "required" || rawState === "missing") {
143
+ return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
144
+ }
145
+ if (rawState === "none") {
146
+ return RETROSPECTIVE_CHECKPOINT_STATE.NONE;
147
+ }
148
+ if (rawState === "skipped") {
149
+ return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.SKIPPED;
150
+ }
151
+ if (rawState === "complete") {
152
+ return hasNewerMergeSinceCheckpoint ? RETROSPECTIVE_CHECKPOINT_STATE.MISSING : RETROSPECTIVE_CHECKPOINT_STATE.COMPLETE;
89
153
  }
90
- if (typeof selectedGate !== "string") return false;
91
- return RETROSPECTIVE_QUALIFYING_GATES.includes(selectedGate);
154
+ // Malformed/unrecognized durable state — fail closed.
155
+ return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
92
156
  }
93
157
 
94
158
  /**