@kylecheng3146/agent-ops 0.5.3 → 0.6.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.
Files changed (93) hide show
  1. package/README.md +48 -0
  2. package/dist/packages/cli/src/args.js +165 -2
  3. package/dist/packages/cli/src/bin.js +64 -17
  4. package/dist/packages/cli/src/cli.js +33 -6
  5. package/dist/packages/cli/src/commands/advance.js +23 -3
  6. package/dist/packages/cli/src/commands/review-show.js +1 -1
  7. package/dist/packages/cli/src/commands/review.js +100 -9
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +61 -17
  10. package/dist/packages/cli/src/commands/verify.js +1 -0
  11. package/dist/packages/cli/src/context.js +63 -1
  12. package/dist/packages/cli/src/hook-process.js +31 -1
  13. package/dist/packages/cli/src/owned-run-step.js +65 -0
  14. package/dist/packages/cli/src/parallel-deps.js +18 -5
  15. package/dist/packages/cli/src/run-deps.js +314 -0
  16. package/dist/packages/cli/src/run-entry.js +943 -0
  17. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  18. package/dist/packages/cli/src/run-step-entry.js +58 -0
  19. package/dist/runtime/src/config/explain.js +6 -0
  20. package/dist/runtime/src/config/hash.js +1 -1
  21. package/dist/runtime/src/config/merge.js +9 -1
  22. package/dist/runtime/src/contracts.js +2 -2
  23. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  24. package/dist/runtime/src/install/doctor.js +2 -0
  25. package/dist/runtime/src/parallel/finish.js +85 -22
  26. package/dist/runtime/src/parallel/guard.js +5 -1
  27. package/dist/runtime/src/parallel/integrate.js +288 -10
  28. package/dist/runtime/src/parallel/manage.js +4 -1
  29. package/dist/runtime/src/parallel/receipt.js +71 -5
  30. package/dist/runtime/src/parallel/service.js +41 -1
  31. package/dist/runtime/src/review/attestation.js +36 -11
  32. package/dist/runtime/src/review/execute.js +24 -1
  33. package/dist/runtime/src/review/packet.js +35 -1
  34. package/dist/runtime/src/review/runner.js +12 -1
  35. package/dist/runtime/src/review/scope.js +18 -1
  36. package/dist/runtime/src/review/slots.js +35 -10
  37. package/dist/runtime/src/run/authorization.js +60 -0
  38. package/dist/runtime/src/run/budget.js +30 -0
  39. package/dist/runtime/src/run/controls.js +139 -0
  40. package/dist/runtime/src/run/convergence.js +177 -0
  41. package/dist/runtime/src/run/hosts/claude.js +180 -0
  42. package/dist/runtime/src/run/hosts/codex.js +279 -0
  43. package/dist/runtime/src/run/hosts/index.js +4 -0
  44. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  45. package/dist/runtime/src/run/hosts/types.js +1 -0
  46. package/dist/runtime/src/run/hosts/util.js +136 -0
  47. package/dist/runtime/src/run/integration.js +204 -0
  48. package/dist/runtime/src/run/macOS.js +189 -0
  49. package/dist/runtime/src/run/ownership.js +22 -0
  50. package/dist/runtime/src/run/planning.js +25 -0
  51. package/dist/runtime/src/run/policy-transition.js +1076 -0
  52. package/dist/runtime/src/run/policy.js +176 -0
  53. package/dist/runtime/src/run/ratchet.js +71 -0
  54. package/dist/runtime/src/run/scheduler.js +260 -0
  55. package/dist/runtime/src/run/service.js +425 -0
  56. package/dist/runtime/src/run/supervisor.js +635 -0
  57. package/dist/runtime/src/run/transport.js +159 -0
  58. package/dist/runtime/src/run/types.js +1 -0
  59. package/dist/runtime/src/run/usage.js +81 -0
  60. package/dist/runtime/src/run/verification.js +64 -0
  61. package/dist/runtime/src/run/worker-gate.js +48 -0
  62. package/dist/runtime/src/schema/acceptance.js +91 -0
  63. package/dist/runtime/src/schema/validate.js +67 -7
  64. package/dist/runtime/src/task/completion.js +14 -2
  65. package/dist/runtime/src/task/contract.js +17 -0
  66. package/dist/runtime/src/task/pin-finding.js +81 -0
  67. package/dist/runtime/src/task/service.js +316 -13
  68. package/dist/runtime/src/task/store.js +57 -7
  69. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  70. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  71. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  72. package/dist/runtime/src/verify/adapters/index.js +5 -0
  73. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  74. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  75. package/dist/runtime/src/verify/adapters/node.js +254 -0
  76. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  77. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  78. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  79. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  80. package/dist/runtime/src/verify/evidence.js +5 -2
  81. package/dist/runtime/src/verify/service.js +24 -8
  82. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  83. package/dist/runtime/src/verify/spawn.js +13 -2
  84. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  85. package/docs/en/spec/loop-engineering.md +49 -0
  86. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  87. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  88. package/package.json +1 -1
  89. package/schemas/config.schema.json +221 -19
  90. package/schemas/evidence.schema.json +129 -6
  91. package/schemas/task.schema.json +265 -6
  92. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  93. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -0,0 +1,159 @@
