@kylecheng3146/agent-ops 0.8.6 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/packages/cli/src/args.js +56 -4
- package/dist/packages/cli/src/bin.js +10 -3
- package/dist/packages/cli/src/cli.js +18 -2
- package/dist/packages/cli/src/commands/escapes.js +109 -0
- package/dist/packages/cli/src/commands/parallel.js +5 -0
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +145 -4
- package/dist/packages/cli/src/commands/task.js +58 -7
- 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/debug-intake.js +2 -2
- package/dist/packages/cli/src/run-entry.js +7 -5
- 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 +2 -2
- package/dist/runtime/src/logging/local-log.js +61 -1
- package/dist/runtime/src/parallel/finish.js +20 -3
- package/dist/runtime/src/parallel/manage.js +51 -1
- package/dist/runtime/src/parallel/receipt.js +9 -1
- package/dist/runtime/src/review/execute.js +12 -6
- package/dist/runtime/src/review/packet.js +15 -1
- package/dist/runtime/src/review/probe.js +3 -2
- package/dist/runtime/src/review/runner.js +45 -0
- package/dist/runtime/src/review/slots.js +1 -1
- package/dist/runtime/src/run/planning.js +5 -2
- package/dist/runtime/src/schema/validate.js +17 -1
- package/dist/runtime/src/surface/discovery.js +298 -0
- package/dist/runtime/src/surface/schema.js +168 -0
- package/dist/runtime/src/task/completion.js +20 -0
- package/dist/runtime/src/task/contract.js +5 -1
- package/dist/runtime/src/task/debug.js +27 -1
- package/dist/runtime/src/task/service.js +72 -5
- package/dist/runtime/src/verify/acceptance-replay.js +24 -11
- package/docs/en/guides/configuration.md +16 -1
- package/docs/en/guides/task-debug.md +12 -0
- package/docs/en/spec/acceptance-and-evidence.md +152 -0
- package/docs/en/spec/judgment.md +7 -0
- package/docs/en/spec/loop-engineering.md +2 -2
- package/docs/en/spec/review.md +5 -0
- package/docs/zh-TW/guides/configuration.md +14 -1
- package/docs/zh-TW/guides/task-debug.md +10 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +127 -0
- package/docs/zh-TW/spec/judgment.md +6 -0
- package/docs/zh-TW/spec/loop-engineering.md +2 -2
- package/docs/zh-TW/spec/review.md +1 -0
- package/package.json +1 -1
- package/schemas/task.schema.json +14 -0
|
@@ -30,6 +30,44 @@ async function jsonFile(path) {
|
|
|
30
30
|
throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Invalid JSON payload.");
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
async function escapeOrigin(args, service) {
|
|
34
|
+
return args.escapeOf === undefined ? undefined : await service.resolveOrigin({ kind: "escape-of", commit: args.escapeOf,
|
|
35
|
+
label: args.escapeLabel, ...(args.escapeReceipt === undefined ? {} : { receipt: args.escapeReceipt }),
|
|
36
|
+
...(args.escapeFix === undefined ? {} : { fix: args.escapeFix }), ...(args.escapePromote === undefined ? {} : { promote: args.escapePromote }) });
|
|
37
|
+
}
|
|
38
|
+
/** Link state, label and blame hints are printed; the ledger record is committed with the fix. */
|
|
39
|
+
async function withEscape(envelope, escape, service) {
|
|
40
|
+
if (escape === undefined || envelope.data === null)
|
|
41
|
+
return envelope;
|
|
42
|
+
const { link, label, agentLabel, facts, proof } = escape.record;
|
|
43
|
+
const proposal = proof?.status === "check" && proof.check !== null ? await service.escapeConfigProposal(proof.check) : undefined;
|
|
44
|
+
const lines = [
|
|
45
|
+
`Escape of ${escape.record.escapeOf}: ${link.state}${link.state === "unresolved" ? ` (${link.reason})` : ""}` +
|
|
46
|
+
`${link.state === "gated" ? ` via receipt ${link.receipt} (${link.matchedBy})` : ""}; label ${label}${label === agentLabel ? "" : ` (agent proposed ${agentLabel})`}.`,
|
|
47
|
+
...facts.map(fact => `- ${fact.path}: ${fact.surface}`),
|
|
48
|
+
...(proof === undefined ? [] : [proof.status === "proven"
|
|
49
|
+
? `Proof: rule ${proof.rule} replays at the escaped commit; backtest ${proof.backtest.fires}/${proof.backtest.commits} commits (${proof.backtest.rate}).`
|
|
50
|
+
: proof.status === "unproven" ? `Proof: ${proof.code} - ${proof.reason} Finish refuses this until a tracked rule proves it.`
|
|
51
|
+
: proof.status === "lesson" ? `Lesson (unproven, does not block): ${proof.reason}`
|
|
52
|
+
: proof.check === null ? "Proof: ESCAPE_RULE_UNPROVEN - name the red-to-green check with --escape-promote <runnerId:checkId>."
|
|
53
|
+
: `Proof: promoted check ${proof.check} must be a proven redCheckId of this task. Config proposal (not written):\n${JSON.stringify(proposal, null, 2)}`]),
|
|
54
|
+
`Ledger record: ${escape.path} (commit it with the fix).`,
|
|
55
|
+
escape.blame.length === 0 ? "Blame hints: none (the fix only adds lines; an omission has no author)."
|
|
56
|
+
: `Blame hints (a hint only): ${escape.blame.map(hint => `${hint.commit.slice(0, 12)} (${hint.lines} line${hint.lines === 1 ? "" : "s"})`).join(", ")}`
|
|
57
|
+
];
|
|
58
|
+
return { ...envelope, data: { ...envelope.data, escape: { ...escape.record, blame: escape.blame, path: escape.path,
|
|
59
|
+
...(proposal === undefined ? {} : { configProposal: proposal }) },
|
|
60
|
+
text: `${envelope.data.text ?? ""}${envelope.data.text?.endsWith("\n") === false ? "\n" : ""}${lines.join("\n")}\n` } };
|
|
61
|
+
}
|
|
62
|
+
/** An unreadable surface file is a missing surface, not a generic payload error. */
|
|
63
|
+
async function surfaceFile(path) {
|
|
64
|
+
try {
|
|
65
|
+
return await jsonFile(path);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new AgentOpsError("SURFACE_MISSING", `Surface file is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
33
71
|
function parseEvidence(values) {
|
|
34
72
|
const evidence = {};
|
|
35
73
|
for (const value of values) {
|
|
@@ -96,6 +134,7 @@ export async function runTaskCommand(options) {
|
|
|
96
134
|
const worktree = sessionId === undefined || options.sessionWorktree === undefined
|
|
97
135
|
? undefined
|
|
98
136
|
: await options.sessionWorktree(sessionId);
|
|
137
|
+
const origin = await escapeOrigin(options.args, worktree?.service ?? options.service);
|
|
99
138
|
const record = await (worktree?.service ?? options.service).create({
|
|
100
139
|
title: options.args.title,
|
|
101
140
|
...(options.args.goal === undefined ? {} : { goal: options.args.goal }),
|
|
@@ -111,19 +150,23 @@ export async function runTaskCommand(options) {
|
|
|
111
150
|
...(options.args.parentTaskId === undefined
|
|
112
151
|
? {}
|
|
113
152
|
: { parentTaskId: options.args.parentTaskId }),
|
|
114
|
-
...(sessionId === undefined ? {} : { sessionId })
|
|
153
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
154
|
+
...(options.args.surfaceFile === undefined ? {} : { surface: await surfaceFile(options.args.surfaceFile) }),
|
|
155
|
+
...(origin === undefined ? {} : { origin }),
|
|
156
|
+
requireSurface: true
|
|
115
157
|
});
|
|
158
|
+
const escape = origin === undefined ? undefined : await (worktree?.service ?? options.service).declareEscape(record.task.id);
|
|
116
159
|
// An explicit CLI DEBUG intake may be the only start signal on hosts
|
|
117
160
|
// without a prompt hook (notably agy). The native session owns the clock;
|
|
118
161
|
// foregroundStart is idempotent for a repeated hook or command.
|
|
119
162
|
if (options.args.debug && sessionId !== undefined) {
|
|
120
163
|
await (worktree?.service ?? options.service).debugService().foregroundStart(record.task.id, sessionId);
|
|
121
164
|
}
|
|
122
|
-
const created = taskEnvelope(action, "TASK_CREATED", sessionId === undefined
|
|
165
|
+
const created = await withEscape(taskEnvelope(action, "TASK_CREATED", sessionId === undefined
|
|
123
166
|
? `Created task ${record.task.id}.`
|
|
124
167
|
: record.task.parentTaskId === undefined
|
|
125
168
|
? `Created and attached task ${record.task.id}.`
|
|
126
|
-
: `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record);
|
|
169
|
+
: `Created subtask ${record.task.id}; the session stays on the top of its task tree.`, record), escape, worktree?.service ?? options.service);
|
|
127
170
|
if (worktree === undefined || created.data === null)
|
|
128
171
|
return created;
|
|
129
172
|
return {
|
|
@@ -146,6 +189,10 @@ export async function runTaskCommand(options) {
|
|
|
146
189
|
}
|
|
147
190
|
if (action === "debug") {
|
|
148
191
|
const id = requireTaskId(options.args), service = options.service.debugService();
|
|
192
|
+
if (options.args.debugAction === "origin") {
|
|
193
|
+
const declared = await service.origin(id, await jsonFile(options.args.payloadFile));
|
|
194
|
+
return await withEscape(taskEnvelope(action, "TASK_DEBUG_ORIGIN_DECLARED", "Origin bound into the contract; re-verify, re-record the confirmed hypothesis, then review.", declared.record), declared.escape, options.service);
|
|
195
|
+
}
|
|
149
196
|
switch (options.args.debugAction) {
|
|
150
197
|
case "hypothesis":
|
|
151
198
|
await service.hypothesis(id, await jsonFile(options.args.payloadFile));
|
|
@@ -177,14 +224,18 @@ export async function runTaskCommand(options) {
|
|
|
177
224
|
return taskEnvelope(action, "TASK_DEBUG_UPDATED", "Original issue debug state preserved.", await service.root(id));
|
|
178
225
|
}
|
|
179
226
|
if (action === "revise") {
|
|
180
|
-
const payload = await jsonFile(options.args.criteriaFile);
|
|
181
|
-
if (!Array.isArray(payload))
|
|
227
|
+
const payload = options.args.criteriaFile === undefined ? undefined : await jsonFile(options.args.criteriaFile);
|
|
228
|
+
if (payload !== undefined && !Array.isArray(payload))
|
|
182
229
|
throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Criteria file must contain an array.");
|
|
230
|
+
const origin = await escapeOrigin(options.args, options.service);
|
|
183
231
|
const record = await options.service.revise(requireTaskId(options.args), {
|
|
184
|
-
expectedContractHash: options.args.expectedContract,
|
|
232
|
+
expectedContractHash: options.args.expectedContract,
|
|
233
|
+
...(payload === undefined ? {} : { criteria: payload }),
|
|
234
|
+
...(options.args.surfaceFile === undefined ? {} : { surface: await surfaceFile(options.args.surfaceFile) }),
|
|
235
|
+
...(origin === undefined ? {} : { origin }),
|
|
185
236
|
reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
|
|
186
237
|
});
|
|
187
|
-
return taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain new verification and fresh review.", record);
|
|
238
|
+
return await withEscape(taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain new verification and fresh review.", record), origin === undefined ? undefined : await options.service.declareEscape(record.task.id), options.service);
|
|
188
239
|
}
|
|
189
240
|
if (action === "replan") {
|
|
190
241
|
const payload = await jsonFile(options.args.planFile);
|
|
@@ -8,6 +8,11 @@ import { okEnvelope } from "../output.js";
|
|
|
8
8
|
import { formatOperationPlan } from "../plan-output.js";
|
|
9
9
|
import { toPublicUpdatePlan } from "../public-plan.js";
|
|
10
10
|
import { formatTrustChange, planTrustGrant } from "./trust.js";
|
|
11
|
+
/** One plan line, so the user approves the worktrees together with the commands. */
|
|
12
|
+
export function worktreeSyncLine(worktrees) {
|
|
13
|
+
return worktrees.length === 0 ? "" :
|
|
14
|
+
`Session worktrees that receive this config and its trust: ${worktrees.join(", ")}\n`;
|
|
15
|
+
}
|
|
11
16
|
function repairedLines(plan) {
|
|
12
17
|
return plan.installation.repaired.map(({ path, reason }) => `repaired: ${path} (${reason.replace("-", " ")})`);
|
|
13
18
|
}
|
|
@@ -146,19 +151,21 @@ export async function runUpdateCommand(options) {
|
|
|
146
151
|
...(worktree === undefined ? {} : { worktree })
|
|
147
152
|
});
|
|
148
153
|
const trust = await trustChange(options, plan);
|
|
154
|
+
const worktrees = await options.sessionWorktrees?.list().catch(() => []) ?? [];
|
|
149
155
|
if (options.args.dryRun) {
|
|
150
156
|
return okEnvelope("UPDATE_PLAN_READY", {
|
|
151
157
|
applied: false,
|
|
152
158
|
plan: toPublicUpdatePlan(plan, trust),
|
|
153
159
|
message: "Update plan calculated; no files were changed.",
|
|
154
|
-
text: formatUpdatePlan(plan, trust)
|
|
160
|
+
text: formatUpdatePlan(plan, trust) + worktreeSyncLine(worktrees),
|
|
161
|
+
...(worktrees.length === 0 ? {} : { worktrees })
|
|
155
162
|
});
|
|
156
163
|
}
|
|
157
164
|
if (!options.args.yes && !options.isTTY) {
|
|
158
165
|
return updateError("UPDATE_CONFIRMATION_REQUIRED", "Non-interactive update requires --yes.", plan, trust);
|
|
159
166
|
}
|
|
160
167
|
if (!options.args.yes &&
|
|
161
|
-
!(await options.confirm(plan, trust))) {
|
|
168
|
+
!(await options.confirm(plan, trust, worktrees))) {
|
|
162
169
|
return updateError("UPDATE_CANCELLED", "Update was cancelled; no files were changed.", plan, trust);
|
|
163
170
|
}
|
|
164
171
|
await applyUpdatePlan(options.root, plan);
|
|
@@ -173,16 +180,21 @@ export async function runUpdateCommand(options) {
|
|
|
173
180
|
return updateError("UPDATE_TRUST_FAILED", "Update was applied, but repository trust was not granted. Run `agent-ops trust grant --yes`.", plan, trust, true);
|
|
174
181
|
}
|
|
175
182
|
}
|
|
183
|
+
// Fail-open: a worktree that cannot be synced keeps its old trust and says so via trust status.
|
|
184
|
+
const synced = worktrees.length === 0 ? [] : await options.sessionWorktrees?.sync().catch(() => []) ?? [];
|
|
185
|
+
const syncedLines = synced.length === 0 ? [] : [`Synced config and trust into session worktrees: ${synced.join(", ")}.`];
|
|
176
186
|
return okEnvelope("UPDATE_APPLIED", {
|
|
177
187
|
applied: true,
|
|
178
188
|
plan: toPublicUpdatePlan(plan, trust),
|
|
179
189
|
message: `Managed installation updated to ${plan.targetVersion}.`,
|
|
180
|
-
...(
|
|
190
|
+
...(synced.length === 0 ? {} : { worktrees: synced }),
|
|
191
|
+
...(plan.installation.repaired.length === 0 && synced.length === 0
|
|
181
192
|
? {}
|
|
182
193
|
: {
|
|
183
194
|
text: [
|
|
184
195
|
`Managed installation updated to ${plan.targetVersion}.`,
|
|
185
|
-
...repairedLines(plan)
|
|
196
|
+
...repairedLines(plan),
|
|
197
|
+
...syncedLines
|
|
186
198
|
].join("\n")
|
|
187
199
|
})
|
|
188
200
|
});
|
|
@@ -72,7 +72,10 @@ function formatReport(report) {
|
|
|
72
72
|
const lines = [
|
|
73
73
|
`Verification task ${safe(report.taskId)}: ${report.status}`,
|
|
74
74
|
`Scope: ${report.selection.reason} (${report.selection.fallback ? "fallback" : "bounded"})`,
|
|
75
|
-
...report.results.flatMap(formatResult)
|
|
75
|
+
...report.results.flatMap(formatResult),
|
|
76
|
+
// A timed-out runner step says which step and which limit; plain UNKNOWN left people guessing.
|
|
77
|
+
...(report.acceptance ?? []).flatMap(row => row.detail === undefined ? [] :
|
|
78
|
+
[`- ${row.status} ${safe(row.runnerId)} (${row.criterionId}, ${row.phase}): ${safe(row.detail)}`])
|
|
76
79
|
];
|
|
77
80
|
if (report.signal !== null) {
|
|
78
81
|
lines.push(`Signal: ${report.signal}`);
|
|
@@ -159,7 +159,7 @@ export async function routeDebugPrompt(root, input, host = "claude") {
|
|
|
159
159
|
const baseline = (await gitRunner(path).run(["rev-parse", "--verify", "HEAD"])).stdout.toString().trim();
|
|
160
160
|
const criteria = ["issue-regression", "related-checks"].map(id => ({ id, description: id === "issue-regression" ? "Reproduce and fix the original reported issue with relevant regression evidence." : "Pass necessary related checks and two fresh independent reviews.", verifierIds: ids,
|
|
161
161
|
acceptance: { mode: "review-only", baselineCommit: baseline, bindings: [], reviewOnlyReason: "Provisional investigation intake only. Identify and revise to executable original-issue regression before debug success; this criterion cannot prove reproduction." } }));
|
|
162
|
-
const created = await tasks.create({ title: fields.prompt.split("\n")[0].slice(0, 160), goal: fields.prompt, intent: "Investigate and fix the original issue through authoritative task debug evidence.", debug: { facts: ["User-reported symptom is unverified."], unknowns: ["Baseline reproduction", "Root cause", "Relevant regression and related checks"] }, criteria, sessionId: session, policyConfigHash: calculateConfigHash(effective) });
|
|
162
|
+
const created = await tasks.create({ title: fields.prompt.split("\n")[0].slice(0, 160), goal: fields.prompt, intent: "Investigate and fix the original issue through authoritative task debug evidence.", debug: { facts: ["User-reported symptom is unverified."], unknowns: ["Baseline reproduction", "Root cause", "Relevant regression and related checks"] }, criteria, sessionId: session, policyConfigHash: calculateConfigHash(effective), requireSurface: true });
|
|
163
163
|
let clockNote = "";
|
|
164
164
|
if (foregroundClockSupported(host)) {
|
|
165
165
|
try {
|
|
@@ -171,7 +171,7 @@ export async function routeDebugPrompt(root, input, host = "claude") {
|
|
|
171
171
|
}
|
|
172
172
|
else
|
|
173
173
|
clockNote = " Native foreground timing is unsupported on this host; controlled operations remain evidence-timed and do not claim a complete active-time budget.";
|
|
174
|
-
return `DEBUG issue ${created.task.id} created in ${path}. Preserve the original goal. Investigate, record falsifiable hypotheses, revise provisional criteria to relevant behavioral regression, repair, verify, obtain two fresh independent reviews, then finish. A clean HEAD baseline is fixed automatically; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Defaults: 60 active minutes / 5 material repair candidates. No user-authored debug plan is required.${clockNote}`;
|
|
174
|
+
return `DEBUG issue ${created.task.id} created in ${path}. Preserve the original goal. Investigate, record falsifiable hypotheses, revise provisional criteria to relevant behavioral regression, add the surface audit (task revise --surface-file) before the first repair, repair, declare the origin (task debug --debug-action origin) once a hypothesis is confirmed, verify, obtain two fresh independent reviews, then finish. A clean HEAD baseline is fixed automatically; do not ask permission for it or offer diagnosis-only as a fallback. Failure to reproduce alone is not a stop condition: continue discriminating checks within the budget. Defaults: 60 active minutes / 5 material repair candidates. No user-authored debug plan is required.${clockNote}`;
|
|
175
175
|
}
|
|
176
176
|
export async function interruptDebugSession(root, sessionId, nativeInput) {
|
|
177
177
|
const saved = await readWorktreeRecord(join(root, ".worktrees", sessionWorktreeName(sessionId)));
|
|
@@ -163,7 +163,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
163
163
|
"Commit product changes before revising the contract. A contract revision stops existing generations; dirty recovery preserves the checkout and blocks takeover.",
|
|
164
164
|
"Write one request atomically to .agent-ops/tasks/run-request.json; wait for matching run-response.json before another request.",
|
|
165
165
|
"Requests require requestId (unique alphanumeric or hyphen, max 64), workerId, generation and current contractHash.",
|
|
166
|
-
"Plan fields: action=plan,title,intent,criteria. Worker fields: action=worker,title,intent,criteria,dependencies (known worker task IDs; omit all acceptance baselines until dependencies are delivered).",
|
|
166
|
+
"Plan fields: action=plan,title,intent,criteria,surface (the EVIDENCE-SURFACE-001 audit object, same shape as task create --surface-file). Worker fields: action=worker,title,intent,criteria,dependencies (known worker task IDs; omit all acceptance baselines until dependencies are delivered),covers (parent included surface IDs this worker delivers; every included surface needs one worker).",
|
|
167
167
|
"Only the coordinator may change execution policy: submit action=policy, configFile (relative proposed JSON), expectedPolicyHash, approvals (each changed capabilityId command:<id>/runner:<id>/setup:<index>, classification=non-dangerous, reason assessing the entire fixed definition including setup/build). Commit source changes and wait until every writer is clean before requesting policy synchronization. Never use init/update/trust grant to create permanent execution authorization. Explicit native or organization denial must not be bypassed.",
|
|
168
168
|
"Worker task baselines are frozen after dependency commits enter its checkout. Handoff fields: action=handoff,sourceFiles (explicit existing source paths when unchanged). Question fields: action=question,prompt. Coordinator finalize fields: action=finalize.",
|
|
169
169
|
"The supervisor, after stopping writers, runs final verification, two fresh reviews and finish. A native completed goal is never final proof."], diagnostic };
|
|
@@ -253,7 +253,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
253
253
|
generateId: () => node.taskId, completion: { root: workerRoot, gitRunner: gitRunner(workerRoot), loadConfig: async () => config }
|
|
254
254
|
});
|
|
255
255
|
await service.create({ title: plan.title, intent: plan.intent, criteria: plan.criteria, goal: current.goal,
|
|
256
|
-
sessionId: ownerSessionId, policyConfigHash: calculateConfigHash(config) });
|
|
256
|
+
sessionId: ownerSessionId, policyConfigHash: calculateConfigHash(config), ...(plan.covers === undefined ? {} : { surface: { covers: plan.covers } }) });
|
|
257
257
|
await start((await repository.read(runId)).workers.find(w => w.workerId === registration.workerId));
|
|
258
258
|
}
|
|
259
259
|
catch (cause) {
|
|
@@ -756,7 +756,7 @@ export async function superviseNativeRun(root, runId) {
|
|
|
756
756
|
try {
|
|
757
757
|
if (current.status !== "active" || request.contractHash !== current.currentContractHash)
|
|
758
758
|
throw new AgentOpsError("RUN_CONTRACT_CHANGED", "Request must bind the current run contract.");
|
|
759
|
-
const fields = request.action === "policy" ? ["configFile", "expectedPolicyHash", "approvals"] : request.action === "plan" ? ["title", "intent", "criteria"] : request.action === "question" ? ["prompt"] : request.action === "worker" ? ["title", "intent", "criteria", "dependencies"] :
|
|
759
|
+
const fields = request.action === "policy" ? ["configFile", "expectedPolicyHash", "approvals"] : request.action === "plan" ? ["title", "intent", "criteria", "surface"] : request.action === "question" ? ["prompt"] : request.action === "worker" ? ["title", "intent", "criteria", "dependencies", "covers"] :
|
|
760
760
|
request.action === "handoff" || request.action === "finalize" ? ["sourceFiles"] : [];
|
|
761
761
|
if (Object.keys(request).some(key => !["requestId", "workerId", "generation", "contractHash", "action", ...fields].includes(key)))
|
|
762
762
|
throw new AgentOpsError("RUN_REQUEST_INVALID", "Unknown request fields are not accepted.");
|
|
@@ -790,7 +790,8 @@ export async function superviseNativeRun(root, runId) {
|
|
|
790
790
|
const config = await deps.loadConfig(worker.worktree);
|
|
791
791
|
const service = new TaskService(new FileTaskStore(join(worker.worktree, ".agent-ops", "tasks", "state.json"), worker.worktree), { completion: { root: worker.worktree, gitRunner: gitRunner(worker.worktree), loadConfig: async () => config } });
|
|
792
792
|
const task = await service.create({ title: request.title, intent: request.intent, goal: current.goal,
|
|
793
|
-
criteria: request.criteria, sessionId: worker.ownerSessionId, policyConfigHash: calculateConfigHash(config)
|
|
793
|
+
criteria: request.criteria, sessionId: worker.ownerSessionId, policyConfigHash: calculateConfigHash(config),
|
|
794
|
+
...(request.surface === undefined ? {} : { surface: request.surface }), requireSurface: true });
|
|
794
795
|
await repository.mutate(runId, saved => ({ ...saved, rootTaskId: task.task.id, currentContractHash: treeContractHash([task.task]),
|
|
795
796
|
tasks: saved.tasks.map(node => node.taskId === worker.taskId ? { ...node, taskId: task.task.id } : node),
|
|
796
797
|
workers: saved.workers.map(w => w.workerId === worker.workerId ? { ...w, taskId: task.task.id } : w) }));
|
|
@@ -802,7 +803,8 @@ export async function superviseNativeRun(root, runId) {
|
|
|
802
803
|
if (worker.workerId !== current.coordinatorId || current.rootTaskId === null)
|
|
803
804
|
throw new AgentOpsError("RUN_COORDINATOR_REQUIRED", "Only the registered coordinator can plan child workers.");
|
|
804
805
|
const taskId = "task-" + (await import("node:crypto")).randomUUID();
|
|
805
|
-
const plan = workerPlan({ title: request.title, intent: request.intent, criteria: request.criteria, dependencies: request.dependencies
|
|
806
|
+
const plan = workerPlan({ title: request.title, intent: request.intent, criteria: request.criteria, dependencies: request.dependencies,
|
|
807
|
+
...(request.covers === undefined ? {} : { covers: request.covers }) }, taskId, current);
|
|
806
808
|
const { taskId: _id, ...specification } = plan;
|
|
807
809
|
const content = JSON.stringify(specification);
|
|
808
810
|
await writePrivateFile(join(directory, runId, "plans", taskId + ".json"), content, commonDir);
|
|
@@ -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
|
+
}
|