@dev-loops/core 1.0.2 → 1.0.4-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * child-launch-bound.mjs — dev-loop execution-cap fail-fast child-launch
3
+ * bound (epic decided approach): a bounded, deterministic primitive that
4
+ * attempts a child launch AT MOST ONCE, queries the harness-supported-model
5
+ * inventory AT MOST ONCE and ONLY after a failed launch, and always produces
6
+ * a durable blocker record on failure — never a retry, a silent model
7
+ * substitution, or a dispatch without the requested override.
8
+ *
9
+ * Pure and offline: launch/query behavior is fully caller-injected
10
+ * (attemptLaunch/querySupportedModels); this module never imports a runtime
11
+ * harness adapter, reads a file, or performs I/O of its own.
12
+ */
13
+
14
+ /**
15
+ * Dev-loop harnesses this bound recognizes; any other value fails closed.
16
+ * This primitive only carries the harness label through the request/blocker
17
+ * — it does not branch on it or depend on per-harness capability data (e.g.
18
+ * HARNESS_DEFAULT_CAPABILITIES), so it stays agnostic across pi, claude, and
19
+ * codex.
20
+ */
21
+ const HARNESS_VALUES = Object.freeze(["pi", "claude", "codex"]);
22
+
23
+ /** @param {unknown} value @returns {boolean} */
24
+ function isNonEmptyString(value) {
25
+ return typeof value === "string" && value.trim().length > 0;
26
+ }
27
+
28
+ /**
29
+ * Validate + normalize the child-launch request. Fails closed (TypeError) on
30
+ * any malformed/empty field, at the trust boundary of this function.
31
+ * @param {object} request
32
+ * @returns {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}}
33
+ */
34
+ function validateRequest(request) {
35
+ if (!request || typeof request !== "object") {
36
+ throw new TypeError("enforceChildLaunchBound requires a request object");
37
+ }
38
+ const { run, roleOrAngle, model, harness } = request;
39
+ if (!isNonEmptyString(run)) throw new TypeError("request.run must be a non-empty string");
40
+ if (!isNonEmptyString(roleOrAngle)) throw new TypeError("request.roleOrAngle must be a non-empty string");
41
+ if (!isNonEmptyString(model)) throw new TypeError("request.model must be a non-empty string");
42
+ if (!HARNESS_VALUES.includes(harness)) {
43
+ throw new TypeError(`request.harness must be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(harness)}`);
44
+ }
45
+ return { run: run.trim(), roleOrAngle: roleOrAngle.trim(), model: model.trim(), harness };
46
+ }
47
+
48
+ // Note: querySupportedModels may return { supported: [...] }, a bare
49
+ // array, or a Set — normalizing all three into one Set here keeps the
50
+ // caller-facing contract flexible without adding a second exported shape.
51
+ /** @param {{supported?: unknown}|unknown[]|Set<string>} result @returns {Set<string>} */
52
+ function toSupportedSet(result) {
53
+ const list = result && typeof result === "object" && !Array.isArray(result) && !(result instanceof Set)
54
+ ? result.supported
55
+ : result;
56
+ if (list instanceof Set) return list;
57
+ if (Array.isArray(list)) return new Set(list);
58
+ return new Set();
59
+ }
60
+
61
+ /**
62
+ * Enforce the bounded, deterministic child-launch fail-fast protocol.
63
+ *
64
+ * Scope: this bounds ONE invocation — one launch attempt plus at most one
65
+ * inventory query for that same call. It holds no state across calls, so
66
+ * cross-call terminality (never re-launching the same `(run, roleOrAngle,
67
+ * model)` after a prior call already returned a blocked verdict) is the
68
+ * caller's/coordinator's contract, not something a persisted token here
69
+ * enforces.
70
+ *
71
+ * Deadline contract: `deadlineMs` is a MEASUREMENT bound, not a per-call
72
+ * timeout. The function times the elapsed wall-clock span of the one launch
73
+ * attempt plus (on failure) the one inventory query, and reports
74
+ * `withinDeadline` fail-closed (`elapsedMs <= deadlineMs`) — it never cancels
75
+ * or races `attemptLaunch`/`querySupportedModels` against the clock. The
76
+ * <=60s guarantee holds only because each injected operation is a single
77
+ * bounded call, never a retry loop, inside this function; enforcing a hard
78
+ * per-operation timeout/cancellation on a slow `attemptLaunch` or
79
+ * `querySupportedModels` implementation is the caller's operation-budget
80
+ * responsibility and is intentionally out of scope for this pure primitive.
81
+ *
82
+ * @param {object} options
83
+ * @param {{run:string, roleOrAngle:string, model:string, harness:"pi"|"claude"|"codex"}} options.request
84
+ * @param {(request:object)=>({ok:true,launch:*}|{ok:false,reason:string})} options.attemptLaunch
85
+ * Called AT MOST ONCE.
86
+ * @param {(request:object)=>({supported:string[]}|string[]|Set<string>)} options.querySupportedModels
87
+ * Called AT MOST ONCE, and only after a failed launch.
88
+ * @param {()=>number} [options.now] - injectable clock, default Date.now.
89
+ * @param {number} [options.deadlineMs] - measurement bound in ms, default 60000.
90
+ * @returns {object} `{ ok: true, launch, events, elapsedMs, withinDeadline }`
91
+ * on success, or the durable blocker `{ ok: false, verdict: "blocked",
92
+ * reason, launchFailureDetail, request, modelSupported, elapsedMs,
93
+ * withinDeadline, events }` on failure. `launchFailureDetail` is the failed
94
+ * `attemptLaunch` result verbatim (its own reason plus any error/message it
95
+ * carried), preserved alongside the normalized `reason`.
96
+ */
97
+ export function enforceChildLaunchBound({ request, attemptLaunch, querySupportedModels, now = () => Date.now(), deadlineMs = 60000 } = {}) {
98
+ const normalizedRequest = validateRequest(request);
99
+ if (typeof attemptLaunch !== "function") {
100
+ throw new TypeError("enforceChildLaunchBound requires attemptLaunch to be a function");
101
+ }
102
+ if (typeof querySupportedModels !== "function") {
103
+ throw new TypeError("enforceChildLaunchBound requires querySupportedModels to be a function");
104
+ }
105
+
106
+ const events = [];
107
+ const start = now();
108
+
109
+ // EXACTLY one launch attempt, ever — no retry, no model substitution.
110
+ const launchResult = attemptLaunch(normalizedRequest);
111
+ events.push({ type: "launch_attempt" });
112
+
113
+ if (launchResult && launchResult.ok === true) {
114
+ // Success: never query the inventory, never attempt a second launch.
115
+ const elapsedMs = now() - start;
116
+ return { ok: true, launch: launchResult.launch, events, elapsedMs, withinDeadline: elapsedMs <= deadlineMs };
117
+ }
118
+
119
+ // Launch failed: query the harness's supported-model inventory EXACTLY
120
+ // once, only now (never before a launch attempt, never more than once).
121
+ const inventory = querySupportedModels(normalizedRequest);
122
+ events.push({ type: "inventory_query" });
123
+ const modelSupported = toSupportedSet(inventory).has(normalizedRequest.model);
124
+
125
+ const launchReason = launchResult && typeof launchResult.reason === "string" ? launchResult.reason : null;
126
+ const reason = launchReason === "unresolvable"
127
+ ? "child_model_unresolvable"
128
+ : !modelSupported
129
+ ? "child_model_unsupported"
130
+ : "child_launch_failed_model_supported";
131
+ // Verbatim passthrough of the failed launch result (its own reason plus any
132
+ // error/message/detail it carried) — never inspected or altered — so a
133
+ // caller can see the exact failure detail alongside the normalized reason.
134
+ const launchFailureDetail = launchResult && typeof launchResult === "object" ? launchResult : null;
135
+
136
+ const elapsedMs = now() - start;
137
+ // Measurement, not enforcement: a slow adapter still returns the blocker
138
+ // (fail-closed) rather than being cancelled or retried against the clock.
139
+ const withinDeadline = elapsedMs <= deadlineMs;
140
+
141
+ return {
142
+ ok: false,
143
+ verdict: "blocked",
144
+ reason,
145
+ launchFailureDetail,
146
+ request: normalizedRequest,
147
+ modelSupported,
148
+ elapsedMs,
149
+ withinDeadline,
150
+ events,
151
+ };
152
+ }
@@ -20,16 +20,33 @@ const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
20
20
  export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