1
+ import { nativeProcessGroupAlive, signalNativeProcessGroup } from "./hosts/util.js";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { AgentOpsError } from "../fs/paths.js";
5
+ const exec = promisify(execFile);
6
+ export async function nativeProcessIdentity(pid) {
7
+ if (!Number.isSafeInteger(pid) || pid < 1)
8
+ return null;
9
+ try {
10
+ const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="], { maxBuffer: 8192 });
11
+ return stdout.trim() || null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /** Adapt the native transport without activating a writer before its lease is saved. */
18
+ export class NativeRunTransport {
19
+ transport;
20
+ repository;
21
+ onEvent;
22
+ processIdentity;
23
+ host;
24
+ #sessions = new Map();
25
+ constructor(transport, repository, onEvent = async () => { }, processIdentity = nativeProcessIdentity) {
26
+ this.transport = transport;
27
+ this.repository = repository;
28
+ this.onEvent = onEvent;
29
+ this.processIdentity = processIdentity;
30
+ this.host = transport.kind;
31
+ }
32
+ async start(input) {
33
+ if (input.worktree === null || input.contractHash === null)
34
+ throw new AgentOpsError("RUN_NATIVE_CONTEXT_REQUIRED", "Native workers require a worktree and current contract hash.");
35
+ const saved = (await this.repository.read(input.runId))?.workers.find(w => w.workerId === input.workerId);
36
+ const request = { runId: input.runId, workerId: input.workerId, generation: input.generation,
37
+ contractHash: input.contractHash, cwd: input.worktree,
38
+ env: { AGENT_OPS_SESSION_ID: input.ownerSessionId, AGENT_OPS_HOST: this.host,
39
+ CODEX_THREAD_ID: undefined, AGENT_OPS_AGENT_ID: undefined,
40
+ AGENT_OPS_RUN_ID: input.runId, AGENT_OPS_WORKER_ID: input.workerId,
41
+ AGENT_OPS_WORKER_GENERATION: String(input.generation) },
42
+ sessionId: saved?.nativeSessionId ?? input.ownerSessionId,
43
+ registerProcess: async (pid) => {
44
+ const identity = await this.processIdentity(pid);
45
+ if (identity === null)
46
+ throw new AgentOpsError("RUN_PROCESS_IDENTITY_REQUIRED", "Cannot register provisional native process identity.");
47
+ await this.repository.mutate(input.runId, current => {
48
+ const worker = current.workers.find(w => w.workerId === input.workerId);
49
+ if (current.status !== "active" || current.disableRestart || worker?.status !== "starting" || worker.generation !== input.generation)
50
+ throw new AgentOpsError("RUN_START_DISABLED", "Stop fenced provisional native registration.");
51
+ return { ...current, workers: current.workers.map(w => w.workerId === input.workerId ? { ...w,
52
+ nativeSessionId: saved?.nativeSessionId ?? input.ownerSessionId, processId: pid, processIdentity: identity } : w) };
53
+ });
54
+ } };
55
+ const incompleteCodex = saved?.nativeSessionId != null && this.host === "codex" && saved.nativeJobId === null;
56
+ if (incompleteCodex && (saved.nativeGoalState !== "inactive" || saved.stopIntent?.confirmedDeadAt == null))
57
+ throw new AgentOpsError("RUN_NATIVE_THREAD_REQUIRED", "Missing Codex thread identity needs confirmed provisional process death before fresh initialization.");
58
+ const handle = saved?.nativeSessionId == null || incompleteCodex ? await this.transport.start(request)
59
+ : await this.transport.resume(request, this.host === "codex" ? saved.nativeJobId : saved.nativeSessionId);
60
+ const identity = handle.processId === null ? null : await this.processIdentity(handle.processId);
61
+ if (identity === null) {
62
+ await this.transport.stop(handle, handle);
63
+ throw new AgentOpsError("RUN_PROCESS_IDENTITY_REQUIRED", "Cannot register an unidentified native process.");
64
+ }
65
+ const entry = { handle, observation: { nativeGoalState: "inactive",
66
+ processAlive: true, processIdentity: identity, stateDigest: null } };
67
+ this.#sessions.set(handle.sessionId, entry);
68
+ // Drain output immediately, but the host has no goal and cannot start a turn yet.
69
+ void this.consume(entry).catch(async () => {
70
+ entry.observation = { ...entry.observation, nativeGoalState: "unknown" };
71
+ await this.repository.appendEvent(handle.runId, { type: "diagnostic", code: "RUN_NATIVE_STREAM_FAILED",
72
+ workerId: handle.workerId, taskId: null, detail: "Native event stream failed; completion remains unproven." }).catch(() => { });
73
+ });
74
+ return { nativeSessionId: handle.sessionId, nativeJobId: handle.threadId, processId: handle.processId,
75
+ processIdentity: identity, instance: handle.nativeVersion };
76
+ }
77
+ async activateRegistered(runId, worker, objective) {
78
+ const current = await this.repository.read(runId);
79
+ const saved = current?.workers.find(w => w.workerId === worker.workerId);
80
+ const entry = worker.nativeSessionId === null ? undefined : this.#sessions.get(worker.nativeSessionId);
81
+ if (current?.status !== "active" || saved?.status !== "running" || saved.generation !== worker.generation ||
82
+ saved.nativeSessionId !== worker.nativeSessionId || saved.stopIntent !== null || entry === undefined)
83
+ throw new AgentOpsError("RUN_WORKER_STALE", "Native activation requires a saved current writer lease.");
84
+ await this.transport.activate(entry.handle, objective, entry.handle);
85
+ }
86
+ async inspect(input) {
87
+ const entry = this.#sessions.get(input.nativeSessionId);
88
+ const identity = input.processId === null ? null : await this.processIdentity(input.processId);
89
+ const groupAlive = input.processId !== null && process.platform !== "win32" && nativeProcessGroupAlive(input.processId);
90
+ const alive = identity !== null || groupAlive;
91
+ if (identity !== null && identity !== input.processIdentity)
92
+ throw new AgentOpsError("RUN_PROCESS_IDENTITY_CHANGED", "A native PID has been reused; preserve the worktree and reconcile ownership.");
93
+ return { ...(entry?.observation ?? { nativeGoalState: "unknown", stateDigest: null }),
94
+ processAlive: alive, processIdentity: identity };
95
+ }
96
+ async resume(_input) {
97
+ throw new AgentOpsError("RUN_RECONCILE_REQUIRED", "Resume requires a new fenced lease and persisted native identity before activation.");
98
+ }
99
+ async send(input) {
100
+ const entry = this.#sessions.get(input.nativeSessionId);
101
+ if (entry === undefined || entry.handle.generation !== input.generation)
102
+ throw new AgentOpsError("RUN_WORKER_STALE", "Cannot update an unowned native session.");
103
+ await this.transport.update(entry.handle, { ...entry.handle, objective: input.message });
104
+ entry.observation = { ...entry.observation, nativeGoalState: "active" };
105
+ }
106
+ async stop(input) {
107
+ const runId = input.runId ?? this.#sessions.get(input.nativeSessionId)?.handle.runId;
108
+ const state = runId === undefined ? null : await this.repository.read(runId);
109
+ const worker = state?.workers.find(w => w.nativeSessionId === input.nativeSessionId && w.generation === input.generation);
110
+ if (worker?.proofProcess != null) {
111
+ const proof = worker.proofProcess;
112
+ await this.stop({ ...input, nativeSessionId: "worker-proof:" + worker.workerId, nativeJobId: null, ...proof });
113
+ await this.repository.mutate(state.runId, current => ({ ...current, workers: current.workers.map(w => w.workerId === worker.workerId &&
114
+ w.generation === input.generation && w.proofProcess?.processId === proof.processId ? { ...w, proofProcess: null } : w) }));
115
+ }
116
+ const entry = this.#sessions.get(input.nativeSessionId);
117
+ if (entry === undefined) {
118
+ const observation = await this.inspect(input);
119
+ if (!observation.processAlive)
120
+ return;
121
+ if (input.processId === null || observation.processIdentity === null || observation.processIdentity !== input.processIdentity)
122
+ throw new AgentOpsError("RUN_PROCESS_RECONCILE_REQUIRED", "Cannot identify surviving native descendants; takeover is refused.");
123
+ // A crash lost the in-memory transport, but the saved PID/start time still identifies this owned group.
124
+ const pid = input.processId;
125
+ await signalNativeProcessGroup(pid, "SIGTERM");
126
+ for (let i = 0; i < 30 && (await this.inspect(input)).processAlive; i++)
127
+ await new Promise(resolve => setTimeout(resolve, 100));
128
+ if ((await this.inspect(input)).processAlive) {
129
+ await signalNativeProcessGroup(pid, "SIGKILL");
130
+ for (let i = 0; i < 30 && (await this.inspect(input)).processAlive; i++)
131
+ await new Promise(resolve => setTimeout(resolve, 100));
132
+ }
133
+ if ((await this.inspect(input)).processAlive)
134
+ throw new AgentOpsError("RUN_WRITER_STILL_ALIVE", "The registered process group has not stopped; takeover is refused.");
135
+ return;
136
+ }
137
+ if (entry.handle.generation !== input.generation)
138
+ throw new AgentOpsError("RUN_WORKER_STALE", "Cannot stop a different writer generation.");
139
+ await this.inspect(input);
140
+ await this.transport.stop(entry.handle, entry.handle);
141
+ entry.observation = { ...entry.observation, processAlive: false, nativeGoalState: "cleared" };
142
+ }
143
+ async consume(entry) {
144
+ for await (const event of this.transport.observe(entry.handle)) {
145
+ const state = await this.repository.read(event.runId);
146
+ const worker = state?.workers.find(w => w.workerId === event.workerId);
147
+ if (worker?.generation !== event.generation || worker.nativeSessionId !== entry.handle.sessionId || worker.status === "fenced") {
148
+ await this.repository.appendEvent(event.runId, { type: "diagnostic", code: "RUN_NATIVE_STALE_EVENT", workerId: event.workerId,
149
+ taskId: null, detail: "Late native event rejected; no usage, authorization or delivery was accepted." });
150
+ continue;
151
+ }
152
+ entry.observation = { ...entry.observation,
153
+ nativeGoalState: ["active", "paused", "complete"].includes(event.nativeStatus)
154
+ ? event.nativeStatus : "unknown",
155
+ ...(event.type === "closed" ? { processAlive: false } : {}) };
156
+ await this.onEvent(event);
157
+ }
158
+ }
159
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import { extractUsage } from "../review/usage.js";
2
+ import { recordUsage } from "./budget.js";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { readReviewReportArtifact, REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
5
+ import { readdir } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ const plain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
8
+ const count = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : null;
9
+ function appendUsage(current, incoming) {
10
+ const usage = recordUsage(current.usage ?? [], incoming);
11
+ const total = (key) => usage.some(u => u[key] !== null)
12
+ ? usage.reduce((sum, u) => sum + (u[key] ?? 0), 0) : null;
13
+ return { ...current, usage, budget: { ...current.budget, usage: { ...current.budget.usage,
14
+ tokens: total("totalTokens"), usd: total("costUsd"), completeness: "partial", source: "native-and-review-epoch-ledger",
15
+ highWaterMark: total("totalTokens") ?? 0 } } };
16
+ }
17
+ /** Copies of a tree report charge each fresh reviewer session only once. */
18
+ export async function recordReviewRunUsage(repository, runId, report) {
19
+ for (const attempt of Array.isArray(report.attempts) ? report.attempts : []) {
20
+ if (typeof attempt.sessionId !== "string" || !plain(attempt.metrics?.usage))
21
+ continue;
22
+ const value = attempt.metrics.usage;
23
+ await repository.mutate(runId, current => appendUsage(current, { source: "review",
24
+ epoch: sha256(attempt.target + ":" + attempt.sessionId), observedAt: report.createdAt,
25
+ inputTokens: value.inputTokens ?? null, outputTokens: value.outputTokens ?? null,
26
+ totalTokens: value.totalTokens ?? null, costUsd: value.costUsd ?? null, completeness: "partial" }));
27
+ }
28
+ }
29
+ export async function recordSavedReviewRunUsage(repository, runId, root) {
30
+ const state = await repository.read(runId);
31
+ if (state === null)
32
+ return;
33
+ let names;
34
+ try {
35
+ names = await readdir(join(root, REVIEW_ATTESTATION_DIRECTORY));
36
+ }
37
+ catch (cause) {
38
+ if (cause.code === "ENOENT")
39
+ return;
40
+ throw cause;
41
+ }
42
+ for (const name of names.filter(name => /^[a-f0-9]{64}\.[a-z][a-z0-9-]{0,127}\.reports\.json$/u.test(name))) {
43
+ const fingerprint = name.slice(0, 64);
44
+ const taskId = name.slice(65, -".reports.json".length);
45
+ const artifact = await readReviewReportArtifact(root, fingerprint, taskId);
46
+ if (artifact !== null && artifact.goalHash === state.goalHash && Date.parse(artifact.createdAt) >= Date.parse(state.createdAt))
47
+ await recordReviewRunUsage(repository, runId, artifact);
48
+ }
49
+ }
50
+ /** Only explicit native counters are consumed. Unknown cost never becomes zero. */
51
+ export async function recordNativeRunUsage(repository, event) {
52
+ if (!plain(event.payload))
53
+ return;
54
+ const state = await repository.read(event.runId);
55
+ const worker = state?.workers.find(w => w.workerId === event.workerId);
56
+ if (state === null || state === undefined || worker === undefined || worker.generation !== event.generation || worker.nativeSessionId === null)
57
+ return;
58
+ let incoming;
59
+ const payload = plain(event.payload.params) ? event.payload.params : event.payload;
60
+ if (event.host === "claude" && event.payload.type === "result" && typeof event.payload.uuid === "string") {
61
+ const usage = extractUsage("claude", JSON.stringify(event.payload), "");
62
+ if (usage !== undefined)
63
+ incoming = { source: "claude", epoch: `${worker.workerId}:${worker.nativeSessionId}:${event.payload.uuid}`,
64
+ inputTokens: usage.inputTokens ?? null, outputTokens: usage.outputTokens ?? null, totalTokens: usage.totalTokens ?? null,
65
+ costUsd: usage.costUsd ?? null, completeness: "partial", observedAt: event.at };
66
+ }
67
+ else if (event.host === "codex" && plain(payload.tokenUsage) && plain(payload.tokenUsage.total)) {
68
+ const usage = payload.tokenUsage.total;
69
+ incoming = { source: "codex", epoch: `${worker.workerId}:${worker.nativeSessionId}:thread`, inputTokens: count(usage.inputTokens),
70
+ outputTokens: count(usage.outputTokens), totalTokens: count(usage.totalTokens), costUsd: null,
71
+ completeness: "partial", observedAt: event.at };
72
+ }
73
+ if (incoming === undefined)
74
+ return;
75
+ await repository.mutate(event.runId, current => {
76
+ const saved = current.workers.find(w => w.workerId === event.workerId);
77
+ if (saved?.generation !== event.generation || saved.nativeSessionId !== worker.nativeSessionId)
78
+ return current;
79
+ return appendUsage(current, incoming);
80
+ });
81
+ }
@@ -0,0 +1,64 @@
1
+ import { join } from "node:path";
2
+ import { canonicalJson } from "../config/hash.js";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { AgentOpsError } from "../fs/paths.js";
5
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
6
+ import { redactSecrets } from "../security/redact.js";
7
+ import { taskContractHash } from "../task/contract.js";
8
+ import { FileEvidenceStore } from "../verify/evidence.js";
9
+ import { deriveFailureObservation } from "./convergence.js";
10
+ /** Preserve actual failed command reports, which are not completion evidence. */
11
+ export async function recordRunVerification(repository, state, workerId, task, report) {
12
+ if (state.policyBinding === undefined)
13
+ return;
14
+ const worker = state.workers.find(w => w.workerId === workerId);
15
+ if (worker === undefined || task.task.id !== report.taskId)
16
+ throw new AgentOpsError("RUN_WORKER_STALE", "Verifier report has no current worker identity.");
17
+ const content = redactSecrets(canonicalJson({ schemaVersion: 1, report, taskContractHash: taskContractHash(task.task),
18
+ policyArtifactDigest: state.policyBinding.artifactDigest }));
19
+ const digest = sha256(content);
20
+ const path = join(state.commonDir, "agent-ops/runs", state.runId, "verification", digest + ".json");
21
+ await repository.mutate(state.runId, async (current) => {
22
+ const saved = current.workers.find(w => w.workerId === workerId);
23
+ if (current.status !== "active" || current.disableRestart || saved?.generation !== worker.generation ||
24
+ current.policyBinding?.artifactDigest !== state.policyBinding.artifactDigest)
25
+ throw new AgentOpsError("RUN_VERIFICATION_STALE", "Verifier observation belongs to a stale run generation or policy.");
26
+ await writePrivateFile(path, content, current.commonDir);
27
+ return { ...current, events: [...current.events, { id: "verify-" + digest, at: new Date().toISOString(), type: "diagnostic",
28
+ code: "RUN_VERIFICATION_OBSERVED", workerId, taskId: report.taskId, detail: canonicalJson({ digest }) }].slice(-2000) };
29
+ });
30
+ }
31
+ /** Read current-contract observations; saved raw source fingerprints never identify failures. */
32
+ export async function observeRunFailure(state, root, records, taskId, code, pendingPinIds) {
33
+ const results = new Map();
34
+ const acceptance = [];
35
+ const evidence = new FileEvidenceStore(root, root);
36
+ for (const record of records.filter(r => r.status !== "archived" && r.supersededBy === undefined)) {
37
+ const events = state.events.filter(e => e.code === "RUN_VERIFICATION_OBSERVED" && e.taskId === record.task.id).reverse();
38
+ for (const event of events) {
39
+ const { digest } = JSON.parse(event.detail);
40
+ if (typeof digest !== "string" || !/^[a-f0-9]{64}$/u.test(digest))
41
+ throw new AgentOpsError("RUN_VERIFICATION_CHANGED", "Verifier observation digest is invalid.");
42
+ const source = await readPrivateFile(join(state.commonDir, "agent-ops/runs", state.runId, "verification", digest + ".json"), state.commonDir);
43
+ if (source === null || sha256(source) !== digest)
44
+ throw new AgentOpsError("RUN_VERIFICATION_CHANGED", "Verifier observation is missing or changed.");
45
+ const saved = JSON.parse(source);
46
+ if (saved.taskContractHash !== taskContractHash(record.task) || saved.policyArtifactDigest !== state.policyBinding?.artifactDigest)
47
+ continue;
48
+ for (const result of saved.report.results)
49
+ results.set(record.task.id + ":" + result.commandId, { ...result, commandId: record.task.id + ":" + result.commandId });
50
+ for (const phase of saved.report.acceptance ?? []) {
51
+ const proof = await evidence.load(phase.reference);
52
+ const criterion = record.task.criteria.find(c => c.id === phase.criterionId);
53
+ if (proof?.acceptance === undefined || proof.taskContractHash !== taskContractHash(record.task) || criterion?.acceptance === undefined || criterion.acceptance.mode === "review-only")
54
+ continue;
55
+ const bindings = criterion.acceptance.bindings.filter(b => b.runnerId === phase.runnerId);
56
+ acceptance.push({ ...phase, criterionId: record.task.id + ":" + phase.criterionId, checks: proof.acceptance.checks,
57
+ requiredCheckIds: bindings.flatMap(b => b.checkIds), redCheckIds: bindings.flatMap(b => b.redCheckIds ?? []) });
58
+ }
59
+ break;
60
+ }
61
+ }
62
+ return deriveFailureObservation({ taskId, status: "FAIL", results: results.size === 0 && acceptance.length === 0 && pendingPinIds.length === 0
63
+ ? [{ commandId: code, required: true, status: "FAIL", failureClass: code }] : [...results.values()], acceptance }, { pendingPinIds });
64
+ }
@@ -0,0 +1,48 @@
1
+ import { readWorktreeRecord } from "../parallel/service.js";
2
+ function blocked(code, remedy) {
3
+ return { action: "block", status: "FAIL", code, remedy };
4
+ }
5
+ /**
6
+ * A worker may stop after its coordinator-issued handoff is fenced. This is a
7
+ * narrow local delivery gate: it does not complete the task, grant root Stop,
8
+ * or accept a native goal's `complete` status as proof.
9
+ */
10
+ export async function validateWorkerStopHandoff(repository, input, recordReader = readWorktreeRecord) {
11
+ const record = await recordReader(input.root);
12
+ if (record === null || record.runId !== input.runId || record.workerId !== input.workerId ||
13
+ record.workerGeneration !== input.generation ||
14
+ (record.agentId !== undefined && input.agentId !== undefined && record.agentId !== input.agentId)) {
15
+ return blocked("RUN_WORKER_HANDOFF_UNREGISTERED", "This Stop is not a coordinator-registered run worker handoff. Preserve the task and use the normal completion gate.");
16
+ }
17
+ const state = await repository.read(input.runId);
18
+ if (state === null)
19
+ return blocked("RUN_WORKER_RUN_MISSING", "The run ledger is unavailable; do not stop a writer without reconciliation.");
20
+ const worker = state.workers.find((candidate) => candidate.workerId === input.workerId);
21
+ if (worker?.workerId === state.coordinatorId)
22
+ return null;
23
+ if (worker === undefined || worker.worktree !== input.root || worker.ownerSessionId !== record.ownerSessionId ||
24
+ (input.sessionId !== worker.ownerSessionId && input.sessionId !== worker.nativeSessionId) ||
25
+ (record.coordinatorId !== undefined && record.coordinatorId !== state.coordinatorId))
26
+ return blocked("RUN_WORKER_HANDOFF_UNREGISTERED", "The native actor does not match this registered child checkout.");
27
+ if (worker === undefined || worker.generation !== input.generation)
28
+ return blocked("RUN_WORKER_GENERATION_STALE", "The worker lease is stale; reconcile the run before stopping this session.");
29
+ const intent = worker.stopIntent;
30
+ if (intent === null || intent.reason !== "handoff" || intent.runId !== state.runId ||
31
+ intent.nativeSessionId !== worker.nativeSessionId || intent.generation !== input.generation || intent.workerId !== input.workerId ||
32
+ (input.deliveryDigest !== undefined && intent.deliveryDigest !== input.deliveryDigest) ||
33
+ (input.contractDigest !== undefined && intent.contractDigest !== input.contractDigest)) {
34
+ return blocked("RUN_WORKER_HANDOFF_REQUIRED", "Native completion is not final proof. Ask the coordinator to create a matching handoff intent first.");
35
+ }
36
+ if (intent.confirmedDeadAt === null && (worker.status !== "handing-off" || Date.parse(intent.expiresAt) <= (input.nowMs ?? Date.now()))) {
37
+ return blocked("RUN_WORKER_HANDOFF_PENDING", "A live child needs a current coordinator-issued handoff intent before Stop.");
38
+ }
39
+ if (!["handing-off", "fenced", "delivered", "stopped"].includes(worker.status))
40
+ return blocked("RUN_WORKER_HANDOFF_PENDING", "Stop requires a fenced handoff; native completion alone is insufficient.");
41
+ return {
42
+ action: "continue",
43
+ status: "PASS",
44
+ code: "RUN_WORKER_HANDOFF_ALLOWED",
45
+ decision: "allow",
46
+ remedy: "Local worker handoff accepted. The task remains active until the coordinator completes final verify, dual review, and receipt proof."
47
+ };
48
+ }
@@ -0,0 +1,91 @@
1
+ const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2
+ const text = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 4096 && !v.includes("\0");
3
+ const id = (v) => typeof v === "string" && /^[a-z][a-z0-9-]{0,127}$/u.test(v);
4
+ const keys = (v, allowed) => Object.keys(v).every(k => allowed.includes(k));
5
+ const dense = (v) => Array.isArray(v) &&
6
+ Array.from({ length: v.length }, (_, i) => Object.hasOwn(v, i)).every(Boolean);
7
+ const strings = (v) => dense(v) && v.length <= 512 && v.every(text) && new Set(v).size === v.length;
8
+ export const commitIdentity = (v) => typeof v === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(v);
9
+ export function acceptanceSourcePath(v) {
10
+ return text(v) && !v.startsWith("/") && !/[\\<>:"|?*\x00-\x1f\x7f]/u.test(v) &&
11
+ v.split("/").every(p => p !== "" && p !== "." && p !== ".." && !/[. ]$/u.test(p) &&
12
+ !/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/iu.test(p) &&
13
+ ![".git", ".agent-ops"].includes(p.toLowerCase()));
14
+ }
15
+ export function materialPath(v) {
16
+ return acceptanceSourcePath(v) && !v.split("/").includes("node_modules") &&
17
+ !/^(?:dist|build|\.tmp)\//u.test(v) &&
18
+ !/^(?:package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.(?:toml|lock)|pyproject\.toml)$/u.test(v);
19
+ }
20
+ export function acceptanceError(v) {
21
+ if (!record(v) || !keys(v, ["mode", "baselineCommit", "bindings", "reviewOnlyReason"]) ||
22
+ !["behavioral", "invariant", "review-only"].includes(String(v.mode)) ||
23
+ !commitIdentity(v.baselineCommit) || !dense(v.bindings) || v.bindings.length > 64)
24
+ return "Acceptance requires a mode, immutable commit SHA and bounded bindings.";
25
+ if (v.mode === "review-only")
26
+ return v.bindings.length === 0 && text(v.reviewOnlyReason)
27
+ ? undefined : "Review-only requires an explicit bounded reason and no mechanical bindings.";
28
+ if (v.reviewOnlyReason !== undefined)
29
+ return "Only review-only contracts carry a fallback reason.";
30
+ if (v.bindings.length === 0)
31
+ return "Mechanical acceptance requires a binding.";
32
+ const identities = new Set();
33
+ let red = 0;
34
+ for (const binding of v.bindings) {
35
+ if (!record(binding) || !keys(binding, ["runnerId", "checkIds", "redCheckIds", "materials"]) ||
36
+ !id(binding.runnerId) || !strings(binding.checkIds) || binding.checkIds.length === 0 ||
37
+ !dense(binding.materials) || binding.materials.length === 0 || binding.materials.length > 512)
38
+ return "Binding requires a runner, unique check IDs and explicit materials.";
39
+ if (binding.redCheckIds !== undefined &&
40
+ (!strings(binding.redCheckIds) || binding.redCheckIds.some(c => !binding.checkIds.includes(c))))
41
+ return "Red checks must be unique members of checkIds.";
42
+ red += binding.redCheckIds?.length ?? 0;
43
+ const paths = new Set();
44
+ for (const material of binding.materials) {
45
+ if (!record(material) || !keys(material, ["path", "role"]) || !materialPath(material.path) ||
46
+ !["test", "fixture", "helper"].includes(String(material.role)) || paths.has(material.path))
47
+ return "Materials must be unique safe relative test, fixture or helper files.";
48
+ paths.add(material.path);
49
+ }
50
+ for (const check of binding.checkIds) {
51
+ const identity = binding.runnerId + "\0" + check;
52
+ if (identities.has(identity))
53
+ return "Duplicate runner/check binding.";
54
+ identities.add(identity);
55
+ }
56
+ }
57
+ if (v.mode === "behavioral" && red === 0)
58
+ return "Behavioral acceptance requires at least one designated red check.";
59
+ if (v.mode === "invariant" && red !== 0)
60
+ return "Invariant acceptance does not designate red checks.";
61
+ return undefined;
62
+ }
63
+ export function runnerError(v) {
64
+ if (!record(v) || !keys(v, ["id", "command", "args", "cwd", "adapter", "timeoutMs", "setup", "build", "testBuild"]) ||
65
+ !id(v.id) || !text(v.command) || !dense(v.args) || v.args.length > 512 ||
66
+ !v.args.every(a => typeof a === "string" && !a.includes("\0") && a.length <= 4096 &&
67
+ (a === "{materials}" || !/[{}]/u.test(a))) ||
68
+ !(v.cwd === "." || materialPath(v.cwd)) ||
69
+ !["generic", "node", "jest", "vitest", "pytest", "rust"].includes(String(v.adapter)))
70
+ return "Acceptance runner requires fixed executable/argv, safe cwd and supported adapter.";
71
+ const timeout = (t) => t === undefined ||
72
+ (Number.isSafeInteger(t) && t > 0 && t <= 3_600_000);
73
+ if (!timeout(v.timeoutMs))
74
+ return "Runner timeout is invalid.";
75
+ for (const phase of ["setup", "build", "testBuild"]) {
76
+ if (v[phase] === undefined)
77
+ continue;
78
+ if (!dense(v[phase]) || v[phase].length > 64)
79
+ return "Runner setup/build must be bounded.";
80
+ for (const step of v[phase]) {
81
+ if (!record(step) || !keys(step, ["command", "args", "timeoutMs"]) || !text(step.command) ||
82
+ !dense(step.args) || step.args.length > 512 ||
83
+ !step.args.every(a => typeof a === "string" && !a.includes("\0") && a.length <= 4096) ||
84
+ !timeout(step.timeoutMs))
85
+ return "Setup/build steps require fixed command/argv and timeout.";
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+ export function isAcceptance(v) { return acceptanceError(v) === undefined; }
91
+ export function isAcceptanceRunner(v) { return runnerError(v) === undefined; }
@@ -1,4 +1,5 @@
1
1
  import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
2
+ import { acceptanceError, runnerError, commitIdentity } from "./acceptance.js";
2
3
  const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
3
4
  const HASH_PATTERN = /^[a-f0-9]{64}$/;
4
5
  const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
@@ -356,7 +357,7 @@ export function validateConfig(value) {
356
357
  if (!isRecord(root.verification)) {
357
358
  return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
358
359
  }
359
- const verificationUnknown = unknownFieldFailure(root.verification, ["commands"], "$.verification");
360
+ const verificationUnknown = unknownFieldFailure(root.verification, ["commands", "acceptanceRunners"], "$.verification");
360
361
  if (verificationUnknown !== undefined) {
361
362
  return verificationUnknown;
362
363
  }
@@ -374,6 +375,20 @@ export function validateConfig(value) {
374
375
  }
375
376
  commandIds.add(command.value.id);
376
377
  }
378
+ if (root.verification.acceptanceRunners !== undefined) {
379
+ if (!Array.isArray(root.verification.acceptanceRunners) || root.verification.acceptanceRunners.length > 128)
380
+ return failure("INVALID_ACCEPTANCE_RUNNER", "$.verification.acceptanceRunners", "Expected bounded runner array.");
381
+ const runnerIds = new Set();
382
+ for (const runner of root.verification.acceptanceRunners) {
383
+ const issue = runnerError(runner);
384
+ if (issue !== undefined)
385
+ return failure("INVALID_ACCEPTANCE_RUNNER", "$.verification.acceptanceRunners", issue);
386
+ const runnerId = runner.id;
387
+ if (runnerIds.has(runnerId) || commandIds.has(runnerId))
388
+ return failure("DUPLICATE_ID", "$.verification.acceptanceRunners", "Runner IDs must be unique and distinct from policy commands.");
389
+ runnerIds.add(runnerId);
390
+ }
391
+ }
377
392
  if (root.features.stopVerification.enabled === true &&
378
393
  root.verification.commands.length === 0) {
379
394
  return failure("STOP_VERIFICATION_COMMANDS_REQUIRED", "$.features.stopVerification.enabled", "Stop verification requires at least one verification command.");
@@ -498,11 +513,11 @@ function validateReviewRole(value, path) {
498
513
  }
499
514
  return success(value);
500
515
  }
501
- function validateCriterion(value, path) {
516
+ export function validateCriterion(value, path) {
502
517
  if (!isRecord(value)) {
503
518
  return failure("INVALID_TYPE", path, "Expected a criterion object.");
504
519
  }
505
- const unknown = unknownFieldFailure(value, ["description", "id", "verifierIds"], path);
520
+ const unknown = unknownFieldFailure(value, ["description", "id", "verifierIds", "acceptance", "finding"], path);
506
521
  if (unknown !== undefined) {
507
522
  return unknown;
508
523
  }
@@ -513,20 +528,43 @@ function validateCriterion(value, path) {
513
528
  return failure("INVALID_DESCRIPTION", `${path}.description`, "Criterion description is required.");
514
529
  }
515
530
  if (!isStringArray(value.verifierIds) ||
516
- value.verifierIds.length === 0 ||
531
+ (value.verifierIds.length === 0 && value.acceptance === undefined) ||
517
532
  !value.verifierIds.every(isIdentifier)) {
518
533
  return failure("INVALID_VERIFIER_REFERENCE", `${path}.verifierIds`, "A criterion requires at least one valid verifier ID.");
519
534
  }
520
535
  if (!hasUniqueStrings(value.verifierIds)) {
521
536
  return failure("DUPLICATE_ID", `${path}.verifierIds`, "Criterion verifier IDs must be unique.");
522
537
  }
538
+ if (value.acceptance !== undefined) {
539
+ const issue = acceptanceError(value.acceptance);
540
+ if (issue !== undefined)
541
+ return failure("INVALID_ACCEPTANCE", path + ".acceptance", issue);
542
+ }
543
+ if (value.finding !== undefined) {
544
+ const f = value.finding;
545
+ if (!isRecord(f) || unknownFieldFailure(f, ["pinId", "reportDigest", "findingIndex", "candidateCommit", "criterionIds"], path + ".finding") !== undefined ||
546
+ typeof f.pinId !== "string" || !HASH_PATTERN.test(f.pinId) || typeof f.reportDigest !== "string" || !HASH_PATTERN.test(f.reportDigest) ||
547
+ !Number.isSafeInteger(f.findingIndex) || f.findingIndex < 0 || !commitIdentity(f.candidateCommit) ||
548
+ !isStringArray(f.criterionIds) || !f.criterionIds.every(id => typeof id === "string" && id.split(":").length <= 2 && id.split(":").every(isIdentifier)) || !hasUniqueStrings(f.criterionIds))
549
+ return failure("INVALID_FINDING_REFERENCE", path + ".finding", "Invalid immutable finding identity.");
550
+ if (value.acceptance === undefined || !isRecord(value.acceptance) || value.acceptance.mode === "invariant" ||
551
+ value.acceptance.baselineCommit !== f.candidateCommit)
552
+ return failure("INVALID_FINDING_BASELINE", path + ".finding", "A finding retains its failed-candidate baseline and behavioral or explicit review-only mode.");
553
+ }
523
554
  return success(value);
524
555
  }
525
556
  export function validateTask(value) {
526
- const root = validateRoot(value, ["criteria", "id", "intent", "parentTaskId", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
557
+ const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
527
558
  if (isFailure(root)) {
528
559
  return root;
529
560
  }
561
+ if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
562
+ return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
563
+ if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
564
+ return failure("INVALID_GOAL", "$.goal", "Goal must be non-empty and at most 64 KiB.");
565
+ if (root.schemaVersion === 1 && (root.goal !== undefined || root.contractRevision !== undefined ||
566
+ (Array.isArray(root.criteria) && root.criteria.some(c => isRecord(c) && (c.acceptance !== undefined || c.finding !== undefined)))))
567
+ return failure("WRONG_SCHEMA_VERSION", "$.schemaVersion", "Typed acceptance requires task schema 2.");
530
568
  if (!isIdentifier(root.id)) {
531
569
  return failure("INVALID_ID", "$.id", "Invalid task ID.");
532
570
  }
@@ -578,6 +616,13 @@ export function validateTaskAgainstConfig(taskValue, configValue) {
578
616
  return failure("UNKNOWN_VERIFIER_REFERENCE", `$.criteria[${criterionIndex}].verifierIds`, `Unknown verifier reference: ${unknownReference}`);
579
617
  }
580
618
  }
619
+ const runnerIds = new Set(config.value.verification.acceptanceRunners?.map(r => r.id) ?? []);
620
+ for (const criterion of task.value.criteria) {
621
+ for (const binding of criterion.acceptance?.bindings ?? []) {
622
+ if (!runnerIds.has(binding.runnerId))
623
+ return failure("UNKNOWN_ACCEPTANCE_RUNNER", "$.criteria", "Unknown acceptance runner: " + binding.runnerId);
624
+ }
625
+ }
581
626
  return task;
582
627
  }
583
628
  export function validateEvidence(value) {
@@ -597,11 +642,26 @@ export function validateEvidence(value) {
597
642
  "status",
598
643
  "taskId",
599
644
  "testCount",
600
- "toolVersions"
601
- ], isRecord(value) && value.schemaVersion === 2 ? 2 : EVIDENCE_SCHEMA_VERSION);
645
+ "toolVersions", "taskContractHash", "acceptance"
646
+ ], isRecord(value) && (value.schemaVersion === 2 || value.schemaVersion === 3) ? value.schemaVersion : EVIDENCE_SCHEMA_VERSION);
602
647
  if (isFailure(root)) {
603
648
  return root;
604
649
  }
650
+ if (root.taskContractHash !== undefined && (root.schemaVersion !== 4 || typeof root.taskContractHash !== "string" || !HASH_PATTERN.test(root.taskContractHash)))
651
+ return failure("INVALID_CONTRACT_HASH", "$.taskContractHash", "Contract evidence requires schema 4 and a digest.");
652
+ if (root.acceptance !== undefined) {
653
+ const a = root.acceptance;
654
+ if (root.schemaVersion !== 4 || !isRecord(a) || root.taskContractHash === undefined ||
655
+ unknownFieldFailure(a, ["criterionContractHash", "phase", "commit", "pairedCommit", "bindingHash", "materialDigest", "executionDigest", "executionArtifact", "checks"], "$.acceptance") !== undefined ||
656
+ !["baseline", "candidate"].includes(String(a.phase)) || !commitIdentity(a.commit) || !commitIdentity(a.pairedCommit) ||
657
+ ![a.criterionContractHash, a.bindingHash, a.materialDigest, a.executionDigest].every(h => typeof h === "string" && HASH_PATTERN.test(h)) ||
658
+ typeof a.executionArtifact !== "string" || !/^\.agent-ops\/tasks\/acceptance\/[a-f0-9]{64}\.json$/u.test(a.executionArtifact) ||
659
+ !Array.isArray(a.checks) || a.checks.length === 0 || a.checks.length > 512 ||
660
+ a.checks.some(c => !isRecord(c) || unknownFieldFailure(c, ["checkId", "status", "failureClass"], "$.acceptance.checks") !== undefined ||
661
+ !isNonEmptyString(c.checkId) || !["PASS", "FAIL", "UNKNOWN"].includes(String(c.status)) || !isNonEmptyString(c.failureClass)) ||
662
+ new Set(a.checks.map(c => c.checkId)).size !== a.checks.length)
663
+ return failure("INVALID_ACCEPTANCE_EVIDENCE", "$.acceptance", "Invalid phase/check evidence.");
664
+ }
605
665
  for (const field of ["taskId", "criterionId", "commandId"]) {
606
666
  if (!isIdentifier(root[field])) {
607
667
  return failure("INVALID_ID", `$.${field}`, `Invalid ${field}.`);