@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.
- package/package.json +6 -1
- package/src/claude/hook-decisions.mjs +172 -5
- package/src/cli/primitives.mjs +21 -0
- package/src/config/config.mjs +83 -2
- package/src/config/extension-defaults.yaml +5 -0
- package/src/github/comment-id-guard.mjs +70 -0
- package/src/github/copilot-helpers.mjs +31 -0
- package/src/github/issue-ops.mjs +6 -0
- package/src/loop/agent-stall.mjs +194 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-ci-status.mjs +18 -7
- package/src/loop/default-branch-guard.mjs +1 -1
- package/src/loop/gate-fanin.mjs +244 -0
- package/src/loop/handoff-envelope.mjs +28 -1
- package/src/loop/issue-refinement-artifact.mjs +154 -18
- package/src/loop/main-checkout-ff.mjs +39 -0
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +12 -0
- package/src/loop/retrospective-checkpoint.mjs +77 -13
- package/src/loop/review-dispatch-plan.mjs +595 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/move-queue-item.mjs +37 -1
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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,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
|
|
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
|
+
}
|