21
21
 
22
22
  /**
23
- * The same workflow ALSO surfaces as a check run under its job id
24
- * (`gate-evidence-runner`) beside the commit status named above, and both are
25
- * the loop's own derived signal. Excluding only the status context left the
26
- * runner's conclusion gating the loop's own pre_approval step: once the
27
- * workflow gained job-level concurrency, a superseded run is cancelled as
23
+ * The same workflow ALSO surfaces as check runs under its two job ids
24
+ * (`gate-evidence-runner`, the compute-heavy detector, and
25
+ * `gate-evidence-reporter`, the always-settling job that owns the status
26
+ * above — see docs/decisions/0076) beside the commit status named above, and
27
+ * all are the loop's own derived signal. Excluding only the status context
28
+ * left either job's conclusion gating the loop's own pre_approval step: once
29
+ * the workflow gained job-level concurrency, a superseded run is cancelled as
28
30
  * normal operation, and a cancelled run is deliberately NOT treated as green
29
31
  * (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
30
32
  * the whole head read "none" and the loop waited on CI forever.
31
33
  */
32
- export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([LOOP_DERIVED_CI_CHECK_NAME, "gate-evidence-runner"]);
34
+ export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([
35
+ LOOP_DERIVED_CI_CHECK_NAME,
36
+ "gate-evidence-runner",
37
+ "gate-evidence-reporter",
38
+ ]);
39
+
40
+ /**
41
+ * The two Gate-evidence JOB check-run names — the detector and the reporter.
42
+ * These (and ONLY these, never the `gate-evidence` status name) are the runs
43
+ * whose superseded CANCELLED check-runs `classifyBenignGateEvidenceUnstable`
44
+ * treats as cosmetic UNSTABLE noise.
45
+ */
46
+ export const GATE_EVIDENCE_JOB_CHECK_NAMES = Object.freeze([
47
+ "gate-evidence-runner",
48
+ "gate-evidence-reporter",
49
+ ]);
33
50
 
