@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 +1 -1
- package/src/config/config.mjs +30 -1
- package/src/loop/copilot-ci-status.mjs +18 -7
- package/src/loop/gate-fanin.mjs +244 -0
- package/src/loop/issue-refinement-artifact.mjs +60 -18
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
package/package.json
CHANGED
package/src/config/config.mjs
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -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
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* (`-
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
*
|
|
81
|
-
*
|
|
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
|
|
85
|
-
if (!
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
return
|
|
154
|
+
// Malformed/unrecognized durable state — fail closed.
|
|
155
|
+
return RETROSPECTIVE_CHECKPOINT_STATE.MISSING;
|
|
92
156
|
}
|
|
93
157
|
|
|
94
158
|
/**
|