@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.
- package/package.json +11 -1
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +97 -48
- package/src/config/config.mjs +439 -37
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/github/copilot-helpers.mjs +28 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +145 -28
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +277 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-fanin.mjs +45 -0
- package/src/loop/handoff-envelope.mjs +113 -6
- package/src/loop/issue-refinement-artifact.mjs +30 -11
- package/src/loop/merge-approval.mjs +283 -0
- package/src/loop/pr-gate-coordination.mjs +49 -0
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/loop/worktree-guard.mjs +80 -13
- package/src/security/secret-scan.mjs +13 -0
|
@@ -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 (
|
|
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 (
|
|
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
|
+
}
|