34
51
  function checkEntryName(entry) {
35
52
  if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
@@ -141,6 +158,27 @@ export function normalizeStatusCheckRollupStatus(rollup) {
141
158
  return "none";
142
159
  }
143
160
 
161
+ /**
162
+ * Resolve the normalized status of ONE named context/check within a
163
+ * `statusCheckRollup` (or check-runs-shaped) payload — e.g. whether the
164
+ * required `gate-evidence` context itself (as opposed to the loop's own
165
+ * exclusion of it, see `deriveLoopCiStatusFromRollup`) is success, failure,
166
+ * pending, or absent. Reuses the same name matching
167
+ * (`partitionEntriesByCheckName`) and state normalization
168
+ * (`normalizeStatusCheckRollupStatus`) the rollup helpers on this module
169
+ * already use, so a caller that needs one context's own state (e.g.
170
+ * merge-pr.mjs naming the real cause of a block on `gate-evidence`) does not
171
+ * re-derive name matching or status normalization.
172
+ *
173
+ * @param {Array<object>} rollup
174
+ * @param {string} contextName
175
+ * @returns {"success"|"failure"|"pending"|"none"}
176
+ */
177
+ export function resolveNamedContextState(rollup, contextName) {
178
+ const { matched } = partitionEntriesByCheckName(rollup, contextName);
179
+ return normalizeStatusCheckRollupStatus(matched);
180
+ }
181
+
144
182
  /**
145
183
  * Summarize the GitHub check-runs API payload for one head SHA.
146
184
  *
@@ -317,6 +355,78 @@ export function normalizeHeadScopedCiContract({
317
355
  return buildCiContract(overallStatus);
318
356
  }
319
357
 
358
+ /**
359
+ * Classify a `mergeStateStatus === "UNSTABLE"` as BENIGN when the required
360
+ * `gate-evidence` commit status is itself `success` and the ONLY non-success
361
+ * rollup entries are superseded Gate-evidence job check-runs (the
362
+ * `gate-evidence-runner` detector OR the `gate-evidence-reporter`, conclusion
363
+ * `CANCELLED`).
364
+ *
365
+ * Both jobs cancel superseded runs: the detector via `cancel-in-progress`, the
366
+ * reporter via its non-cancelling group cancelling a still-queued run superseded
367
+ * by a newer one. Each leaves a `cancelled` check-run on the head, so
368
+ * `mergeStateStatus` reads `UNSTABLE` on nearly every PR even when the required
369
+ * `gate-evidence` status on the head is green. The cancellation is correct and
370
+ * stays (docs/decisions/0076); this classifier only lets a reader distinguish
371
+ * that cosmetic noise from a real non-success.
372
+ *
373
+ * Fail-closed: only an actual `UNSTABLE` with a `success` `gate-evidence` status
374
+ * and no other non-success entry is benign. A failed (not cancelled) Gate-evidence
375
+ * job, a non-success `gate-evidence` status, or any other failing/pending check
376
+ * makes it non-benign.
377
+ *
378
+ * ponytail: `gh pr view --json statusCheckRollup` returns a single bounded page
379
+ * (~100 contexts); a very chatty PR could exceed it and hide a real failure,
380
+ * failing this open. Acceptable because this is a display-only surface — the
381
+ * merge path never consults it. Do NOT wire this classifier into a merge
382
+ * decision without adding pagination.
383
+ *
384
+ * @param {Array<object>} rollup A `gh pr view --json statusCheckRollup` payload.
385
+ * @param {string|null} mergeStateStatus
386
+ * @returns {{ benign: boolean, reason: string }}
387
+ */
388
+ export function classifyBenignGateEvidenceUnstable(rollup, mergeStateStatus) {
389
+ const state = typeof mergeStateStatus === "string" ? mergeStateStatus.toUpperCase() : "";
390
+ if (state !== "UNSTABLE") {
391
+ return { benign: false, reason: "mergeStateStatus is not UNSTABLE" };
392
+ }
393
+ if (!Array.isArray(rollup)) {
394
+ return { benign: false, reason: "status rollup unavailable" };
395
+ }
396
+ // The required gate-evidence signal is a commit STATUS (a StatusContext,
397
+ // `.context`), never a check-run (`.name`). Anchor the success guard on the
398
+ // StatusContext alone so a same-named success check-run can never stand in for
399
+ // an absent required status (partitionEntriesByCheckName matches `.name` OR
400
+ // `.context`, so it would otherwise accept either).
401
+ const gateEvidenceStatusEntries = rollup.filter(
402
+ (entry) => entry?.context === LOOP_DERIVED_CI_CHECK_NAME && typeof entry?.state === "string",
403
+ );
404
+ if (normalizeStatusCheckRollupStatus(gateEvidenceStatusEntries) !== "success") {
405
+ return { benign: false, reason: "gate-evidence status is not success" };
406
+ }
407
+ const offenders = [];
408
+ for (const entry of rollup) {
409
+ if (normalizeStatusCheckRollupStatus([entry]) === "success") continue;
410
+ const name = checkEntryName(entry);
411
+ const conclusion = typeof entry?.conclusion === "string" ? entry.conclusion.toUpperCase() : "";
412
+ // Only a cancelled Gate-evidence JOB check-run (runner or reporter) is the
413
+ // superseded-run artifact this classifier ignores — an explicit two-job
414
+ // allowlist, not LOOP_DERIVED_CI_CHECK_NAMES (which also carries the
415
+ // `gate-evidence` status name), so a cancelled entry named `gate-evidence`
416
+ // can never be waved through as benign.
417
+ if (GATE_EVIDENCE_JOB_CHECK_NAMES.includes(name) && conclusion === "CANCELLED") continue;
418
+ const status = typeof entry?.status === "string" ? entry.status.toUpperCase() : "";
419
+ offenders.push(`${name || "unknown"}=${conclusion || status || "?"}`);
420
+ }
421
+ if (offenders.length > 0) {
422
+ return { benign: false, reason: `non-benign non-success checks present: ${offenders.join(", ")}` };
423
+ }
424
+ return {
425
+ benign: true,
426
+ reason: "no non-success entry other than superseded Gate-evidence job cancellations; gate-evidence status is success",
427
+ };
428
+ }
429
+
320
430
  /**
321
431
  * Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
322
432
  * `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the
@@ -197,6 +197,7 @@ export function buildSnapshotFromPrFacts({
197
197
  lastCopilotRoundMaxSignal = null,
198
198
  failureDetails = [],
199
199
  excludedFailureDetails,
200
+ copilotBodyFeedbackUnresolved = false,
200
201
  }) {
201
202
  const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
202
203
  const prMerged = prState === "MERGED";
@@ -205,6 +206,9 @@ export function buildSnapshotFromPrFacts({
205
206
  // that never threads an explicit ciStatus (e.g. gate-coordination detection)
206
207
  // still never treats it as a blocking CI failure.
207
208
  const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
209
+ const currentHeadSha = typeof prData?.headRefOid === "string" && prData.headRefOid.trim().length > 0
210
+ ? prData.headRefOid.trim()
211
+ : null;
208
212
 
209
213
  return normalizeSnapshot({
210
214
  prExists: true,
@@ -212,6 +216,7 @@ export function buildSnapshotFromPrFacts({
212
216
  prDraft: Boolean(prData?.isDraft),
213
217
  prMerged,
214
218
  prClosed,
219
+ currentHeadSha,
215
220
  copilotReviewRequestStatus,
216
221
  copilotReviewPresent,
217
222
  copilotReviewOnCurrentHead,
@@ -222,6 +227,7 @@ export function buildSnapshotFromPrFacts({
222
227
  ciStatus: ciStatus ?? rollupDerivation.status,
223
228
  failureDetails,
224
229
  excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
230
+ copilotBodyFeedbackUnresolved,
225
231
  });
226
232
  }
227
233
 
@@ -269,6 +275,9 @@ export function normalizeSnapshot(raw) {
269
275
  prDraft: Boolean(raw.prDraft),
270
276
  prMerged: Boolean(raw.prMerged),
271
277
  prClosed: Boolean(raw.prClosed),
278
+ currentHeadSha: typeof raw.currentHeadSha === "string" && raw.currentHeadSha.trim().length > 0
279
+ ? raw.currentHeadSha.trim()
280
+ : null,
272
281
  copilotReviewRequestStatus: VALID_REVIEW_REQUEST_STATUSES.has(raw.copilotReviewRequestStatus)
273
282
  ? raw.copilotReviewRequestStatus
274
283
  : "none",
@@ -288,6 +297,7 @@ export function normalizeSnapshot(raw) {
288
297
  agentFixStatus: raw.agentFixStatus === "applied" ? "applied" : null,
289
298
  failureDetails: Array.isArray(raw.failureDetails) ? raw.failureDetails : [],
290
299
  excludedFailureDetails: Array.isArray(raw.excludedFailureDetails) ? raw.excludedFailureDetails : [],
300
+ copilotBodyFeedbackUnresolved: Boolean(raw.copilotBodyFeedbackUnresolved),
291
301
  };
292
302
  }
293
303
 
@@ -397,7 +407,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
397
407
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
398
408
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
399
409
  const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
400
- const cleanThreads = s.unresolvedThreadCount === 0;
410
+ const cleanThreads = s.unresolvedThreadCount === 0 && !s.copilotBodyFeedbackUnresolved;
401
411
  if (cleanThreads && ciClean) {
402
412
  state = STATE.ROUND_CAP_CLEAN_FALLBACK;
403
413
  } else if (!reviewInFlight) {
@@ -406,11 +416,16 @@ export function interpretLoopState(snapshot, refinementConfig) {
406
416
  // Not clean WITH an in-flight request: leave state undecided for the routing below.
407
417
  }
408
418
 
419
+ // Unresolved feedback includes both inline review threads and a Copilot
420
+ // review-body finding on the current head (a body-only "Changes recommended"
421
+ // review with zero inline threads must not read as clean).
422
+ const unresolvedFeedback = s.unresolvedThreadCount > 0 || s.copilotBodyFeedbackUnresolved;
423
+
409
424
  if (state === undefined) {
410
- if (s.unresolvedThreadCount > 0 && s.agentFixStatus === "applied") {
425
+ if (unresolvedFeedback && s.agentFixStatus === "applied") {
411
426
  // Agent has fixed the code; threads still need reply/resolve on GitHub
412
427
  state = STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE;
413
- } else if (s.unresolvedThreadCount > 0) {
428
+ } else if (unresolvedFeedback) {
414
429
  // Unresolved feedback exists — do not wait; enter fix/reply-resolve handling
415
430
  state = STATE.UNRESOLVED_FEEDBACK_PRESENT;
416
431
  } else if (s.copilotReviewRequestStatus === "requested" || s.copilotReviewRequestStatus === "already-requested") {
@@ -459,7 +474,8 @@ export function interpretLoopState(snapshot, refinementConfig) {
459
474
  const sameHeadCleanConverged = state === STATE.READY_TO_REREQUEST_REVIEW
460
475
  && s.copilotReviewOnCurrentHead
461
476
  && s.unresolvedThreadCount === 0
462
- && s.actionableThreadCount === 0;
477
+ && s.actionableThreadCount === 0
478
+ && !s.copilotBodyFeedbackUnresolved;
463
479
 
464
480
  let nextAction = NEXT_ACTIONS[state];
465
481
  if (sameHeadCleanConverged) {