@dev-loops/core 1.0.2-slim.0 → 1.0.3

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
+ }
@@ -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) {
@@ -0,0 +1,412 @@
1
+ /**
2
+ * execution-record.mjs — compact per-execution-unit telemetry record, the
3
+ * dev-loop execution-cap effort's final telemetry slice.
4
+ *
5
+ * Earlier execution-cap slices bounded the dev-loop's execution units (child
6
+ * launch, reviewer unit, role budget) and enforced single watcher ownership.
7
+ * None of them measure what a unit actually cost. This module adds one
8
+ * compact, honesty-gated telemetry RECORD per execution unit (coordinator
9
+ * phase, reviewer unit, judge round, fixer pass, watch cycle).
10
+ *
11
+ * Modeled on ./cache-telemetry-evidence.mjs: LOCAL/harness-observable
12
+ * metrics (prompt/context bytes, turns, tool calls, local tool time) are
13
+ * always measured — a genuine local zero is a real zero. PROVIDER-owned
14
+ * metrics (input/output/cache-read tokens) are honesty-gated: a harness
15
+ * that cannot observe a dimension must report it `{ available:false,
16
+ * reason }`, NEVER a coerced/estimated zero, and — the core fidelity guard
17
+ * — must never be handed a numeric value for a dimension its profile marks
18
+ * unavailable (fail closed). Child wall time (childWallTimeMs) is a
19
+ * wall-clock measurement, not provider telemetry: any harness may report
20
+ * it, regardless of its provider-token profile — measured when a finite
21
+ * non-negative value is supplied, else `{ available:false, reason }`,
22
+ * never a coerced zero.
23
+ *
24
+ * Pure and offline: no GitHub, no clock, no file reads except the writer.
25
+ */
26
+ import { mkdir, writeFile } from "node:fs/promises";
27
+ import path from "node:path";
28
+
29
+ import { HARNESS_VALUES } from "./role-budget-bound.mjs";
30
+
31
+ export const EXECUTION_RECORD_SCHEMA_VERSION = 1;
32
+
33
+ /** The five execution-unit kinds this record covers (superset of role-budget-bound's ROLE_VALUES). */
34
+ export const EXECUTION_UNIT_ROLES = Object.freeze([
35
+ "coordinator_phase",
36
+ "reviewer_unit",
37
+ "judge_round",
38
+ "fixer_pass",
39
+ "watch_cycle",
40
+ ]);
41
+
42
+ /**
43
+ * Per-harness provider-token-telemetry capability. Conservative honest
44
+ * defaults: `claude` observes provider token usage; `pi` and `codex` do not
45
+ * — we have no ground truth that either exposes per-unit provider token
46
+ * usage, so a record for them must never claim a measured token value (the
47
+ * dev-loop execution-cap telemetry non-goal: never claim telemetry a harness
48
+ * does not expose). Kept separate from review-dispatch-plan's own harness
49
+ * capability map on purpose (different concern: cache reuse vs. per-unit cost).
50
+ */
51
+ export const TELEMETRY_HARNESS_PROFILES = Object.freeze({
52
+ claude: Object.freeze({ providerTokens: "available" }),
53
+ codex: Object.freeze({ providerTokens: "unavailable" }),
54
+ pi: Object.freeze({ providerTokens: "unavailable" }),
55
+ });
56
+
57
+ const profileKeys = Object.keys(TELEMETRY_HARNESS_PROFILES).slice().sort();
58
+ const harnessKeys = HARNESS_VALUES.slice().sort();
59
+ if (profileKeys.length !== harnessKeys.length || profileKeys.some((k, i) => k !== harnessKeys[i])) {
60
+ throw new Error("execution-record.mjs: TELEMETRY_HARNESS_PROFILES must cover exactly HARNESS_VALUES");
61
+ }
62
+
63
+ /** @param {unknown} value @returns {boolean} */
64
+ function isNonEmptyString(value) {
65
+ return typeof value === "string" && value.trim().length > 0;
66
+ }
67
+ /** @param {unknown} value @returns {boolean} */
68
+ function isHexHeadSha(value) {
69
+ return typeof value === "string" && /^[0-9a-f]{7,64}$/i.test(value.trim());
70
+ }
71
+ /** @param {unknown} value @returns {boolean} discrete non-negative counter. */
72
+ function isNonNegativeInteger(value) {
73
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
74
+ }
75
+ /** @param {unknown} value @returns {boolean} a genuine measurable non-negative duration/count. */
76
+ function isNonNegativeFiniteNumber(value) {
77
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
78
+ }
79
+ /**
80
+ * Own-property-only harness profile lookup. A plain `[]` read plus
81
+ * truthiness would let an inherited name (`toString`, `constructor`,
82
+ * `__proto__`) resolve to a non-nullish value from Object.prototype and
83
+ * bypass the "unknown harness" fail-closed check — this gates strictly on
84
+ * the three real harness keys.
85
+ * @param {unknown} harness @returns {object|undefined}
86
+ */
87
+ function getHarnessProfile(harness) {
88
+ return typeof harness === "string" && Object.hasOwn(TELEMETRY_HARNESS_PROFILES, harness)
89
+ ? TELEMETRY_HARNESS_PROFILES[harness]
90
+ : undefined;
91
+ }
92
+ /** @param {unknown} value @returns {boolean} rejects an empty string, a path separator, or a ".." traversal segment — a fail-closed guard for any value interpolated into a filesystem path. */
93
+ function isSafePathSegment(value) {
94
+ return typeof value === "string" && value.trim().length > 0 && !value.includes("/") && !value.includes("\\") && !value.includes("..");
95
+ }
96
+
97
+ /**
98
+ * Recursively freeze a plain object/array's own nested plain objects/arrays.
99
+ * Mirrors reviewer-unit-bound.mjs / role-budget-bound.mjs / watcher-exclusivity.mjs.
100
+ * @param {unknown} value @param {WeakSet<object>} [seen] @returns {unknown}
101
+ */
102
+ function deepFreeze(value, seen = new WeakSet()) {
103
+ if (value === null || typeof value !== "object" || seen.has(value)) return value;
104
+ seen.add(value);
105
+ for (const key of Object.keys(value)) deepFreeze(value[key], seen);
106
+ return Object.freeze(value);
107
+ }
108
+
109
+ /**
110
+ * Resolve one PROVIDER-token dimension's honesty-gated value. `null` means
111
+ * "not observed" and is recorded as `{available:false, reason}` — never
112
+ * coerced to 0. A non-null value is only accepted when the harness profile
113
+ * marks the dimension observable; otherwise this is the core fidelity guard
114
+ * and fails closed (a harness that cannot observe the metric must never
115
+ * report a number for it).
116
+ */
117
+ function resolveProviderTokenDimension({ harness, dim, value, reason, role, observable }) {
118
+ if (value === null || value === undefined) {
119
+ const defaultReason = observable
120
+ ? `harness ${harness} exposes token telemetry but no value was reported for this ${role}`
121
+ : `harness ${harness} does not expose provider token usage telemetry`;
122
+ return Object.freeze({ available: false, value: null, reason: isNonEmptyString(reason) ? reason.trim() : defaultReason });
123
+ }
124
+ if (!observable) {
125
+ throw new Error(`harness ${harness} does not expose provider token usage telemetry — providerTokens.${dim} must never report a value, got ${JSON.stringify(value)}`);
126
+ }
127
+ if (!isNonNegativeFiniteNumber(value)) {
128
+ throw new TypeError(`providerTokens.${dim} must be a finite non-negative number or null, got ${JSON.stringify(value)}`);
129
+ }
130
+ return Object.freeze({ available: true, value, reason: null });
131
+ }
132
+
133
+ /**
134
+ * Resolve childWallTimeMs. Any harness may report it (a wall-clock
135
+ * measurement, not provider telemetry) — measured-or-unavailable-with-reason,
136
+ * never gated on the provider-token profile.
137
+ */
138
+ function resolveChildWallTime({ value, reason, role }) {
139
+ if (value === null || value === undefined) {
140
+ const defaultReason = `child wall time not reported by harness for this ${role}`;
141
+ return Object.freeze({ available: false, value: null, reason: isNonEmptyString(reason) ? reason.trim() : defaultReason });
142
+ }
143
+ if (!isNonNegativeFiniteNumber(value)) {
144
+ throw new TypeError(`childWallTimeMs must be a finite non-negative number or null, got ${JSON.stringify(value)}`);
145
+ }
146
+ return Object.freeze({ available: true, value, reason: null });
147
+ }
148
+
149
+ /**
150
+ * Build one compact per-execution-unit telemetry record.
151
+ *
152
+ * @param {object} input
153
+ * @param {"pi"|"claude"|"codex"} input.harness
154
+ * @param {"coordinator_phase"|"reviewer_unit"|"judge_round"|"fixer_pass"|"watch_cycle"} input.role
155
+ * @param {{headSha:string, phase?, round?, unit?, unitId?}} input.identity
156
+ * @param {number} input.promptBytes @param {number} input.contextBytes
157
+ * @param {number} input.turns @param {number} input.toolCalls
158
+ * @param {{input:number|null, output:number|null, cacheRead:number|null, reasons?:object}} input.providerTokens
159
+ * @param {number} input.localToolTimeMs @param {number|null} input.childWallTimeMs
160
+ * @param {string|null} input.waitOwner @param {string} input.outcome
161
+ * @returns {object} frozen record.
162
+ */
163
+ export function buildExecutionUnitRecord({
164
+ harness,
165
+ role,
166
+ identity,
167
+ promptBytes,
168
+ contextBytes,
169
+ turns,
170
+ toolCalls,
171
+ providerTokens = {},
172
+ localToolTimeMs,
173
+ childWallTimeMs = null,
174
+ childWallTimeMsReason,
175
+ waitOwner = null,
176
+ outcome,
177
+ } = {}) {
178
+ if (!HARNESS_VALUES.includes(harness)) {
179
+ throw new TypeError(`buildExecutionUnitRecord requires harness to be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(harness)}`);
180
+ }
181
+ if (!EXECUTION_UNIT_ROLES.includes(role)) {
182
+ throw new TypeError(`buildExecutionUnitRecord requires role to be one of ${EXECUTION_UNIT_ROLES.join(", ")}, got ${JSON.stringify(role)}`);
183
+ }
184
+ if (!identity || typeof identity !== "object" || !isHexHeadSha(identity.headSha)) {
185
+ throw new TypeError("buildExecutionUnitRecord requires identity.headSha to be a hex string (7-64 chars)");
186
+ }
187
+ const headSha = identity.headSha.trim().toLowerCase();
188
+ const derivedUnitId = identity.unitId ?? identity.unit ?? identity.round ?? identity.phase;
189
+ if (derivedUnitId === null || derivedUnitId === undefined || (typeof derivedUnitId === "string" && derivedUnitId.trim().length === 0)) {
190
+ throw new TypeError("buildExecutionUnitRecord requires identity to carry at least one of unitId/unit/round/phase");
191
+ }
192
+ const unitId = String(derivedUnitId).trim();
193
+ if (!isNonEmptyString(outcome)) {
194
+ throw new TypeError("buildExecutionUnitRecord requires a non-empty outcome");
195
+ }
196
+ if (waitOwner !== null && !isNonEmptyString(waitOwner)) {
197
+ throw new TypeError("buildExecutionUnitRecord requires waitOwner to be a non-empty string or null");
198
+ }
199
+
200
+ const localMetric = (value, label) => {
201
+ if (!isNonNegativeInteger(value)) {
202
+ throw new TypeError(`${label} must be a finite non-negative integer, got ${JSON.stringify(value)}`);
203
+ }
204
+ return value;
205
+ };
206
+ if (!isNonNegativeFiniteNumber(localToolTimeMs)) {
207
+ throw new TypeError(`localToolTimeMs must be a finite non-negative number, got ${JSON.stringify(localToolTimeMs)}`);
208
+ }
209
+ const metrics = Object.freeze({
210
+ promptBytes: localMetric(promptBytes, "promptBytes"),
211
+ contextBytes: localMetric(contextBytes, "contextBytes"),
212
+ turns: localMetric(turns, "turns"),
213
+ toolCalls: localMetric(toolCalls, "toolCalls"),
214
+ localToolTimeMs,
215
+ });
216
+
217
+ const observable = getHarnessProfile(harness)?.providerTokens === "available";
218
+ const reasons = providerTokens.reasons ?? {};
219
+ const providerTokensNorm = Object.freeze({
220
+ input: resolveProviderTokenDimension({ harness, dim: "input", value: providerTokens.input ?? null, reason: reasons.input, role, observable }),
221
+ output: resolveProviderTokenDimension({ harness, dim: "output", value: providerTokens.output ?? null, reason: reasons.output, role, observable }),
222
+ cacheRead: resolveProviderTokenDimension({ harness, dim: "cacheRead", value: providerTokens.cacheRead ?? null, reason: reasons.cacheRead, role, observable }),
223
+ });
224
+ const childWallTimeNorm = resolveChildWallTime({ value: childWallTimeMs, reason: childWallTimeMsReason, role });
225
+
226
+ const availability = Object.freeze({
227
+ providerTokensInput: Object.freeze({ available: providerTokensNorm.input.available, reason: providerTokensNorm.input.reason }),
228
+ providerTokensOutput: Object.freeze({ available: providerTokensNorm.output.available, reason: providerTokensNorm.output.reason }),
229
+ providerTokensCacheRead: Object.freeze({ available: providerTokensNorm.cacheRead.available, reason: providerTokensNorm.cacheRead.reason }),
230
+ childWallTimeMs: Object.freeze({ available: childWallTimeNorm.available, reason: childWallTimeNorm.reason }),
231
+ });
232
+ const hasUnavailableProviderMetric = !providerTokensNorm.input.available
233
+ || !providerTokensNorm.output.available
234
+ || !providerTokensNorm.cacheRead.available;
235
+
236
+ return deepFreeze({
237
+ schemaVersion: EXECUTION_RECORD_SCHEMA_VERSION,
238
+ role,
239
+ harness,
240
+ headSha,
241
+ unitId,
242
+ identity: {
243
+ headSha,
244
+ unitId,
245
+ phase: identity.phase ?? null,
246
+ round: identity.round ?? null,
247
+ unit: identity.unit ?? null,
248
+ },
249
+ metrics,
250
+ providerTokens: providerTokensNorm,
251
+ childWallTimeMs: childWallTimeNorm,
252
+ waitOwner,
253
+ outcome,
254
+ availability,
255
+ hasUnavailableProviderMetric,
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Fail-closed validation (never throws). Re-derives provider-token
261
+ * availability from `record.harness`'s profile — NOT from a stored
262
+ * `available:true` flag — so a hand-edited record that flips availability
263
+ * while the harness profile says unavailable fails closed, mirroring
264
+ * validateCacheTelemetryEvidence's capability re-derivation.
265
+ *
266
+ * @param {object} input @param {object} input.record
267
+ * @returns {{ok:boolean, failures:Array<{check:string, reason:string}>}}
268
+ */
269
+ export function validateExecutionUnitRecord({ record } = {}) {
270
+ const failures = [];
271
+ if (!record || typeof record !== "object") {
272
+ return { ok: false, failures: [{ check: "record", reason: "missing execution-unit record" }] };
273
+ }
274
+ if (record.schemaVersion !== EXECUTION_RECORD_SCHEMA_VERSION) {
275
+ failures.push({ check: "schema_version", reason: `schemaVersion must be ${EXECUTION_RECORD_SCHEMA_VERSION}, got ${JSON.stringify(record.schemaVersion)}` });
276
+ }
277
+ if (!EXECUTION_UNIT_ROLES.includes(record.role)) {
278
+ failures.push({ check: "role", reason: `role must be one of ${EXECUTION_UNIT_ROLES.join(", ")}, got ${JSON.stringify(record.role)}` });
279
+ }
280
+ const profile = getHarnessProfile(record.harness);
281
+ if (!profile) {
282
+ failures.push({ check: "harness", reason: `harness must be one of ${HARNESS_VALUES.join(", ")}, got ${JSON.stringify(record.harness)}` });
283
+ }
284
+ if (!isHexHeadSha(record.headSha)) {
285
+ failures.push({ check: "head_sha", reason: `headSha must be a hex string (7-64 chars), got ${JSON.stringify(record.headSha)}` });
286
+ }
287
+ if (!isNonEmptyString(record.unitId)) {
288
+ failures.push({ check: "unit_id", reason: `unitId must be a non-empty string, got ${JSON.stringify(record.unitId)}` });
289
+ }
290
+ if (!record.identity || typeof record.identity !== "object" || record.identity.headSha !== record.headSha || record.identity.unitId !== record.unitId) {
291
+ failures.push({ check: "identity", reason: "identity.headSha/identity.unitId must equal the record's own headSha/unitId" });
292
+ }
293
+ const metrics = record.metrics ?? {};
294
+ for (const dim of ["promptBytes", "contextBytes", "turns", "toolCalls"]) {
295
+ if (!isNonNegativeInteger(metrics[dim])) {
296
+ failures.push({ check: "local_metric", reason: `metrics.${dim} must be a finite non-negative integer, got ${JSON.stringify(metrics[dim])}` });
297
+ }
298
+ }
299
+ if (!isNonNegativeFiniteNumber(metrics.localToolTimeMs)) {
300
+ failures.push({ check: "local_metric", reason: `metrics.localToolTimeMs must be a finite non-negative number, got ${JSON.stringify(metrics.localToolTimeMs)}` });
301
+ }
302
+
303
+ const checkHonestyGatedDim = (label, dimRecord, { gateOnProfile } = {}) => {
304
+ if (!dimRecord || typeof dimRecord !== "object" || typeof dimRecord.available !== "boolean") {
305
+ failures.push({ check: `${label}_shape`, reason: `${label} must be an object with a boolean available field` });
306
+ return;
307
+ }
308
+ if (dimRecord.available) {
309
+ if (gateOnProfile && profile && profile.providerTokens !== "available") {
310
+ failures.push({ check: `${label}_honesty`, reason: `${label}.available=true but harness ${record.harness} does not expose provider token telemetry — an unavailable harness must never claim a measured value` });
311
+ }
312
+ if (!isNonNegativeFiniteNumber(dimRecord.value)) {
313
+ failures.push({ check: `${label}_value`, reason: `${label}.value must be a finite non-negative number when available=true, got ${JSON.stringify(dimRecord.value)}` });
314
+ }
315
+ } else {
316
+ if (dimRecord.value !== null) {
317
+ failures.push({ check: `${label}_value`, reason: `${label}.value must be null when available=false, got ${JSON.stringify(dimRecord.value)}` });
318
+ }
319
+ if (!isNonEmptyString(dimRecord.reason)) {
320
+ failures.push({ check: `${label}_reason`, reason: `${label}.reason must be a non-empty string when available=false` });
321
+ }
322
+ }
323
+ };
324
+ checkHonestyGatedDim("providerTokens.input", record.providerTokens?.input, { gateOnProfile: true });
325
+ checkHonestyGatedDim("providerTokens.output", record.providerTokens?.output, { gateOnProfile: true });
326
+ checkHonestyGatedDim("providerTokens.cacheRead", record.providerTokens?.cacheRead, { gateOnProfile: true });
327
+ checkHonestyGatedDim("childWallTimeMs", record.childWallTimeMs, {});
328
+
329
+ // Re-derive availability from the already-validated per-dimension records
330
+ // (never trust a stored availability object on its own) — deleting or
331
+ // forging this field must fail closed rather than silently pass.
332
+ const expectedAvailability = {
333
+ providerTokensInput: record.providerTokens?.input,
334
+ providerTokensOutput: record.providerTokens?.output,
335
+ providerTokensCacheRead: record.providerTokens?.cacheRead,
336
+ childWallTimeMs: record.childWallTimeMs,
337
+ };
338
+ for (const [key, dim] of Object.entries(expectedAvailability)) {
339
+ const avail = record.availability?.[key];
340
+ if (!avail || typeof avail !== "object" || typeof avail.available !== "boolean") {
341
+ failures.push({ check: `availability.${key}`, reason: `availability.${key} must be an object with a boolean available field` });
342
+ continue;
343
+ }
344
+ const expectedAvailable = dim && typeof dim === "object" ? dim.available : undefined;
345
+ const expectedReason = dim && typeof dim === "object" ? dim.reason : undefined;
346
+ if (avail.available !== expectedAvailable) {
347
+ failures.push({ check: `availability.${key}`, reason: `availability.${key}.available=${JSON.stringify(avail.available)} does not match the re-derived ${key} availability (expected ${JSON.stringify(expectedAvailable)})` });
348
+ } else if (avail.reason !== expectedReason) {
349
+ failures.push({ check: `availability.${key}`, reason: `availability.${key}.reason=${JSON.stringify(avail.reason)} does not match the re-derived ${key} reason (expected ${JSON.stringify(expectedReason)})` });
350
+ }
351
+ }
352
+
353
+ if (record.waitOwner !== null && !isNonEmptyString(record.waitOwner)) {
354
+ failures.push({ check: "wait_owner", reason: `waitOwner must be a non-empty string or null, got ${JSON.stringify(record.waitOwner)}` });
355
+ }
356
+ if (!isNonEmptyString(record.outcome)) {
357
+ failures.push({ check: "outcome", reason: `outcome must be a non-empty string, got ${JSON.stringify(record.outcome)}` });
358
+ }
359
+
360
+ const expectedHasUnavailable = ["input", "output", "cacheRead"].some((dim) => record.providerTokens?.[dim]?.available !== true);
361
+ if (record.hasUnavailableProviderMetric !== expectedHasUnavailable) {
362
+ failures.push({ check: "aggregate_consistency", reason: `hasUnavailableProviderMetric=${record.hasUnavailableProviderMetric} does not match the re-derived providerTokens availability (expected ${expectedHasUnavailable})` });
363
+ }
364
+
365
+ return { ok: failures.length === 0, failures };
366
+ }
367
+
368
+ /**
369
+ * Strict fail-closed enforcement surface (GATE-EXEC-EXECUTION-RECORD): throws
370
+ * when the record is missing or invalid, naming every failing check.
371
+ * @param {object} input @param {object} input.record
372
+ * @returns {true}
373
+ */
374
+ export function enforceExecutionUnitRecord({ record } = {}) {
375
+ const r = validateExecutionUnitRecord({ record });
376
+ if (!r.ok) {
377
+ throw new Error(
378
+ `GATE-EXEC-EXECUTION-RECORD: execution-unit record failed validation; refusing to proceed (${r.failures.map((f) => `${f.check}: ${f.reason}`).join("; ")})`,
379
+ );
380
+ }
381
+ return true;
382
+ }
383
+
384
+ /**
385
+ * Deterministic artifact path for one execution unit's record.
386
+ * @param {object} input @param {string} input.dir @param {string} input.role
387
+ * @param {string} input.headSha @param {string} input.unitId
388
+ * @returns {string}
389
+ */
390
+ export function executionRecordPath({ dir, role, headSha, unitId } = {}) {
391
+ if (typeof dir !== "string" || dir.length === 0) throw new Error("executionRecordPath requires a dir");
392
+ if (!EXECUTION_UNIT_ROLES.includes(role) || !isSafePathSegment(role)) {
393
+ throw new Error(`executionRecordPath requires role to be one of ${EXECUTION_UNIT_ROLES.join(", ")}`);
394
+ }
395
+ if (!isHexHeadSha(headSha) || !isSafePathSegment(headSha)) throw new Error("executionRecordPath requires a hex headSha");
396
+ if (!isSafePathSegment(unitId)) {
397
+ throw new Error("executionRecordPath requires a non-empty unitId without path separators or '..' segments");
398
+ }
399
+ return path.join(dir, `${role}-${String(unitId).trim()}-${headSha.trim().toLowerCase()}.execution-record.json`);
400
+ }
401
+
402
+ /**
403
+ * Persist the record to its deterministic path.
404
+ * @param {object} input @param {string} input.dir @param {object} input.record
405
+ * @returns {Promise<{path:string}>}
406
+ */
407
+ export async function writeExecutionUnitRecord({ dir, record } = {}) {
408
+ const target = executionRecordPath({ dir, role: record.role, headSha: record.headSha, unitId: record.unitId });
409
+ await mkdir(path.dirname(target), { recursive: true });
410
+ await writeFile(target, `${JSON.stringify(record, null, 2)}\n`, "utf8");
411
+ return { path: target };
412
+ }