@kylecheng3146/agent-ops 0.9.0 → 0.10.2
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/README.md +3 -2
- package/dist/packages/cli/src/args.js +58 -14
- package/dist/packages/cli/src/bin.js +10 -3
- package/dist/packages/cli/src/cli.js +30 -6
- package/dist/packages/cli/src/commands/advance.js +2 -1
- package/dist/packages/cli/src/commands/escapes.js +109 -0
- package/dist/packages/cli/src/commands/parallel.js +1 -1
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +63 -11
- package/dist/packages/cli/src/commands/run.js +10 -3
- package/dist/packages/cli/src/commands/task.js +41 -3
- package/dist/packages/cli/src/commands/update.js +16 -4
- package/dist/packages/cli/src/commands/verify.js +4 -1
- package/dist/packages/cli/src/context.js +22 -4
- package/dist/packages/cli/src/debug-intake.js +1 -1
- package/dist/packages/cli/src/office-entry.js +8 -4
- package/dist/packages/cli/src/owned-run-step.js +4 -4
- package/dist/packages/cli/src/parallel-deps.js +0 -2
- package/dist/packages/cli/src/run-deps.js +12 -9
- package/dist/packages/cli/src/run-entry.js +76 -17
- package/dist/packages/cli/src/run-setup-entry.js +2 -2
- package/dist/runtime/src/escape/link.js +317 -0
- package/dist/runtime/src/escape/origin.js +20 -0
- package/dist/runtime/src/escape/rules.js +252 -0
- package/dist/runtime/src/fs/temporary.js +10 -0
- package/dist/runtime/src/install/harness.js +7 -4
- package/dist/runtime/src/logging/local-log.js +44 -1
- package/dist/runtime/src/office/scene.js +1 -1
- package/dist/runtime/src/office/server.js +43 -16
- package/dist/runtime/src/office/snapshot.js +1 -1
- package/dist/runtime/src/parallel/finish.js +139 -65
- package/dist/runtime/src/parallel/manage.js +51 -1
- package/dist/runtime/src/parallel/receipt.js +3 -1
- package/dist/runtime/src/parallel/service.js +5 -1
- package/dist/runtime/src/review/execute.js +20 -12
- package/dist/runtime/src/review/packet.js +20 -7
- package/dist/runtime/src/review/probe.js +3 -2
- package/dist/runtime/src/review/runner.js +26 -3
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/run/controls.js +99 -1
- package/dist/runtime/src/run/hosts/codex.js +28 -1
- package/dist/runtime/src/run/hosts/protocol.js +5 -2
- package/dist/runtime/src/run/integration.js +35 -13
- package/dist/runtime/src/run/policy.js +65 -10
- package/dist/runtime/src/run/scheduler.js +10 -5
- package/dist/runtime/src/run/service.js +68 -21
- package/dist/runtime/src/run/supervisor.js +11 -2
- package/dist/runtime/src/run/transport.js +1 -0
- package/dist/runtime/src/schema/validate.js +6 -1
- package/dist/runtime/src/security/permissions.js +85 -0
- package/dist/runtime/src/surface/discovery.js +79 -2
- package/dist/runtime/src/surface/schema.js +43 -2
- package/dist/runtime/src/task/completion.js +20 -0
- package/dist/runtime/src/task/contract.js +3 -1
- package/dist/runtime/src/task/debug-state.js +9 -3
- package/dist/runtime/src/task/debug-user-event.js +82 -17
- package/dist/runtime/src/task/debug.js +59 -16
- package/dist/runtime/src/task/service.js +58 -6
- package/dist/runtime/src/verify/acceptance-replay.js +24 -11
- package/docs/en/guides/configuration.md +24 -5
- package/docs/en/guides/task-debug.md +26 -2
- package/docs/en/spec/acceptance-and-evidence.md +104 -2
- package/docs/en/spec/harness-adapters.md +26 -1
- package/docs/en/spec/loop-engineering.md +34 -6
- package/docs/en/spec/review.md +6 -0
- package/docs/zh-TW/guides/configuration.md +20 -4
- package/docs/zh-TW/guides/task-debug.md +19 -2
- package/docs/zh-TW/spec/acceptance-and-evidence.md +84 -4
- package/docs/zh-TW/spec/harness-adapters.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +9 -3
- package/docs/zh-TW/spec/review.md +5 -0
- package/package.json +1 -1
- package/schemas/task.schema.json +8 -1
|
@@ -8,7 +8,7 @@ import { recordNativeRunAuthorization } from "../../../runtime/src/run/authoriza
|
|
|
8
8
|
import { observeRunFailure } from "../../../runtime/src/run/verification.js";
|
|
9
9
|
import { criteriaProgress, recordRunPhase } from "../../../runtime/src/run/phase.js";
|
|
10
10
|
import { compareFailureObservations } from "../../../runtime/src/run/convergence.js";
|
|
11
|
-
import { RunControlService } from "../../../runtime/src/run/controls.js";
|
|
11
|
+
import { RunControlService, nativeResumeTime } from "../../../runtime/src/run/controls.js";
|
|
12
12
|
import { beginPolicyTransition, markPolicyTransitionStage, completePolicyTransition, discoverPendingPolicyTransition } from "../../../runtime/src/run/policy-transition.js";
|
|
13
13
|
import { readReviewReportArtifact } from "../../../runtime/src/review/attestation.js";
|
|
14
14
|
import { execFile, spawn } from "node:child_process";
|
|
@@ -17,6 +17,7 @@ import { join } from "node:path";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { setTimeout as delay } from "node:timers/promises";
|
|
19
19
|
import { AgentOpsError, resolveContainedPath } from "../../../runtime/src/fs/paths.js";
|
|
20
|
+
import { ensureRunPolicy } from "./context.js";
|
|
20
21
|
import { bindRunPolicy, readRunPolicy, runRuntimeHash, validateRunPolicyChange } from "../../../runtime/src/run/policy.js";
|
|
21
22
|
import { sha256 } from "../../../runtime/src/fs/hash.js";
|
|
22
23
|
import { calculateConfigHash, canonicalJson } from "../../../runtime/src/config/hash.js";
|
|
@@ -27,7 +28,7 @@ import { RunSupervisor } from "../../../runtime/src/run/supervisor.js";
|
|
|
27
28
|
import { NativeRunTransport, nativeProcessIdentity } from "../../../runtime/src/run/transport.js";
|
|
28
29
|
import { workerPlan } from "../../../runtime/src/run/planning.js";
|
|
29
30
|
import { RunScheduler } from "../../../runtime/src/run/scheduler.js";
|
|
30
|
-
import { activeWallTimeMs } from "../../../runtime/src/run/scheduler.js";
|
|
31
|
+
import { activeWallTimeMs, remainingRunTimeMs } from "../../../runtime/src/run/scheduler.js";
|
|
31
32
|
import { readBootIdentity, readGuiLoginIdentity, LaunchdController } from "../../../runtime/src/run/macOS.js";
|
|
32
33
|
import { readPrivateFile, writePrivateFile, withPrivateFileLock } from "../../../runtime/src/security/permissions.js";
|
|
33
34
|
import { TaskService } from "../../../runtime/src/task/service.js";
|
|
@@ -42,6 +43,31 @@ const exec = promisify(execFile);
|
|
|
42
43
|
const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
44
|
const active = (worker) => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status);
|
|
44
45
|
const cli = fileURLToPath(new URL("./bin.js", import.meta.url));
|
|
46
|
+
/** Release the sole native slot only after confirmed coordinator death. */
|
|
47
|
+
export async function prepareSerialSchedule(repository, supervisor, runId) {
|
|
48
|
+
const current = await repository.read(runId);
|
|
49
|
+
if (current === null || current.jobs !== 1 || current.rootTaskId === null || current.status !== "active" || current.disableRestart || current.awaitingResume)
|
|
50
|
+
return null;
|
|
51
|
+
const children = current.tasks.filter(task => task.taskId !== current.rootTaskId);
|
|
52
|
+
const coordinator = current.workers.find(worker => worker.workerId === current.coordinatorId);
|
|
53
|
+
if (children.length === 0 || coordinator === undefined)
|
|
54
|
+
return null;
|
|
55
|
+
if (children.some(task => !["delivered", "complete", "blocked"].includes(task.status))) {
|
|
56
|
+
if (active(coordinator)) {
|
|
57
|
+
await supervisor.stopWorker(runId, coordinator.workerId, coordinator.generation, "handoff");
|
|
58
|
+
await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_COORDINATOR_YIELDED", workerId: coordinator.workerId,
|
|
59
|
+
taskId: coordinator.taskId, detail: "Coordinator process death confirmed before assigning the sole slot to children." });
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return coordinator.status === "stopped" && !current.workers.some(worker => worker.workerId !== coordinator.workerId && active(worker)) ? coordinator : null;
|
|
64
|
+
}
|
|
65
|
+
/** Retain the structured child failure instead of hiding it behind recovery text. */
|
|
66
|
+
export function runStepError(result, fallback) {
|
|
67
|
+
const details = Array.isArray(result.errors) ? result.errors.filter(plain)
|
|
68
|
+
.map(error => typeof error.message === "string" ? error.message : "").filter(Boolean).join("; ") : "";
|
|
69
|
+
return new AgentOpsError(typeof result.code === "string" ? result.code : "RUN_INTEGRATION_RECOVERY_REQUIRED", `${fallback}${details.length === 0 ? "" : ": " + details.slice(0, 4096)}`);
|
|
70
|
+
}
|
|
45
71
|
/** Recheck after confirmed death and after verification, before publishing the frozen SHA. */
|
|
46
72
|
export async function assertRunDeliverySnapshot(root, taskId, head, contract, tasks) {
|
|
47
73
|
const runner = gitRunner(root);
|
|
@@ -104,6 +130,15 @@ export async function superviseNativeRun(root, runId) {
|
|
|
104
130
|
await blockStartup("RUN_RESTART_STORM");
|
|
105
131
|
return;
|
|
106
132
|
}
|
|
133
|
+
let manualResumeAt;
|
|
134
|
+
await repository.mutate(runId, current => {
|
|
135
|
+
manualResumeAt = nativeResumeTime(current);
|
|
136
|
+
if (manualResumeAt === undefined)
|
|
137
|
+
return current;
|
|
138
|
+
// Consume the latest control durably; bounded event history is not a one-shot fence.
|
|
139
|
+
return { ...current, controls: current.controls.map((control, index) => index === current.controls.length - 1
|
|
140
|
+
? { ...control, appliedAt: new Date().toISOString() } : control) };
|
|
141
|
+
});
|
|
107
142
|
await repository.appendEvent(runId, { type: "status", code: "RUN_SUPERVISOR_START", workerId: null, taskId: null, detail: null });
|
|
108
143
|
let version;
|
|
109
144
|
try {
|
|
@@ -171,7 +206,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
171
206
|
await writePrivateFile(path, JSON.stringify(content, null, 2), worker.worktree);
|
|
172
207
|
return "Read .agent-ops/tasks/run-instructions.json and carry its immutable original goal through the evidence loop. Submit a plan if rootTaskId is null; otherwise implement and repair the assigned task. A child requests handoff after local green; the coordinator waits for all child deliveries, then requests finalization. Never substitute native goal completion for agent-ops evidence.";
|
|
173
208
|
}
|
|
174
|
-
async function start(worker, diagnostic) {
|
|
209
|
+
async function start(worker, diagnostic, resumeAt) {
|
|
175
210
|
if (await discoverPendingPolicyTransition(repository, runId) !== null)
|
|
176
211
|
throw new AgentOpsError("RUN_POLICY_TRANSITION_PENDING", "Finish policy synchronization before acquiring a new writer generation.");
|
|
177
212
|
if (worker.worktree === null)
|
|
@@ -195,7 +230,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
195
230
|
throw new AgentOpsError("RUN_WORKTREE_OWNERSHIP", "The worker checkout is not registered to this run.");
|
|
196
231
|
await writeWorktreeRecord({ ...record, workerGeneration: worker.generation });
|
|
197
232
|
const goal = await instructions(worker, diagnostic);
|
|
198
|
-
const running = await supervisor.startWorker(runId, worker.workerId, worker.generation, goal);
|
|
233
|
+
const running = await supervisor.startWorker(runId, worker.workerId, worker.generation, goal, resumeAt);
|
|
199
234
|
await recordRunPhase(repository, runId, worker.workerId, (await repository.read(runId)).rootTaskId === null ? "planning" : "implementing");
|
|
200
235
|
try {
|
|
201
236
|
await transport.activateRegistered(runId, running, goal);
|
|
@@ -206,14 +241,22 @@ export async function superviseNativeRun(root, runId) {
|
|
|
206
241
|
}
|
|
207
242
|
}
|
|
208
243
|
async function schedule() {
|
|
244
|
+
const coordinator = await prepareSerialSchedule(repository, supervisor, runId);
|
|
245
|
+
if (coordinator !== null) {
|
|
246
|
+
await start(coordinator);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
209
249
|
const current = (await repository.read(runId));
|
|
210
250
|
for (const node of await scheduler.next(runId)) {
|
|
211
251
|
if (node.planDigest === undefined || node.taskId === current.rootTaskId)
|
|
212
252
|
continue;
|
|
213
253
|
const existing = current.workers.find(worker => worker.taskId === node.taskId);
|
|
214
254
|
if (existing !== undefined && existing.worktree !== null &&
|
|
215
|
-
(await deps.tasks(existing.worktree).list()).some(r => r.task.id === node.taskId))
|
|
255
|
+
(await deps.tasks(existing.worktree).list()).some(r => r.task.id === node.taskId)) {
|
|
256
|
+
if (existing.status === "stopped")
|
|
257
|
+
await start(existing);
|
|
216
258
|
continue;
|
|
259
|
+
}
|
|
217
260
|
const planPath = join(directory, runId, "plans", node.taskId + ".json");
|
|
218
261
|
const source = await readPrivateFile(planPath, commonDir);
|
|
219
262
|
if (source === null || sha256(source) !== node.planDigest)
|
|
@@ -291,12 +334,14 @@ export async function superviseNativeRun(root, runId) {
|
|
|
291
334
|
if (absent)
|
|
292
335
|
return (await recoverRunIntegrationAfterCleanup(commonDir, runId, deps.git)).receiptPath;
|
|
293
336
|
const result = await advance(saved);
|
|
294
|
-
if (result.status !== "ok"
|
|
295
|
-
throw
|
|
337
|
+
if (result.status !== "ok")
|
|
338
|
+
throw runStepError(result, "Recover the sealed transaction before allowing any writer to resume");
|
|
339
|
+
if (!plain(result.data) || typeof result.data.receipt !== "string")
|
|
340
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Recovery did not return the sealed receipt.");
|
|
296
341
|
return result.data.receipt;
|
|
297
342
|
}
|
|
298
343
|
async function step(cwd, args, saved) {
|
|
299
|
-
const remaining =
|
|
344
|
+
const remaining = remainingRunTimeMs(saved);
|
|
300
345
|
if (remaining <= 0)
|
|
301
346
|
throw new AgentOpsError("RUN_BUDGET_EXHAUSTED", "No active time remains for final proof.");
|
|
302
347
|
return await new Promise((resolve, reject) => {
|
|
@@ -327,7 +372,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
327
372
|
catch { }
|
|
328
373
|
}, 3000);
|
|
329
374
|
};
|
|
330
|
-
const timeout = setTimeout(terminate, remaining);
|
|
375
|
+
const timeout = Number.isFinite(remaining) ? setTimeout(terminate, remaining) : undefined;
|
|
331
376
|
const registration = (async () => {
|
|
332
377
|
const identity = child.pid === undefined ? null : await nativeProcessIdentity(child.pid);
|
|
333
378
|
if (identity === null || closed) {
|
|
@@ -637,11 +682,20 @@ export async function superviseNativeRun(root, runId) {
|
|
|
637
682
|
await synchronizePolicy();
|
|
638
683
|
state = (await repository.read(runId));
|
|
639
684
|
for (const worker of state.workers.filter(w => active(w) || w.status === "stopped")) {
|
|
685
|
+
if (state.jobs === 1 && state.rootTaskId !== null) {
|
|
686
|
+
const pending = state.tasks.filter(task => task.taskId !== state.rootTaskId && !["delivered", "complete", "blocked"].includes(task.status));
|
|
687
|
+
const node = pending.find(task => task.taskId === worker.taskId);
|
|
688
|
+
if (pending.length > 0 && (worker.workerId === state.coordinatorId || node === undefined ||
|
|
689
|
+
node.dependencies.some(id => !state.tasks.some(task => task.taskId === id && ["delivered", "complete"].includes(task.status)))))
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
640
692
|
if (worker.workerId !== state.coordinatorId && (worker.worktree === null ||
|
|
641
693
|
!(await deps.tasks(worker.worktree).list()).some(r => r.task.id === worker.taskId)))
|
|
642
694
|
continue;
|
|
643
695
|
try {
|
|
644
|
-
await start(worker);
|
|
696
|
+
await start(worker, undefined, manualResumeAt);
|
|
697
|
+
if (state.jobs === 1)
|
|
698
|
+
break;
|
|
645
699
|
}
|
|
646
700
|
catch (cause) {
|
|
647
701
|
await repository.appendEvent(runId, { type: "diagnostic", code: cause instanceof AgentOpsError ? cause.code : "RUN_RECOVERY_FAILED",
|
|
@@ -677,8 +731,9 @@ export async function superviseNativeRun(root, runId) {
|
|
|
677
731
|
await stopAll(state.status === "budget-limited" ? "budget" : "stop");
|
|
678
732
|
return;
|
|
679
733
|
}
|
|
734
|
+
state = await ensureRunPolicy(repository, state);
|
|
680
735
|
const elapsed = activeWallTimeMs(state.budget.activeIntervals, Date.now());
|
|
681
|
-
if (elapsed >= state.budget.limitMs) {
|
|
736
|
+
if (state.budget.limitMs !== null && elapsed >= state.budget.limitMs) {
|
|
682
737
|
await repository.mutate(runId, current => ({ ...current, status: "budget-limited", disableRestart: true }));
|
|
683
738
|
await launchd.disableRestart(runDescriptor(state), "active run time budget exhausted");
|
|
684
739
|
await stopAll("budget");
|
|
@@ -688,7 +743,12 @@ export async function superviseNativeRun(root, runId) {
|
|
|
688
743
|
accumulatedMs: activeWallTimeMs(current.budget.activeIntervals, Date.now()), lastObservedAt: new Date().toISOString() } }));
|
|
689
744
|
await schedule();
|
|
690
745
|
state = (await repository.read(runId));
|
|
691
|
-
for (const
|
|
746
|
+
for (const candidateWorker of state.workers.filter(w => ["running", "idle"].includes(w.status))) {
|
|
747
|
+
const freshState = (await repository.read(runId));
|
|
748
|
+
const currentWorker = freshState.workers.find(w => w.workerId === candidateWorker.workerId);
|
|
749
|
+
if (currentWorker === undefined || !["running", "idle"].includes(currentWorker.status))
|
|
750
|
+
continue;
|
|
751
|
+
const worker = currentWorker;
|
|
692
752
|
await supervisor.heartbeat(runId, worker.workerId, worker.generation);
|
|
693
753
|
const observation = await supervisor.observeWorker(runId, worker.workerId, worker.generation);
|
|
694
754
|
if (!observation.processAlive) {
|
|
@@ -888,7 +948,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
888
948
|
await recordRunPhase(repository, runId, worker.workerId, "verifying");
|
|
889
949
|
const result = await advance(current);
|
|
890
950
|
if (result.status !== "ok")
|
|
891
|
-
throw
|
|
951
|
+
throw runStepError(result, "Final proof failed; inspect saved verifier and fresh review reports before repair");
|
|
892
952
|
const data = result.data;
|
|
893
953
|
if (!plain(data) || typeof data.receipt !== "string")
|
|
894
954
|
throw new AgentOpsError("RUN_RECEIPT_REQUIRED", "Finalization requires the receipt returned by the existing finish gate.");
|
|
@@ -904,9 +964,8 @@ export async function superviseNativeRun(root, runId) {
|
|
|
904
964
|
const stopped = latest.workers.find(w => w.workerId === worker.workerId);
|
|
905
965
|
if (request.action === "finalize" && latest.integration !== null && (latest.integration.status !== "prepared" ||
|
|
906
966
|
(await deps.git(root, ["rev-parse", latest.targetBranch + "^{commit}"])).stdout.trim() === latest.integration.candidate)) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
response = { requestId: request.requestId, code: "RUN_COMPLETE", receipt: recovered };
|
|
967
|
+
// Preserve the failed step; a manual resume reconciles the sealed transaction after remediation.
|
|
968
|
+
throw cause;
|
|
910
969
|
}
|
|
911
970
|
else if (request.action === "policy" && await discoverPendingPolicyTransition(repository, runId) !== null) {
|
|
912
971
|
await repository.appendEvent(runId, { type: "diagnostic", code: "RUN_POLICY_RECOVERY_REQUIRED", workerId: worker.workerId, taskId: worker.taskId,
|
|
@@ -936,7 +995,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
936
995
|
}
|
|
937
996
|
catch (cause) {
|
|
938
997
|
await repository.appendEvent(runId, { type: "diagnostic", code: cause instanceof AgentOpsError ? cause.code : "RUN_SUPERVISOR_FAILED",
|
|
939
|
-
workerId: null, taskId: null, detail: "Supervisor stopped; worktree and proof artifacts are preserved." });
|
|
998
|
+
workerId: null, taskId: null, detail: cause instanceof AgentOpsError ? cause.message : "Supervisor stopped; worktree and proof artifacts are preserved." });
|
|
940
999
|
try {
|
|
941
1000
|
await stopAll("crash");
|
|
942
1001
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FileRunRepository } from "../../../runtime/src/run/service.js";
|
|
2
2
|
import { readRunPolicy, runRuntimeHash } from "../../../runtime/src/run/policy.js";
|
|
3
|
-
import {
|
|
3
|
+
import { remainingRunTimeMs } from "../../../runtime/src/run/scheduler.js";
|
|
4
4
|
import { runVerificationCommand, NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
|
|
5
5
|
import { redactSecrets } from "../../../runtime/src/security/redact.js";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -21,7 +21,7 @@ const policy = await readRunPolicy(state, await runRuntimeHash());
|
|
|
21
21
|
const step = /^(?:0|[1-9][0-9]*)$/u.test(index) ? policy?.config.worktree?.setup?.[Number(index)] : undefined;
|
|
22
22
|
if (step === undefined)
|
|
23
23
|
throw new Error("Setup must select an authorized fixed policy command.");
|
|
24
|
-
const remaining =
|
|
24
|
+
const remaining = remainingRunTimeMs(state);
|
|
25
25
|
if (remaining <= 0)
|
|
26
26
|
throw new Error("Run budget is exhausted.");
|
|
27
27
|
const result = await runVerificationCommand({ id: "run-setup", ...step, cwd: ".", required: true,
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { NOTES_REF } from "../parallel/service.js";
|
|
7
|
+
import { readPrivateFile } from "../security/permissions.js";
|
|
8
|
+
import { globMatcher, isCovers } from "../surface/schema.js";
|
|
9
|
+
import { collectBaseChangePaths, collectChangeSurface } from "../verify/change-surface.js";
|
|
10
|
+
import { RECEIPT_ID, validOrigin } from "./origin.js";
|
|
11
|
+
import { fixPaths, gitRunner, proveEscape } from "./rules.js";
|
|
12
|
+
/** Git-tracked, data only: one record per task so parallel worktrees never conflict. */
|
|
13
|
+
export const LEDGER_DIRECTORY = ".agent-ops-escapes";
|
|
14
|
+
const out = (result) => new TextDecoder().decode(result.stdout);
|
|
15
|
+
function gitFailed(what) {
|
|
16
|
+
throw new AgentOpsError("ESCAPE_GIT_FAILED", `git ${what} failed while linking the escape.`);
|
|
17
|
+
}
|
|
18
|
+
/** Resolve a declared commit-ish to its full SHA (argv only), then validate the whole origin. */
|
|
19
|
+
export async function resolveOrigin(git, value) {
|
|
20
|
+
const raw = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
21
|
+
let origin = raw;
|
|
22
|
+
if (raw.kind === "escape-of") {
|
|
23
|
+
const ref = raw.commit;
|
|
24
|
+
if (typeof ref !== "string" || !/^[A-Za-z0-9._/^~][A-Za-z0-9._/^~-]{0,255}$/u.test(ref))
|
|
25
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", "escape-of needs a commit.");
|
|
26
|
+
const resolved = await git.run(["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{commit}`]);
|
|
27
|
+
if (resolved.exitCode !== 0)
|
|
28
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", `Commit ${ref} does not exist in this repository.`);
|
|
29
|
+
origin = { ...raw, commit: out(resolved).trim() };
|
|
30
|
+
if (raw.fix !== undefined) {
|
|
31
|
+
if (typeof raw.fix !== "string" || !/^[A-Za-z0-9._/^~][A-Za-z0-9._/^~-]{0,255}$/u.test(raw.fix))
|
|
32
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", "escape fix needs a commit.");
|
|
33
|
+
const fix = await git.run(["rev-parse", "--verify", "--quiet", "--end-of-options", `${raw.fix}^{commit}`]);
|
|
34
|
+
if (fix.exitCode !== 0)
|
|
35
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", `Fix commit ${raw.fix} does not exist in this repository.`);
|
|
36
|
+
origin = { ...origin, fix: out(fix).trim() };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!validOrigin(origin))
|
|
40
|
+
throw new AgentOpsError("ESCAPE_ORIGIN_INVALID", "origin is {kind: escape-of, commit, label, receipt?, fix?, promote? (runnerId:checkId)} with label requirement|surface|scenario|criteria|verify|review|flagged|out-of-model, or {kind: pre-existing|external, reason}.");
|
|
41
|
+
return origin;
|
|
42
|
+
}
|
|
43
|
+
/** Per-file diff identity that survives squash: blob ids and hunk positions are dropped. */
|
|
44
|
+
export async function fileFingerprints(git, from, to) {
|
|
45
|
+
const names = await git.run(["diff", "--name-only", "-z", "--no-renames", from, to, "--"]);
|
|
46
|
+
const diff = await git.run(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "-U0",
|
|
47
|
+
"--src-prefix=a/", "--dst-prefix=b/", from, to, "--"]);
|
|
48
|
+
if (names.exitCode !== 0 || diff.exitCode !== 0)
|
|
49
|
+
gitFailed("diff");
|
|
50
|
+
const paths = out(names).split("\0").filter(Boolean);
|
|
51
|
+
const chunks = ("\n" + out(diff)).split("\ndiff --git ").slice(1);
|
|
52
|
+
if (chunks.length !== paths.length)
|
|
53
|
+
gitFailed("diff");
|
|
54
|
+
return paths.map((path, i) => ({ path, digest: sha256(chunks[i].split("\n").slice(1)
|
|
55
|
+
.filter(line => !line.startsWith("index ")).map(line => line.startsWith("@@") ? "@@" : line).join("\n")) }));
|
|
56
|
+
}
|
|
57
|
+
/** Receipt schema v2 fields: every commit in target..candidate and per-file fingerprints. */
|
|
58
|
+
export async function receiptRange(git, target, candidate) {
|
|
59
|
+
const list = await git.run(["rev-list", "--reverse", `${target}..${candidate}`, "--"]);
|
|
60
|
+
if (list.exitCode !== 0)
|
|
61
|
+
gitFailed("rev-list");
|
|
62
|
+
return { commits: out(list).split("\n").filter(Boolean), fileFingerprints: await fileFingerprints(git, target, candidate) };
|
|
63
|
+
}
|
|
64
|
+
export async function gitCommonDir(git, root) {
|
|
65
|
+
const result = await git.run(["rev-parse", "--git-common-dir"]);
|
|
66
|
+
if (result.exitCode !== 0)
|
|
67
|
+
gitFailed("rev-parse");
|
|
68
|
+
return resolve(root, out(result).trim());
|
|
69
|
+
}
|
|
70
|
+
/** Unreadable or foreign files are skipped: linking is advisory. */
|
|
71
|
+
export async function loadReceipts(commonDir) {
|
|
72
|
+
const directory = join(commonDir, "agent-ops", "receipts");
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = await readdir(directory);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code === "ENOENT")
|
|
79
|
+
return [];
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const loaded = [];
|
|
83
|
+
for (const name of names.filter(n => n.endsWith(".json") && RECEIPT_ID.test(n.slice(0, -5))).sort()) {
|
|
84
|
+
try {
|
|
85
|
+
const source = await readPrivateFile(join(directory, name), commonDir);
|
|
86
|
+
const receipt = source === null ? null : JSON.parse(source);
|
|
87
|
+
if (receipt !== null && (receipt.schemaVersion === 1 || receipt.schemaVersion === 2) && typeof receipt.candidateHead === "string")
|
|
88
|
+
loaded.push({ id: name.slice(0, -5), receipt });
|
|
89
|
+
}
|
|
90
|
+
catch { /* not a receipt this version can read */ }
|
|
91
|
+
}
|
|
92
|
+
return loaded;
|
|
93
|
+
}
|
|
94
|
+
const newest = (list) => [...list].sort((a, b) => String(b.receipt.createdAt).localeCompare(String(a.receipt.createdAt)))[0];
|
|
95
|
+
/** Override, then range (v1: exact head), then per-file fingerprint, then unresolved or ungated. */
|
|
96
|
+
export async function resolveEscape(git, receipts, commit, override) {
|
|
97
|
+
if (override !== undefined)
|
|
98
|
+
return receipts.some(r => r.id === override)
|
|
99
|
+
? { state: "gated", receipt: override, matchedBy: "override" } : { state: "unresolved", reason: "receipt-not-local", receipt: override };
|
|
100
|
+
const range = receipts.filter(r => r.receipt.schemaVersion === 2 && r.receipt.commits?.includes(commit));
|
|
101
|
+
if (range.length > 0)
|
|
102
|
+
return { state: "gated", receipt: newest(range).id, matchedBy: "range" };
|
|
103
|
+
const head = receipts.filter(r => r.receipt.schemaVersion === 1 && r.receipt.candidateHead === commit);
|
|
104
|
+
if (head.length > 0)
|
|
105
|
+
return { state: "gated", receipt: newest(head).id, matchedBy: "head" };
|
|
106
|
+
const parent = await git.run(["rev-parse", "--verify", "--quiet", `${commit}^`]);
|
|
107
|
+
if (parent.exitCode === 0) {
|
|
108
|
+
const own = await fileFingerprints(git, out(parent).trim(), commit);
|
|
109
|
+
const scored = receipts.map(r => ({ r, score: own.filter(f => r.receipt.fileFingerprints?.some(g => g.path === f.path && g.digest === f.digest)).length }));
|
|
110
|
+
const best = Math.max(0, ...scored.map(s => s.score));
|
|
111
|
+
const winners = scored.filter(s => s.score === best && best > 0);
|
|
112
|
+
if (winners.length === 1)
|
|
113
|
+
return { state: "gated", receipt: winners[0].r.id, matchedBy: "fingerprint" };
|
|
114
|
+
if (winners.length > 1)
|
|
115
|
+
return { state: "unresolved", reason: "ambiguous-receipt" };
|
|
116
|
+
}
|
|
117
|
+
// finish notes the merged commit with its receipt path; a note without the receipt means another machine.
|
|
118
|
+
const note = await git.run(["notes", `--ref=${NOTES_REF}`, "show", commit]);
|
|
119
|
+
return note.exitCode === 0 ? { state: "unresolved", reason: "receipt-not-local" } : { state: "ungated" };
|
|
120
|
+
}
|
|
121
|
+
/** git grep -F replay of the receipt's recorded queries at its candidate. */
|
|
122
|
+
export async function surfaceFacts(git, receipt, paths) {
|
|
123
|
+
const audits = receipt.tasks.map(t => t.task.surface).filter((s) => s !== undefined && !isCovers(s));
|
|
124
|
+
if (audits.length === 0)
|
|
125
|
+
return paths.map(path => ({ path, surface: "no-audit" }));
|
|
126
|
+
const head = receipt.candidateHead;
|
|
127
|
+
const hits = new Set();
|
|
128
|
+
const queries = new Set(audits.flatMap(a => Object.values(a.axes).flatMap(axis => "queries" in axis ? axis.queries : [])));
|
|
129
|
+
for (const query of queries) {
|
|
130
|
+
const result = await git.run(["grep", "-F", "-l", "-z", "-I", "--no-color", "-e", query, head, "--"]);
|
|
131
|
+
if (result.exitCode > 1)
|
|
132
|
+
return paths.map(path => ({ path, surface: "unavailable" }));
|
|
133
|
+
for (const hit of out(result).split("\0").filter(Boolean))
|
|
134
|
+
hits.add(hit.slice(head.length + 1));
|
|
135
|
+
}
|
|
136
|
+
const included = audits.flatMap(a => a.included.map(row => globMatcher(row.path)));
|
|
137
|
+
const excluded = audits.flatMap(a => a.excluded.map(row => globMatcher(row.glob)));
|
|
138
|
+
const facts = [];
|
|
139
|
+
for (const path of paths) {
|
|
140
|
+
const surface = included.some(m => m(path)) ? "included" : excluded.some(m => m(path)) ? "excluded" :
|
|
141
|
+
(await git.run(["cat-file", "-e", `${head}:${path}`])).exitCode !== 0 ? "absent" : hits.has(path) ? "hit" : "unseen";
|
|
142
|
+
facts.push({ path, surface });
|
|
143
|
+
}
|
|
144
|
+
return facts;
|
|
145
|
+
}
|
|
146
|
+
/** Mechanical replay outranks the agent unless it named the only earlier gate. */
|
|
147
|
+
export function primaryLabel(agentLabel, facts) {
|
|
148
|
+
if (agentLabel === "requirement")
|
|
149
|
+
return agentLabel;
|
|
150
|
+
if (facts.some(f => f.surface === "excluded"))
|
|
151
|
+
return "surface-excluded";
|
|
152
|
+
if (facts.some(f => f.surface === "unseen"))
|
|
153
|
+
return "surface-unseen";
|
|
154
|
+
return agentLabel;
|
|
155
|
+
}
|
|
156
|
+
/** Product files the fix touched since its base (tests and agent-ops data are not missed surfaces). */
|
|
157
|
+
export async function missedPaths(git, base) {
|
|
158
|
+
const paths = new Set([...await collectBaseChangePaths(git, base), ...(await collectChangeSurface(git)).paths]);
|
|
159
|
+
return [...paths].filter(path => !path.startsWith(".agent-ops") &&
|
|
160
|
+
!/(?:^|\/)(?:tests?|fixtures)(?:\/|$)|(?:\.test|\.spec)\./u.test(path)).sort().slice(0, 200);
|
|
161
|
+
}
|
|
162
|
+
/** A hint only: which commits last wrote the lines the fix changed or removed. Omissions have none. */
|
|
163
|
+
export async function blameHints(git, base) {
|
|
164
|
+
const diff = await git.run(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--no-renames", "-U0",
|
|
165
|
+
"--src-prefix=a/", "--dst-prefix=b/", base, "--"]);
|
|
166
|
+
if (diff.exitCode !== 0)
|
|
167
|
+
return [];
|
|
168
|
+
const counts = new Map();
|
|
169
|
+
let path = null, hunks = 0;
|
|
170
|
+
for (const line of out(diff).split("\n")) {
|
|
171
|
+
if (line.startsWith("--- ")) {
|
|
172
|
+
path = line.startsWith("--- a/") ? line.slice(6) : null;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const hunk = /^@@ -(\d+)(?:,(\d+))? /u.exec(line);
|
|
176
|
+
if (hunk === null || path === null || hunks >= 200)
|
|
177
|
+
continue;
|
|
178
|
+
const start = Number(hunk[1]), length = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
179
|
+
if (length === 0)
|
|
180
|
+
continue;
|
|
181
|
+
hunks += 1;
|
|
182
|
+
const blame = await git.run(["blame", "--porcelain", "-L", `${start},+${length}`, base, "--", path]);
|
|
183
|
+
if (blame.exitCode !== 0)
|
|
184
|
+
continue;
|
|
185
|
+
for (const header of out(blame).matchAll(/^([0-9a-f]{40,64}) \d+ \d+/gmu))
|
|
186
|
+
counts.set(header[1], (counts.get(header[1]) ?? 0) + 1);
|
|
187
|
+
}
|
|
188
|
+
return [...counts].map(([commit, lines]) => ({ commit, lines })).sort((a, b) => b.lines - a.lines || a.commit.localeCompare(b.commit)).slice(0, 10);
|
|
189
|
+
}
|
|
190
|
+
/** Linking is advisory: a Git failure leaves the escape unresolved, never blocks the declaration. */
|
|
191
|
+
export async function assessEscape(input) {
|
|
192
|
+
let receipts = [], link;
|
|
193
|
+
try {
|
|
194
|
+
receipts = await loadReceipts(await gitCommonDir(input.git, input.root));
|
|
195
|
+
link = await resolveEscape(input.git, receipts, input.origin.commit, input.origin.receipt);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
link = { state: "unresolved", reason: "link-unavailable" };
|
|
199
|
+
}
|
|
200
|
+
// A backfill names the past fix: its diff, not this task's, is what the gates missed.
|
|
201
|
+
const paths = input.origin.fix === undefined ? await missedPaths(input.git, input.base) : await fixPaths(input.root, input.origin.fix);
|
|
202
|
+
const linked = link.state === "gated" ? receipts.find(r => r.id === link.receipt)?.receipt : undefined;
|
|
203
|
+
let facts = [];
|
|
204
|
+
if (linked !== undefined) {
|
|
205
|
+
try {
|
|
206
|
+
facts = await surfaceFacts(input.git, linked, paths);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
facts = paths.map(path => ({ path, surface: "unavailable" }));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { link, agentLabel: input.origin.label, label: primaryLabel(input.origin.label, facts), missedPaths: paths, facts,
|
|
213
|
+
residualRisks: linked?.residualRisks ?? [] };
|
|
214
|
+
}
|
|
215
|
+
export function ledgerPath(root, taskId) {
|
|
216
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(taskId))
|
|
217
|
+
throw new AgentOpsError("ESCAPE_TASK_INVALID", "Invalid task ID for the escape ledger.");
|
|
218
|
+
return join(root, LEDGER_DIRECTORY, `${taskId}.json`);
|
|
219
|
+
}
|
|
220
|
+
/** The ledger is repository content: never follow a checked-in symlink out of the worktree. */
|
|
221
|
+
async function unsafe(target, kind) {
|
|
222
|
+
try {
|
|
223
|
+
const stat = await lstat(target);
|
|
224
|
+
return stat.isSymbolicLink() || (kind === "directory" ? !stat.isDirectory() : !stat.isFile());
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.code === "ENOENT")
|
|
228
|
+
return false;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function safeLedgerPath(root, taskId) {
|
|
233
|
+
const path = ledgerPath(root, taskId);
|
|
234
|
+
if (await unsafe(join(root, LEDGER_DIRECTORY), "directory") || await unsafe(path, "file"))
|
|
235
|
+
throw new AgentOpsError("ESCAPE_LEDGER_UNSAFE", `${LEDGER_DIRECTORY} must be a real directory of regular files.`);
|
|
236
|
+
return path;
|
|
237
|
+
}
|
|
238
|
+
/** One record per task; re-declaring replaces it. Commit it with the fix. */
|
|
239
|
+
export async function declareEscape(input) {
|
|
240
|
+
const assessment = await assessEscape(input);
|
|
241
|
+
const record = { schemaVersion: 1, taskId: input.taskId, escapeOf: input.origin.commit,
|
|
242
|
+
...(input.origin.fix === undefined ? {} : { fix: input.origin.fix }), link: assessment.link,
|
|
243
|
+
agentLabel: assessment.agentLabel, label: assessment.label, missedPaths: assessment.missedPaths, facts: assessment.facts,
|
|
244
|
+
residualRisks: assessment.residualRisks.length, proof: await proof(input.root, input.origin, assessment), declaredAt: input.now };
|
|
245
|
+
await safeLedgerPath(input.root, input.taskId);
|
|
246
|
+
await mkdir(join(input.root, LEDGER_DIRECTORY), { recursive: true });
|
|
247
|
+
const path = await safeLedgerPath(input.root, input.taskId);
|
|
248
|
+
// O_NOFOLLOW closes the lstat-to-open race on POSIX; Windows has no such flag.
|
|
249
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW ?? 0), 0o644);
|
|
250
|
+
try {
|
|
251
|
+
await handle.writeFile(`${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
await handle.close();
|
|
255
|
+
}
|
|
256
|
+
return { record, path, blame: await blameHints(input.git, input.base) };
|
|
257
|
+
}
|
|
258
|
+
/** Proof never throws at declaration: a broken rule is recorded as unproven and finish refuses it. */
|
|
259
|
+
async function proof(root, origin, assessment) {
|
|
260
|
+
try {
|
|
261
|
+
return await proveEscape({ root, escapeOf: origin.commit, label: assessment.label, missedPaths: assessment.missedPaths,
|
|
262
|
+
...(origin.promote === undefined ? {} : { promote: origin.promote }) });
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return { status: "unproven", code: "ESCAPE_RULE_UNPROVEN",
|
|
266
|
+
reason: error instanceof AgentOpsError ? `${error.code}: ${error.message}` : "The rule proof could not run." };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const proofKey = (value) => value === undefined ? null : JSON.stringify([value.status,
|
|
270
|
+
"rule" in value ? value.rule ?? null : null, "ruleHash" in value ? value.ruleHash : null, "check" in value ? value.check : null]);
|
|
271
|
+
/**
|
|
272
|
+
* Fails closed at finish: a fresh replay must prove the escape and match the
|
|
273
|
+
* committed ledger record. A criteria/verify label returns the check to prove.
|
|
274
|
+
*/
|
|
275
|
+
export async function escapeCompletionProblem(input) {
|
|
276
|
+
let assessment, current;
|
|
277
|
+
try {
|
|
278
|
+
assessment = await assessEscape({ ...input, git: gitRunner(input.root) });
|
|
279
|
+
current = await proveEscape({ root: input.root, escapeOf: input.origin.commit, label: assessment.label, missedPaths: assessment.missedPaths,
|
|
280
|
+
...(input.origin.promote === undefined ? {} : { promote: input.origin.promote }) });
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
const code = error instanceof AgentOpsError && ["ESCAPE_RULE_INVALID", "ESCAPE_RULE_UNSAFE"].includes(error.code) ? error.code : "ESCAPE_RULE_UNPROVEN";
|
|
284
|
+
return { code, reason: error instanceof Error ? error.message : "The escape proof could not run." };
|
|
285
|
+
}
|
|
286
|
+
if (current.status === "unproven")
|
|
287
|
+
return { code: current.code, reason: current.reason };
|
|
288
|
+
const recorded = await readEscapeRecord(input.root, input.taskId);
|
|
289
|
+
if (recorded === null || recorded.label !== assessment.label || proofKey(recorded.proof) !== proofKey(current))
|
|
290
|
+
return { code: "ESCAPE_RULE_UNPROVEN", reason: `The tracked ${LEDGER_DIRECTORY}/${input.taskId}.json record is missing or stale; re-declare the origin and commit the record.` };
|
|
291
|
+
if (current.status === "check")
|
|
292
|
+
return current.check === null
|
|
293
|
+
? { code: "ESCAPE_RULE_UNPROVEN", reason: "A criteria/verify escape names its red-to-green check: --escape-promote <runnerId:checkId> (DEBUG payload promote)." }
|
|
294
|
+
: { check: current.check };
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
export async function removeEscapeRecord(root, taskId) {
|
|
298
|
+
await rm(await safeLedgerPath(root, taskId), { force: true });
|
|
299
|
+
}
|
|
300
|
+
export async function readEscapeRecord(root, taskId) {
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(await readFile(await safeLedgerPath(root, taskId), "utf8"));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Review packet data: the declaration, a fresh assessment, and whether the tracked record still matches it. */
|
|
309
|
+
export async function escapeReviewData(input) {
|
|
310
|
+
if (input.origin.kind !== "escape-of")
|
|
311
|
+
return { taskId: input.taskId, origin: input.origin };
|
|
312
|
+
const assessment = await assessEscape({ ...input, origin: input.origin });
|
|
313
|
+
const recorded = await readEscapeRecord(input.root, input.taskId);
|
|
314
|
+
const current = recorded !== null && JSON.stringify([recorded.escapeOf, recorded.link, recorded.label, recorded.missedPaths, recorded.facts]) ===
|
|
315
|
+
JSON.stringify([input.origin.commit, assessment.link, assessment.label, assessment.missedPaths, assessment.facts]);
|
|
316
|
+
return { taskId: input.taskId, origin: input.origin, ...assessment, recordCurrent: current, ...(recorded?.proof === undefined ? {} : { proof: recorded.proof }) };
|
|
317
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Escape Loop R1: where a bug came from, declared on the task that fixes it. */
|
|
2
|
+
export const ESCAPE_GATES = ["requirement", "surface", "scenario", "criteria", "verify", "review"];
|
|
3
|
+
/** Agent-proposed label: the earliest gate that could have caught it, or flagged / out-of-model. */
|
|
4
|
+
export const ESCAPE_LABELS = [...ESCAPE_GATES, "flagged", "out-of-model"];
|
|
5
|
+
export const PROMOTE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
6
|
+
export const RECEIPT_ID = /^[a-f0-9]{64}-[a-f0-9]{40,64}$/u;
|
|
7
|
+
const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
8
|
+
const keys = (v, required, optional = []) => required.every(k => Object.hasOwn(v, k)) && Object.keys(v).every(k => required.includes(k) || optional.includes(k));
|
|
9
|
+
export function validOrigin(value) {
|
|
10
|
+
if (!record(value))
|
|
11
|
+
return false;
|
|
12
|
+
if (value.kind === "escape-of")
|
|
13
|
+
return keys(value, ["kind", "commit", "label"], ["receipt", "fix", "promote"]) && typeof value.commit === "string" &&
|
|
14
|
+
/^[a-f0-9]{40,64}$/u.test(value.commit) && ESCAPE_LABELS.includes(value.label) &&
|
|
15
|
+
(value.receipt === undefined || (typeof value.receipt === "string" && RECEIPT_ID.test(value.receipt))) &&
|
|
16
|
+
(value.fix === undefined || (typeof value.fix === "string" && /^[a-f0-9]{40,64}$/u.test(value.fix) && value.fix !== value.commit)) &&
|
|
17
|
+
(value.promote === undefined || (typeof value.promote === "string" && PROMOTE.test(value.promote)));
|
|
18
|
+
return (value.kind === "pre-existing" || value.kind === "external") && keys(value, ["kind", "reason"]) &&
|
|
19
|
+
typeof value.reason === "string" && value.reason.trim().length >= 10 && value.reason.length <= 2048 && !value.reason.includes("\0");
|
|
20
|
+
}
|