@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
@@ -2,12 +2,16 @@ import { calculateConfigHash } from "../config/hash.js";
2
2
  import { findReviewAttestation } from "../review/attestation.js";
3
3
  import { validateEvidence, validateTaskAgainstConfig } from "../schema/validate.js";
4
4
  import { isPassingVerificationEvidence } from "../verify/evidence.js";
5
+ import { taskContractHash } from "./contract.js";
6
+ import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
5
7
  export async function checkTaskCompletionEvidence(stored, options) {
6
8
  const problem = await checkTaskVerificationEvidence(stored, options);
7
9
  if (problem !== null)
8
10
  return problem;
9
11
  const attestation = await findReviewAttestation(options.root, options.sourceFingerprint, stored.task.id);
10
12
  if (attestation === null || attestation.taskId !== stored.task.id ||
13
+ ((stored.task.criteria.some(c => c.acceptance !== undefined) || (stored.task.contractRevision ?? 0) > 0 || stored.task.goal !== undefined) &&
14
+ (attestation.schemaVersion !== 3 || attestation.taskContracts?.[stored.task.id] !== taskContractHash(stored.task))) ||
11
15
  (attestation.tree !== undefined && stored.task.criteria.some((criterion) => !attestation.tree.criterionIds.includes(`${stored.task.id}:${criterion.id}`)))) {
12
16
  return { status: "FAIL", code: "REVIEW_REQUIRED", remedy: `Run agent-ops review --task ${stored.task.id} --yes for the whole task and current source.` };
13
17
  }
@@ -24,8 +28,9 @@ export async function checkTaskVerificationEvidence(stored, options) {
24
28
  return { status: "FAIL", code: "TASK_STALE", remedy: "Recreate the task against the current config, then verify and review it." };
25
29
  }
26
30
  for (const criterion of stored.task.criteria) {
27
- const commands = config.verification.commands.filter(({ id, required }) => criterion.verifierIds.includes(id) && required);
28
- if (commands.length === 0) {
31
+ const commands = config.verification.commands.filter(({ id, required }) => required &&
32
+ (criterion.acceptance !== undefined || criterion.verifierIds.includes(id)));
33
+ if (commands.length === 0 && criterion.acceptance === undefined) {
29
34
  return { status: "FAIL", code: "REQUIRED_VERIFIER_MISSING", remedy: "Configure a required verifier for every criterion, then recreate, verify and review the task." };
30
35
  }
31
36
  for (const command of commands) {
@@ -42,6 +47,7 @@ export async function checkTaskVerificationEvidence(stored, options) {
42
47
  if (evidence.taskId === stored.task.id && evidence.criterionId === criterion.id &&
43
48
  evidence.commandId === command.id && evidence.configHash === configHash &&
44
49
  evidence.sourceFingerprint === sourceFingerprint &&
50
+ ((stored.task.goal === undefined && (stored.task.contractRevision ?? 0) === 0 && stored.task.criteria.every(c => c.acceptance === undefined)) || evidence.taskContractHash === taskContractHash(stored.task)) &&
45
51
  (latest === undefined || Date.parse(evidence.finishedAt) > Date.parse(latest.finishedAt) ||
46
52
  (Date.parse(evidence.finishedAt) === Date.parse(latest.finishedAt) && !isPassingVerificationEvidence(command, evidence)))) {
47
53
  latest = evidence;
@@ -52,6 +58,10 @@ export async function checkTaskVerificationEvidence(stored, options) {
52
58
  }
53
59
  }
54
60
  }
61
+ const coverage = await acceptanceCoverage(stored, config, sourceFingerprint, evidenceStore);
62
+ if (coverage.some(row => (row.mode === "behavioral" || row.mode === "invariant") && row.status !== "proven")) {
63
+ return { status: "FAIL", code: "ACCEPTANCE_EVIDENCE_REQUIRED", remedy: "Run current paired acceptance checks; UNKNOWN and non-discriminating checks do not prove a criterion." };
64
+ }
55
65
  return null;
56
66
  }
57
67
  /** Include descendants so legacy completed children cannot hide unfinished work. */
@@ -61,6 +71,8 @@ export function findIncompleteSubtask(records, taskId) {
61
71
  while (previousSize !== descendants.size) {
62
72
  previousSize = descendants.size;
63
73
  for (const record of records) {
74
+ if (record.supersededBy !== undefined)
75
+ continue;
64
76
  if (record.task.parentTaskId !== undefined && descendants.has(record.task.parentTaskId)) {
65
77
  descendants.add(record.task.id);
66
78
  if (record.status === "active" || record.completedAt === null || record.failureFingerprint !== null)
@@ -0,0 +1,17 @@
1
+ import { canonicalJson } from "../config/hash.js";
2
+ import { sha256 } from "../fs/hash.js";
3
+ export function goalHash(task) {
4
+ return sha256(task.goal ?? task.intent ?? task.title);
5
+ }
6
+ export function criterionContractHash(criterion) {
7
+ return sha256(canonicalJson(criterion));
8
+ }
9
+ export function taskContractHash(task) {
10
+ return sha256(canonicalJson({ goalHash: goalHash(task), title: task.title,
11
+ intent: task.intent ?? null, revision: task.contractRevision ?? 0,
12
+ criteria: task.criteria.map(criterionContractHash) }));
13
+ }
14
+ export function treeContractHash(tasks) {
15
+ return sha256(canonicalJson(tasks.map(task => ({ id: task.id, hash: taskContractHash(task) }))
16
+ .sort((a, b) => a.id.localeCompare(b.id))));
17
+ }
@@ -0,0 +1,81 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { AgentOpsError } from "../fs/paths.js";
4
+ import { sha256 } from "../fs/hash.js";
5
+ import { readPrivateFile } from "../security/permissions.js";
6
+ import { reviewReportDigest, REVIEW_ATTESTATION_DIRECTORY, REVIEW_REPORT_ARTIFACT_SUFFIX } from "../review/attestation.js";
7
+ import { validateReviewReport } from "../review/report.js";
8
+ import { validateCriterion } from "../schema/validate.js";
9
+ import { commitIdentity } from "../schema/acceptance.js";
10
+ import { goalHash, taskContractHash } from "./contract.js";
11
+ export async function loadFindingPin(root, record, reference, input, records = [record]) {
12
+ const checked = validateCriterion(input.finding !== undefined && input.acceptance !== undefined
13
+ ? { ...input, acceptance: { ...input.acceptance, baselineCommit: input.finding.candidateCommit } } : input, "$.criterion");
14
+ if (!checked.ok)
15
+ throw new AgentOpsError("TASK_PIN_CRITERION_INVALID", "Pin requires a valid acceptance criterion.");
16
+ const match = reference.match(/^([a-f0-9]{64}):(0|[1-9][0-9]{0,2})$/u);
17
+ if (match === null)
18
+ throw new AgentOpsError("TASK_FINDING_REFERENCE_INVALID", "Finding reference must be report digest:finding index.");
19
+ const digest = match[1];
20
+ const index = Number(match[2]);
21
+ const directory = join(root, ...REVIEW_ATTESTATION_DIRECTORY.split("/"));
22
+ let names;
23
+ try {
24
+ names = await readdir(directory);
25
+ }
26
+ catch {
27
+ names = [];
28
+ }
29
+ const hashes = new Set([taskContractHash(record.task), ...(record.revisions ?? []).flatMap(r => [r.previousHash, r.currentHash])]);
30
+ for (const name of names.filter(n => /^[a-f0-9]{64}(?:\.[a-z][a-z0-9-]{0,127})?\.reports\.json$/u.test(n)).sort()) {
31
+ const source = await readPrivateFile(join(directory, name), root);
32
+ if (source === null || Buffer.byteLength(source) > 512 * 1024)
33
+ continue;
34
+ let artifact;
35
+ try {
36
+ artifact = JSON.parse(source);
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ if (typeof artifact !== "object" || artifact === null || Array.isArray(artifact))
42
+ continue;
43
+ if (artifact.tree !== undefined && (typeof artifact.tree !== "object" || artifact.tree === null ||
44
+ !Array.isArray(artifact.tree.taskIds) || typeof artifact.tree.rootTaskId !== "string"))
45
+ continue;
46
+ if (artifact.schemaVersion !== 2 || artifact.status !== "FAIL" || !commitIdentity(artifact.candidateCommit) ||
47
+ artifact.taskContracts?.[record.task.id] === undefined || !hashes.has(artifact.taskContracts[record.task.id]) ||
48
+ name !== artifact.sourceFingerprint + (artifact.taskId === undefined ? "" : "." + artifact.taskId) + REVIEW_REPORT_ARTIFACT_SUFFIX)
49
+ continue;
50
+ const owner = artifact.tree === undefined ? record : records.find(r => r.task.id === artifact.tree.rootTaskId);
51
+ if (owner === undefined || artifact.goalHash !== goalHash(owner.task) ||
52
+ (artifact.tree === undefined ? artifact.taskId !== record.task.id : !artifact.tree.taskIds.includes(record.task.id)))
53
+ continue;
54
+ for (const report of [artifact.report, artifact.adversarial?.report]) {
55
+ if (typeof report !== "object" || report === null || !Array.isArray(report.results) ||
56
+ report.results.some(r => typeof r !== "object" || r === null || typeof r.criterionId !== "string"))
57
+ continue;
58
+ const valid = validateReviewReport(report, report.results.map(r => r.criterionId));
59
+ if (!valid.ok || reviewReportDigest(valid.value) !== digest || index >= valid.value.findings.length)
60
+ continue;
61
+ const finding = valid.value.findings[index];
62
+ if (input.acceptance === undefined || input.acceptance.mode === "invariant")
63
+ throw new AgentOpsError("TASK_PIN_ACCEPTANCE_REQUIRED", "Pinned findings require behavioral red checks or an explicit review-only contract.");
64
+ const pinId = sha256(digest + ":" + index);
65
+ const requestedPin = input.finding?.pinId;
66
+ const existing = record.task.criteria.find(c => c.finding?.pinId === (requestedPin ?? pinId));
67
+ if (requestedPin !== undefined && existing === undefined)
68
+ throw new AgentOpsError("TASK_PIN_UNKNOWN", "Explicit recurrence must name an existing pin ID.");
69
+ if (existing !== undefined && requestedPin === undefined) {
70
+ if (input.id !== existing.id)
71
+ throw new AgentOpsError("TASK_PIN_EXISTS", "This finding already has a criterion; name its pin ID to record recurrence.");
72
+ return structuredClone(existing);
73
+ }
74
+ return { ...structuredClone(input), id: existing?.id ?? input.id,
75
+ acceptance: { ...input.acceptance, baselineCommit: existing?.acceptance?.baselineCommit ?? artifact.candidateCommit },
76
+ finding: existing?.finding ?? { pinId, reportDigest: digest, findingIndex: index,
77
+ candidateCommit: artifact.candidateCommit, criterionIds: [...finding.criterionIds] } };
78
+ }
79
+ }
80
+ throw new AgentOpsError("TASK_FINDING_NOT_FOUND", "No saved failed report binds this finding to the task's goal, contract and candidate commit.");
81
+ }
@@ -1,13 +1,19 @@
1
+ import { canonicalJson } from "../config/hash.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { TASK_SCHEMA_VERSION } from "../contracts.js";
3
4
  import { AgentOpsError } from "../fs/paths.js";
4
- import { validateEvidence, validateTask } from "../schema/validate.js";
5
+ import { validateEvidence, validateTask, validateTaskAgainstConfig } from "../schema/validate.js";
5
6
  import { renderTaskMarkdown } from "./render.js";
6
7
  import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
7
8
  import { calculateConfigHash, FileEvidenceStore, isPassingVerificationEvidence } from "../verify/evidence.js";
8
9
  import { resolveReviewScope } from "../review/scope.js";
9
10
  import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
11
+ import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
12
+ import { loadFindingPin } from "./pin-finding.js";
13
+ import { findReviewAttestation } from "../review/attestation.js";
10
14
  import { advanceFailureFingerprint } from "../verify/fingerprint.js";
15
+ import { taskContractHash, treeContractHash } from "./contract.js";
16
+ import { commitIdentity } from "../schema/acceptance.js";
11
17
  const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
12
18
  function defaultTaskId() {
13
19
  return `task-${randomUUID()}`;
@@ -86,12 +92,25 @@ export class TaskService {
86
92
  if (input.sessionId !== undefined) {
87
93
  assertSessionId(input.sessionId);
88
94
  }
95
+ let createdSourceCommit = input.createdSourceCommit;
96
+ if (createdSourceCommit === undefined && this.#completion !== undefined) {
97
+ const head = await this.#completion.gitRunner.run(["rev-parse", "--verify", "HEAD"]);
98
+ const resolved = head.stdout.toString().trim();
99
+ if (head.exitCode === 0 && commitIdentity(resolved))
100
+ createdSourceCommit = resolved;
101
+ }
102
+ if (createdSourceCommit !== undefined && !commitIdentity(createdSourceCommit))
103
+ throw taskError("TASK_BASELINE_INVALID", "Task creation commit must be a resolved SHA.");
104
+ const criteria = structuredClone(input.criteria).map(criterion => ({ ...criterion,
105
+ ...(criterion.acceptance === undefined ? {} : { acceptance: { ...criterion.acceptance,
106
+ baselineCommit: criterion.acceptance.baselineCommit ?? createdSourceCommit } }) }));
89
107
  const task = {
90
108
  schemaVersion: TASK_SCHEMA_VERSION,
91
109
  id: this.#generateId(),
92
110
  title: input.title,
93
111
  ...(input.intent === undefined ? {} : { intent: input.intent }),
94
- criteria: [...input.criteria],
112
+ criteria,
113
+ ...(input.goal === undefined ? {} : { goal: input.goal }),
95
114
  ...(input.parentTaskId === undefined
96
115
  ? {}
97
116
  : { parentTaskId: input.parentTaskId })
@@ -100,6 +119,16 @@ export class TaskService {
100
119
  if (!validation.ok) {
101
120
  throw taskError("TASK_INVALID", validation.errors[0]?.message ?? "Task input is invalid.");
102
121
  }
122
+ if (validation.value.criteria.some(c => c.finding !== undefined))
123
+ throw taskError("TASK_PIN_REQUIRED", "New finding identities must be validated with pin-finding against a saved failed review.");
124
+ if (validation.value.criteria.some(c => c.acceptance !== undefined)) {
125
+ if (this.#completion === undefined)
126
+ throw taskError("TASK_BASELINE_CONTEXT_REQUIRED", "Typed acceptance requires repository context.");
127
+ const configured = validateTaskAgainstConfig(validation.value, await this.#completion.loadConfig());
128
+ if (!configured.ok)
129
+ throw taskError("TASK_INVALID", configured.errors[0]?.message ?? "Invalid runner binding.");
130
+ await this.#retainBaselines(validation.value);
131
+ }
103
132
  const now = assertTimestamp(this.#now());
104
133
  return await this.#store.mutate((state) => {
105
134
  if (state.tasks.some((record) => record.task.id === validation.value.id)) {
@@ -115,9 +144,15 @@ export class TaskService {
115
144
  if (parent.status !== "active") {
116
145
  throw taskError("TASK_PARENT_NOT_ACTIVE", "Only an active task can take new subtasks.");
117
146
  }
147
+ if (parent.task.goal !== undefined) {
148
+ if (validation.value.goal !== undefined && validation.value.goal !== parent.task.goal)
149
+ throw taskError("TASK_GOAL_IMMUTABLE", "Subtasks must retain their parent original goal.");
150
+ validation.value.goal = parent.task.goal;
151
+ }
118
152
  }
119
153
  const record = {
120
154
  task: validation.value,
155
+ ...(createdSourceCommit === undefined ? {} : { createdSourceCommit }),
121
156
  status: "active",
122
157
  evidence: {},
123
158
  createdAt: now,
@@ -154,9 +189,183 @@ export class TaskService {
154
189
  return cloneRecord(record);
155
190
  });
156
191
  }
192
+ async #retainBaselines(task) {
193
+ if (this.#completion === undefined)
194
+ throw taskError("TASK_BASELINE_CONTEXT_REQUIRED", "Baseline retention needs Git context.");
195
+ for (const criterion of task.criteria) {
196
+ const sha = criterion.acceptance?.baselineCommit;
197
+ if (sha === undefined)
198
+ continue;
199
+ const result = await this.#completion.gitRunner.run(["rev-parse", "--verify", sha + "^{commit}"]);
200
+ if (result.exitCode !== 0 || result.stdout.toString().trim() !== sha)
201
+ throw taskError("BASELINE_UNAVAILABLE", "Criterion baseline is not the exact available commit.");
202
+ const retained = await this.#completion.gitRunner.run(["update-ref", "refs/agent-ops/baselines/" + task.id + "/" + criterion.id + "/" + sha, sha]);
203
+ if (retained.exitCode !== 0)
204
+ throw taskError("BASELINE_RETENTION_FAILED", "Could not retain criterion baseline.");
205
+ }
206
+ }
207
+ async revise(taskId, input) {
208
+ if (!/^[a-f0-9]{64}$/u.test(input.expectedContractHash) || input.reason.trim() === "" || input.reason.length > 8192 || input.reason.includes("\0") ||
209
+ (input.diagnostics ?? []).some(d => d.length > 4096 || d.includes("\0")))
210
+ throw taskError("TASK_REVISION_INVALID", "Revision requires expected contract and bounded reason/diagnostics.");
211
+ const previous = await this.status({ taskId });
212
+ if (previous.status !== "active")
213
+ throw taskError("TASK_NOT_ACTIVE", "Only active tasks can revise acceptance.");
214
+ if (taskContractHash(previous.task) !== input.expectedContractHash)
215
+ throw taskError("TASK_CONTRACT_CHANGED", "Task contract changed.");
216
+ const baseline = input.baseline ?? previous.createdSourceCommit;
217
+ const criteria = structuredClone(input.criteria).map(criterion => {
218
+ if (criterion.acceptance === undefined)
219
+ return criterion;
220
+ const sha = criterion.acceptance.baselineCommit ?? baseline;
221
+ if (sha === undefined)
222
+ throw taskError("BASELINE_REQUIRED", "Legacy tasks need an explicit baseline before typed acceptance.");
223
+ const old = previous.task.criteria.find(c => c.id === criterion.id)?.acceptance;
224
+ if (old !== undefined && sha !== old.baselineCommit)
225
+ throw taskError("BASELINE_IMMUTABLE", "Create a replacement criterion instead of changing its baseline.");
226
+ return { ...criterion, acceptance: { ...criterion.acceptance, baselineCommit: sha } };
227
+ });
228
+ const next = { ...previous.task, schemaVersion: TASK_SCHEMA_VERSION,
229
+ contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria };
230
+ const checked = validateTask(next);
231
+ if (!checked.ok)
232
+ throw taskError("TASK_INVALID", checked.errors[0]?.message ?? "Invalid criterion revision.");
233
+ const records = (await this.#store.read()).tasks;
234
+ for (const criterion of next.criteria)
235
+ await this.#validateFindingSource(previous, criterion, records);
236
+ let policyConfigHash = previous.policyConfigHash;
237
+ if (this.#completion !== undefined) {
238
+ const config = await this.#completion.loadConfig();
239
+ const configured = validateTaskAgainstConfig(next, config);
240
+ if (!configured.ok)
241
+ throw taskError("TASK_INVALID", configured.errors[0]?.message ?? "Invalid binding.");
242
+ policyConfigHash = calculateConfigHash(config);
243
+ }
244
+ if (criteria.some(c => c.acceptance !== undefined))
245
+ await this.#retainBaselines(next);
246
+ const now = assertTimestamp(this.#now());
247
+ return await this.#store.mutate(state => {
248
+ const current = findTask(state, taskId);
249
+ if (current.status !== "active" || JSON.stringify(current) !== JSON.stringify(previous))
250
+ throw taskError("TASK_CONTRACT_CHANGED", "Concurrent revision preserved; retry from current task.");
251
+ const result = { ...current, task: next, evidence: {}, failureFingerprint: null,
252
+ policyConfigHash, completionBase: null, updatedAt: now,
253
+ revisions: [...(current.revisions ?? []), { previousTask: current.task, previousEvidence: current.evidence,
254
+ previousFailure: current.failureFingerprint, previousHash: input.expectedContractHash,
255
+ currentHash: taskContractHash(next), reason: input.reason, diagnostics: [...(input.diagnostics ?? [])], at: now }] };
256
+ const { reviewBase: _reviewBase, noChangePaths: _paths, ...withoutReview } = result;
257
+ replaceTask(state, withoutReview);
258
+ return cloneRecord(withoutReview);
259
+ });
260
+ }
261
+ async replan(taskId, input) {
262
+ if (input.reason.trim() === "" || input.reason.includes("\0") || input.reason.length > 8192 ||
263
+ input.tasks.length === 0 || input.tasks.length > 128)
264
+ throw taskError("TASK_REPLAN_INVALID", "Replan requires bounded children and a reason.");
265
+ const snapshot = await this.#store.read();
266
+ const root = findTask(snapshot, taskId);
267
+ if (root.status !== "active")
268
+ throw taskError("TASK_NOT_ACTIVE", "Only active roots can be replanned.");
269
+ const ids = new Set([taskId]);
270
+ for (let n = 0; n !== ids.size;) {
271
+ n = ids.size;
272
+ for (const r of snapshot.tasks)
273
+ if (r.supersededBy === undefined && r.task.parentTaskId !== undefined && ids.has(r.task.parentTaskId))
274
+ ids.add(r.task.id);
275
+ }
276
+ const previous = snapshot.tasks.filter(r => ids.has(r.task.id) && r.supersededBy === undefined);
277
+ if (treeContractHash(previous.map(r => r.task)) !== input.expectedTreeContractHash)
278
+ throw taskError("TASK_CONTRACT_CHANGED", "Task tree changed.");
279
+ if (previous.some(r => r.status !== "active"))
280
+ throw taskError("TASK_REPLAN_INVALID", "Replan cannot replace completed or archived contracts.");
281
+ if (input.tasks.reduce((count, t) => count + t.replaces.length, 0) > 512)
282
+ throw taskError("TASK_REPLAN_MAPPING_INVALID", "Replan provenance exceeds its bounded history limit.");
283
+ const sources = new Map(previous.flatMap(r => r.task.criteria.map(c => [r.task.id + ":" + c.id, c])));
284
+ const covered = new Set();
285
+ const now = assertTimestamp(this.#now());
286
+ const children = [];
287
+ const config = this.#completion === undefined ? undefined : await this.#completion.loadConfig();
288
+ for (const planned of input.tasks) {
289
+ if (planned.replaces.length === 0 || new Set(planned.replaces).size !== planned.replaces.length ||
290
+ planned.replaces.some(ref => !sources.has(ref)))
291
+ throw taskError("TASK_REPLAN_MAPPING_INVALID", "Every child must reference existing criterion identities.");
292
+ for (const ref of planned.replaces)
293
+ covered.add(ref);
294
+ for (const c of planned.criteria) {
295
+ const originals = planned.replaces.map(ref => sources.get(ref)).filter(old => old.id === c.id);
296
+ if (originals.some(original => original.acceptance !== undefined && c.acceptance?.baselineCommit !== original.acceptance.baselineCommit))
297
+ throw taskError("BASELINE_IMMUTABLE", "Replan must preserve criterion baselines.");
298
+ }
299
+ for (const criterion of planned.criteria) {
300
+ if (criterion.finding === undefined)
301
+ continue;
302
+ const inherited = planned.replaces.map(ref => sources.get(ref)).find(c => c.finding?.pinId === criterion.finding.pinId);
303
+ if (inherited !== undefined) {
304
+ if (canonicalJson(inherited.finding) !== canonicalJson(criterion.finding))
305
+ throw taskError("TASK_PIN_IMMUTABLE", "Replan must retain immutable finding identity.");
306
+ }
307
+ else {
308
+ let authorized = false;
309
+ for (const record of previous.filter(r => planned.replaces.some(ref => ref.startsWith(r.task.id + ":")))) {
310
+ try {
311
+ await this.#validateFindingSource(record, criterion, snapshot.tasks);
312
+ authorized = true;
313
+ break;
314
+ }
315
+ catch { }
316
+ }
317
+ if (!authorized)
318
+ throw taskError("TASK_PIN_REQUIRED", "New replan pins require a saved failed report bound to the replaced contracts.");
319
+ }
320
+ }
321
+ for (const ref of planned.replaces) {
322
+ const pin = sources.get(ref).finding?.pinId;
323
+ if (pin !== undefined && !planned.criteria.some(c => c.finding?.pinId === pin))
324
+ throw taskError("TASK_REPLAN_PIN_REQUIRED", "Split contracts must preserve mapped regression pin identities.");
325
+ }
326
+ const task = { schemaVersion: TASK_SCHEMA_VERSION, id: planned.id ?? this.#generateId(),
327
+ title: planned.title, intent: planned.intent, goal: root.task.goal ?? root.task.intent ?? root.task.title,
328
+ criteria: structuredClone(planned.criteria), parentTaskId: taskId };
329
+ const checked = config === undefined ? validateTask(task) : validateTaskAgainstConfig(task, config);
330
+ if (!checked.ok)
331
+ throw taskError("TASK_INVALID", checked.errors[0]?.message ?? "Invalid split task.");
332
+ if (task.criteria.some(c => c.acceptance !== undefined))
333
+ await this.#retainBaselines(task);
334
+ children.push({ task, status: "active", evidence: {}, createdAt: now, updatedAt: now,
335
+ completedAt: null, archivedAt: null, failureFingerprint: null, completionBase: null,
336
+ policyConfigHash: config === undefined ? root.policyConfigHash : calculateConfigHash(config),
337
+ ...(root.createdSourceCommit === undefined ? {} : { createdSourceCommit: root.createdSourceCommit }) });
338
+ }
339
+ if ([...sources.keys()].some(ref => !covered.has(ref)))
340
+ throw taskError("TASK_REPLAN_COVERAGE_REQUIRED", "Split must account for every previous criterion.");
341
+ if (new Set(children.map(c => c.task.id)).size !== children.length || children.some(c => snapshot.tasks.some(r => r.task.id === c.task.id)))
342
+ throw taskError("TASK_ID_CONFLICT", "Split task IDs must be new and unique.");
343
+ return await this.#store.mutate(state => {
344
+ if (JSON.stringify(state.tasks) !== JSON.stringify(snapshot.tasks))
345
+ throw taskError("TASK_CONTRACT_CHANGED", "Concurrent tree change preserved.");
346
+ const updated = { ...root.task, schemaVersion: TASK_SCHEMA_VERSION, contractRevision: (root.task.contractRevision ?? 0) + 1 };
347
+ const newRoot = { ...root, task: updated, evidence: {}, failureFingerprint: null, updatedAt: now, completionBase: null,
348
+ revisions: [...(root.revisions ?? []), { previousTask: root.task, previousEvidence: root.evidence, previousFailure: root.failureFingerprint,
349
+ previousHash: taskContractHash(root.task), currentHash: taskContractHash(updated), reason: input.reason, at: now,
350
+ diagnostics: input.tasks.flatMap((t, i) => t.replaces.map(ref => ref + " -> " + children[i].task.id)) }] };
351
+ const { reviewBase: _base, noChangePaths: _paths, ...clearedRoot } = newRoot;
352
+ replaceTask(state, clearedRoot);
353
+ for (const old of previous.filter(r => r.task.id !== taskId)) {
354
+ const successors = children.filter((_child, i) => input.tasks[i].replaces.some(ref => ref.startsWith(old.task.id + ":"))).map(c => c.task.id);
355
+ replaceTask(state, { ...old, status: "archived", archivedAt: now, updatedAt: now, supersededBy: successors });
356
+ }
357
+ state.tasks.push(...children);
358
+ for (let i = 0; i < state.sessions.length; i++) {
359
+ const attachment = state.sessions[i];
360
+ if (ids.has(attachment.taskId))
361
+ state.sessions[i] = { ...attachment, taskId, attachedAt: now };
362
+ }
363
+ return children.map(cloneRecord);
364
+ });
365
+ }
157
366
  /** Import a child worktree's intent and criteria, never its source-bound proof. */
158
367
  async importDelivery(parentTaskId, delivered) {
159
- if (delivered.length === 0 || delivered.some(({ status, task }) => status === "archived" || task.intent === undefined || task.intent.trim() === "")) {
368
+ if (delivered.length === 0 || delivered.some(({ status, task }) => (status === "archived" && delivered.find(r => r.task.id === task.id)?.supersededBy === undefined) || task.intent === undefined || task.intent.trim() === "")) {
160
369
  throw taskError("TASK_DELIVERY_INVALID", "Every delivered task needs a recorded intent and must not be archived.");
161
370
  }
162
371
  await this.#store.mutate((state) => {
@@ -188,12 +397,15 @@ export class TaskService {
188
397
  }
189
398
  state.tasks.push({
190
399
  task,
191
- status: "active",
400
+ ...(record.createdSourceCommit === undefined ? {} : { createdSourceCommit: record.createdSourceCommit }),
401
+ ...(record.revisions === undefined ? {} : { revisions: structuredClone(record.revisions) }),
402
+ ...(record.supersededBy === undefined ? {} : { supersededBy: [...record.supersededBy] }),
403
+ status: record.supersededBy === undefined ? "active" : "archived",
192
404
  evidence: {},
193
405
  createdAt: record.createdAt,
194
406
  updatedAt: this.#now(),
195
407
  completedAt: null,
196
- archivedAt: null,
408
+ archivedAt: record.supersededBy === undefined ? null : record.archivedAt,
197
409
  failureFingerprint: null,
198
410
  policyConfigHash: record.policyConfigHash,
199
411
  completionBase: null
@@ -299,7 +511,8 @@ export class TaskService {
299
511
  const fingerprint = async () => {
300
512
  try {
301
513
  const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
302
- ...(completion.base === undefined ? {} : { base: completion.base }) });
514
+ ...(completion.base === undefined ? {} : { base: completion.base }),
515
+ ...((current.noChangePaths ?? completion.noChangePaths) === undefined ? {} : { noChangePaths: current.noChangePaths ?? completion.noChangePaths }) });
303
516
  // Recorded so the completion gate can recompute this exact range once
304
517
  // the work is committed and the worktree has nothing left to measure.
305
518
  completionBase = scope.mode === "base" ? scope.resolvedBase : null;
@@ -365,7 +578,7 @@ export class TaskService {
365
578
  * A PASS review's base: set records it, null clears it (the review covered
366
579
  * uncommitted work), undefined leaves the record's value alone.
367
580
  */
368
- reviewBase) {
581
+ reviewBase, noChangePaths) {
369
582
  const now = assertTimestamp(this.#now());
370
583
  return await this.#store.mutate((state) => {
371
584
  const current = findTask(state, taskId);
@@ -395,6 +608,7 @@ export class TaskService {
395
608
  ...rest,
396
609
  evidence,
397
610
  updatedAt: now,
611
+ ...(noChangePaths === undefined ? {} : { noChangePaths: [...noChangePaths] }),
398
612
  ...(base === undefined ? {} : { reviewBase: base })
399
613
  };
400
614
  replaceTask(state, updated);
@@ -422,12 +636,15 @@ export class TaskService {
422
636
  const validation = validateEvidence(value);
423
637
  return validation.ok && validation.value.taskId === snapshot.task.id &&
424
638
  validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
425
- validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint
639
+ validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint &&
640
+ (!(snapshot.task.goal !== undefined || (snapshot.task.contractRevision ?? 0) > 0 || snapshot.task.criteria.some(c => c.acceptance !== undefined)) ||
641
+ validation.value.taskContractHash === taskContractHash(snapshot.task))
426
642
  ? validation.value : null;
427
643
  };
428
644
  for (const criterion of snapshot.task.criteria) {
429
- const commands = config.verification.commands.filter(({ id, required }) => required && criterion.verifierIds.includes(id));
430
- if (commands.length === 0)
645
+ const commands = config.verification.commands.filter(({ id, required }) => required &&
646
+ (criterion.acceptance !== undefined || criterion.verifierIds.includes(id)));
647
+ if (commands.length === 0 && criterion.acceptance === undefined)
431
648
  throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task criteria lack required coverage.");
432
649
  for (const command of commands) {
433
650
  let original = false;
@@ -442,7 +659,7 @@ export class TaskService {
442
659
  }
443
660
  for (const reference of evidenceInput[criterion.id] ?? []) {
444
661
  const evidence = matches(await store.load(reference), criterion.id, command.id);
445
- if (evidence !== null && evidence.schemaVersion === 3 &&
662
+ if (evidence !== null && evidence.schemaVersion >= 3 &&
446
663
  !snapshot.evidence[criterion.id]?.includes(reference) && isPassingVerificationEvidence(command, evidence))
447
664
  fresh = true;
448
665
  }
@@ -454,13 +671,25 @@ export class TaskService {
454
671
  for (const [criterionId, references] of Object.entries(appended)) {
455
672
  for (const reference of references) {
456
673
  const validation = validateEvidence(await store.load(reference));
457
- if (!validation.ok || !snapshot.task.criteria.find(({ id }) => id === criterionId)?.verifierIds.includes(validation.value.commandId) || matches(validation.value, criterionId, validation.value.commandId) === null) {
674
+ const criterion = snapshot.task.criteria.find(({ id }) => id === criterionId);
675
+ const permitted = validation.ok && criterion !== undefined && (criterion.acceptance === undefined
676
+ ? criterion.verifierIds.includes(validation.value.commandId)
677
+ : validation.value.acceptance === undefined
678
+ ? config.verification.commands.some(command => command.id === validation.value.commandId)
679
+ : criterion.acceptance.bindings.some(binding => binding.runnerId === validation.value.commandId));
680
+ if (!validation.ok || !permitted || matches(validation.value, criterionId, validation.value.commandId) === null) {
458
681
  throw taskError("TASK_EVIDENCE_INVALID", "Fresh verification references must belong to this task, criterion, source and config.");
459
682
  }
460
683
  }
461
684
  }
685
+ for (const record of [snapshot, { ...snapshot, evidence: appended }]) {
686
+ const rows = await acceptanceCoverage(record, config, sourceFingerprint, store);
687
+ if (rows.some(row => (row.mode === "behavioral" || row.mode === "invariant") && row.status !== "proven"))
688
+ throw taskError("TASK_REVERIFICATION_CHANGED", "Completed mechanical contracts require both original and fresh paired acceptance proof.");
689
+ }
462
690
  const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
463
- ...(completion.base === undefined ? {} : { base: completion.base }) });
691
+ ...(completion.base === undefined ? {} : { base: completion.base }),
692
+ ...(snapshot.noChangePaths === undefined ? {} : { noChangePaths: snapshot.noChangePaths }) });
464
693
  if (sourceFingerprint !== await calculateSourceFingerprint(completion.root, scope, completion.gitRunner) ||
465
694
  configHash !== calculateConfigHash(await completion.loadConfig())) {
466
695
  throw taskError("TASK_REVERIFICATION_CHANGED", "Source or config changed during reverification.");
@@ -528,4 +757,78 @@ export class TaskService {
528
757
  });
529
758
  });
530
759
  }
760
+ async coverage(taskId) {
761
+ const record = await this.status({ taskId });
762
+ const originalMechanical = record.task.criteria.filter(c => {
763
+ const original = record.revisions?.map(r => r.previousTask.criteria.find(old => old.id === c.id)).find(Boolean) ?? c;
764
+ return original.acceptance?.mode === "behavioral" || original.acceptance?.mode === "invariant";
765
+ }).length;
766
+ const unavailable = () => ({ originalMechanical, reviewed: false, rows: record.task.criteria.map(c => ({ criterionId: c.id,
767
+ mode: c.acceptance?.mode ?? "legacy", status: "undischarged", evidenceReferences: [],
768
+ reason: "Current source-bound coverage is unavailable; verify and review the current contract." })) });
769
+ if (this.#completion === undefined)
770
+ return unavailable();
771
+ try {
772
+ const config = await this.#completion.loadConfig();
773
+ const base = this.#completion.base ?? record.reviewBase ?? record.completionBase ?? record.createdSourceCommit;
774
+ const scope = await resolveReviewScope({ root: this.#completion.root, runner: this.#completion.gitRunner,
775
+ ...(base === undefined ? {} : { base }), ...(record.noChangePaths === undefined ? {} : { noChangePaths: record.noChangePaths }) });
776
+ const fingerprint = await calculateSourceFingerprint(this.#completion.root, scope, this.#completion.gitRunner);
777
+ const rows = await acceptanceCoverage(record, config, fingerprint, new FileEvidenceStore(this.#completion.root, this.#completion.root));
778
+ const attestation = await findReviewAttestation(this.#completion.root, fingerprint, taskId);
779
+ const reviewed = attestation !== null && (attestation.schemaVersion === 3 ? attestation.taskContracts?.[taskId] === taskContractHash(record.task) :
780
+ record.task.goal === undefined && (record.task.contractRevision ?? 0) === 0 && record.task.criteria.every(c => c.acceptance === undefined));
781
+ return { rows, reviewed, originalMechanical };
782
+ }
783
+ catch {
784
+ return unavailable();
785
+ }
786
+ }
787
+ async #validateFindingSource(record, criterion, records) {
788
+ const finding = criterion.finding;
789
+ if (finding === undefined)
790
+ return;
791
+ const existing = record.task.criteria.find(c => c.finding?.pinId === finding.pinId);
792
+ if (existing !== undefined) {
793
+ if (canonicalJson(existing.finding) !== canonicalJson(finding))
794
+ throw taskError("TASK_PIN_IMMUTABLE", "Finding source identity cannot change.");
795
+ return;
796
+ }
797
+ if (this.#completion === undefined)
798
+ throw taskError("TASK_PIN_REQUIRED", "New pins require a saved review source.");
799
+ const { finding: _finding, ...unbound } = criterion;
800
+ const resolved = await loadFindingPin(this.#completion.root, record, finding.reportDigest + ":" + finding.findingIndex, unbound, records);
801
+ if (canonicalJson(resolved.finding) !== canonicalJson(finding) || resolved.acceptance?.baselineCommit !== criterion.acceptance?.baselineCommit)
802
+ throw taskError("TASK_PIN_REQUIRED", "Pin identity and baseline must match the saved failed report.");
803
+ }
804
+ async pinFinding(taskId, expectedContractHash, reference, criterion, reason) {
805
+ if (this.#completion === undefined)
806
+ throw taskError("TASK_COMPLETION_UNAVAILABLE", "Pin requires a repository and saved review artifacts.");
807
+ const state = await this.#store.read();
808
+ const record = findTask(state, taskId);
809
+ if (record.status !== "active" || taskContractHash(record.task) !== expectedContractHash)
810
+ throw taskError("TASK_CONTRACT_CHANGED", "Pin requires the current active contract.");
811
+ const pinned = await loadFindingPin(this.#completion.root, record, reference, criterion, state.tasks);
812
+ const existing = record.task.criteria.find(c => c.finding?.pinId === pinned.finding?.pinId);
813
+ if (existing === undefined && record.task.criteria.length === 5)
814
+ return { record: null, pendingCriterion: pinned };
815
+ if (existing === undefined && record.task.criteria.some(c => c.id === pinned.id))
816
+ throw taskError("TASK_CRITERION_ID_CONFLICT", "A new pin requires a new criterion ID.");
817
+ const criteria = existing === undefined ? [...record.task.criteria, pinned] : record.task.criteria.map(c => c.id === existing.id ? pinned : c);
818
+ const updated = await this.revise(taskId, { expectedContractHash, criteria,
819
+ reason: reason ?? "Pin saved review finding " + reference + " against the immutable original goal.", diagnostics: ["finding:" + reference, "pin:" + pinned.finding.pinId] });
820
+ return { record: updated, pendingCriterion: pinned };
821
+ }
822
+ async treeContract(taskId) {
823
+ const state = await this.#store.read();
824
+ findTask(state, taskId);
825
+ const ids = new Set([taskId]);
826
+ for (let size = 0; size !== ids.size;) {
827
+ size = ids.size;
828
+ for (const record of state.tasks)
829
+ if (record.supersededBy === undefined && record.task.parentTaskId !== undefined && ids.has(record.task.parentTaskId))
830
+ ids.add(record.task.id);
831
+ }
832
+ return treeContractHash(state.tasks.filter(record => ids.has(record.task.id) && record.supersededBy === undefined).map(record => record.task));
833
+ }
531
834
  }