@kylecheng3146/agent-ops 0.5.2 → 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 (103) hide show
  1. package/README.md +64 -0
  2. package/dist/packages/cli/src/args.js +214 -10
  3. package/dist/packages/cli/src/bin.js +77 -17
  4. package/dist/packages/cli/src/cli.js +42 -10
  5. package/dist/packages/cli/src/commands/advance.js +173 -0
  6. package/dist/packages/cli/src/commands/review-show.js +189 -0
  7. package/dist/packages/cli/src/commands/review.js +201 -34
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +62 -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/install/harness.js +1 -1
  26. package/dist/runtime/src/install/preauth.js +10 -1
  27. package/dist/runtime/src/parallel/finish.js +217 -39
  28. package/dist/runtime/src/parallel/guard.js +5 -1
  29. package/dist/runtime/src/parallel/integrate.js +416 -0
  30. package/dist/runtime/src/parallel/manage.js +4 -1
  31. package/dist/runtime/src/parallel/receipt.js +153 -0
  32. package/dist/runtime/src/parallel/service.js +45 -3
  33. package/dist/runtime/src/review/attestation.js +60 -11
  34. package/dist/runtime/src/review/execute.js +24 -1
  35. package/dist/runtime/src/review/packet.js +35 -1
  36. package/dist/runtime/src/review/render.js +71 -0
  37. package/dist/runtime/src/review/runner.js +12 -1
  38. package/dist/runtime/src/review/scope.js +18 -1
  39. package/dist/runtime/src/review/slots.js +35 -10
  40. package/dist/runtime/src/run/authorization.js +60 -0
  41. package/dist/runtime/src/run/budget.js +30 -0
  42. package/dist/runtime/src/run/controls.js +139 -0
  43. package/dist/runtime/src/run/convergence.js +177 -0
  44. package/dist/runtime/src/run/hosts/claude.js +180 -0
  45. package/dist/runtime/src/run/hosts/codex.js +279 -0
  46. package/dist/runtime/src/run/hosts/index.js +4 -0
  47. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  48. package/dist/runtime/src/run/hosts/types.js +1 -0
  49. package/dist/runtime/src/run/hosts/util.js +136 -0
  50. package/dist/runtime/src/run/integration.js +204 -0
  51. package/dist/runtime/src/run/macOS.js +189 -0
  52. package/dist/runtime/src/run/ownership.js +22 -0
  53. package/dist/runtime/src/run/planning.js +25 -0
  54. package/dist/runtime/src/run/policy-transition.js +1076 -0
  55. package/dist/runtime/src/run/policy.js +176 -0
  56. package/dist/runtime/src/run/ratchet.js +71 -0
  57. package/dist/runtime/src/run/scheduler.js +260 -0
  58. package/dist/runtime/src/run/service.js +425 -0
  59. package/dist/runtime/src/run/supervisor.js +635 -0
  60. package/dist/runtime/src/run/transport.js +159 -0
  61. package/dist/runtime/src/run/types.js +1 -0
  62. package/dist/runtime/src/run/usage.js +81 -0
  63. package/dist/runtime/src/run/verification.js +64 -0
  64. package/dist/runtime/src/run/worker-gate.js +48 -0
  65. package/dist/runtime/src/schema/acceptance.js +91 -0
  66. package/dist/runtime/src/schema/validate.js +72 -7
  67. package/dist/runtime/src/task/completion.js +26 -6
  68. package/dist/runtime/src/task/contract.js +17 -0
  69. package/dist/runtime/src/task/pin-finding.js +81 -0
  70. package/dist/runtime/src/task/render.js +3 -0
  71. package/dist/runtime/src/task/service.js +361 -10
  72. package/dist/runtime/src/task/store.js +57 -7
  73. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  74. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  75. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  76. package/dist/runtime/src/verify/adapters/index.js +5 -0
  77. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  78. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  79. package/dist/runtime/src/verify/adapters/node.js +254 -0
  80. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  81. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  82. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  83. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  84. package/dist/runtime/src/verify/evidence.js +5 -2
  85. package/dist/runtime/src/verify/service.js +24 -8
  86. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  87. package/dist/runtime/src/verify/spawn.js +13 -2
  88. package/docs/en/guides/configuration.md +10 -0
  89. package/docs/en/spec/acceptance-and-evidence.md +58 -0
  90. package/docs/en/spec/delegation.md +4 -4
  91. package/docs/en/spec/loop-engineering.md +49 -0
  92. package/docs/en/spec/review.md +24 -2
  93. package/docs/zh-TW/guides/configuration.md +9 -0
  94. package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
  95. package/docs/zh-TW/spec/delegation.md +4 -4
  96. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  97. package/docs/zh-TW/spec/review.md +24 -2
  98. package/package.json +1 -1
  99. package/schemas/config.schema.json +221 -19
  100. package/schemas/evidence.schema.json +129 -6
  101. package/schemas/task.schema.json +268 -6
  102. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  103. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -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,11 +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
- criteria: [...input.criteria],
111
+ ...(input.intent === undefined ? {} : { intent: input.intent }),
112
+ criteria,
113
+ ...(input.goal === undefined ? {} : { goal: input.goal }),
94
114
  ...(input.parentTaskId === undefined
95
115
  ? {}
96
116
  : { parentTaskId: input.parentTaskId })
@@ -99,6 +119,16 @@ export class TaskService {
99
119
  if (!validation.ok) {
100
120
  throw taskError("TASK_INVALID", validation.errors[0]?.message ?? "Task input is invalid.");
101
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
+ }
102
132
  const now = assertTimestamp(this.#now());
103
133
  return await this.#store.mutate((state) => {
104
134
  if (state.tasks.some((record) => record.task.id === validation.value.id)) {
@@ -114,9 +144,15 @@ export class TaskService {
114
144
  if (parent.status !== "active") {
115
145
  throw taskError("TASK_PARENT_NOT_ACTIVE", "Only an active task can take new subtasks.");
116
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
+ }
117
152
  }
118
153
  const record = {
119
154
  task: validation.value,
155
+ ...(createdSourceCommit === undefined ? {} : { createdSourceCommit }),
120
156
  status: "active",
121
157
  evidence: {},
122
158
  createdAt: now,
@@ -153,6 +189,230 @@ export class TaskService {
153
189
  return cloneRecord(record);
154
190
  });
155
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
+ }
366
+ /** Import a child worktree's intent and criteria, never its source-bound proof. */
367
+ async importDelivery(parentTaskId, delivered) {
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() === "")) {
369
+ throw taskError("TASK_DELIVERY_INVALID", "Every delivered task needs a recorded intent and must not be archived.");
370
+ }
371
+ await this.#store.mutate((state) => {
372
+ const parent = findTask(state, parentTaskId);
373
+ if (parent.status !== "active") {
374
+ throw taskError("TASK_PARENT_NOT_ACTIVE", "Only an active parent can accept delivery.");
375
+ }
376
+ const ids = new Set(delivered.map(({ task }) => task.id));
377
+ if (ids.size !== delivered.length) {
378
+ throw taskError("TASK_DELIVERY_INVALID", "Delivered task IDs must be unique.");
379
+ }
380
+ for (const record of delivered) {
381
+ const task = {
382
+ ...record.task,
383
+ ...(record.task.parentTaskId === undefined ? { parentTaskId } : {})
384
+ };
385
+ if (task.parentTaskId !== parentTaskId && !ids.has(task.parentTaskId)) {
386
+ throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has a parent outside this delivery.`);
387
+ }
388
+ if (record.policyConfigHash !== parent.policyConfigHash || !validateTask(task).ok) {
389
+ throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has an incompatible policy or definition.`);
390
+ }
391
+ const existing = state.tasks.find((item) => item.task.id === task.id);
392
+ if (existing !== undefined) {
393
+ if (JSON.stringify(existing.task) !== JSON.stringify(task)) {
394
+ throw taskError("TASK_ID_CONFLICT", `Task ${task.id} conflicts with an existing task.`);
395
+ }
396
+ continue;
397
+ }
398
+ state.tasks.push({
399
+ task,
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",
404
+ evidence: {},
405
+ createdAt: record.createdAt,
406
+ updatedAt: this.#now(),
407
+ completedAt: null,
408
+ archivedAt: record.supersededBy === undefined ? null : record.archivedAt,
409
+ failureFingerprint: null,
410
+ policyConfigHash: record.policyConfigHash,
411
+ completionBase: null
412
+ });
413
+ }
414
+ });
415
+ }
156
416
  async list(filter = {}) {
157
417
  const state = await this.#store.read();
158
418
  return state.tasks
@@ -251,7 +511,8 @@ export class TaskService {
251
511
  const fingerprint = async () => {
252
512
  try {
253
513
  const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
254
- ...(completion.base === undefined ? {} : { base: completion.base }) });
514
+ ...(completion.base === undefined ? {} : { base: completion.base }),
515
+ ...((current.noChangePaths ?? completion.noChangePaths) === undefined ? {} : { noChangePaths: current.noChangePaths ?? completion.noChangePaths }) });
255
516
  // Recorded so the completion gate can recompute this exact range once
256
517
  // the work is committed and the worktree has nothing left to measure.
257
518
  completionBase = scope.mode === "base" ? scope.resolvedBase : null;
@@ -317,7 +578,7 @@ export class TaskService {
317
578
  * A PASS review's base: set records it, null clears it (the review covered
318
579
  * uncommitted work), undefined leaves the record's value alone.
319
580
  */
320
- reviewBase) {
581
+ reviewBase, noChangePaths) {
321
582
  const now = assertTimestamp(this.#now());
322
583
  return await this.#store.mutate((state) => {
323
584
  const current = findTask(state, taskId);
@@ -347,6 +608,7 @@ export class TaskService {
347
608
  ...rest,
348
609
  evidence,
349
610
  updatedAt: now,
611
+ ...(noChangePaths === undefined ? {} : { noChangePaths: [...noChangePaths] }),
350
612
  ...(base === undefined ? {} : { reviewBase: base })
351
613
  };
352
614
  replaceTask(state, updated);
@@ -374,12 +636,15 @@ export class TaskService {
374
636
  const validation = validateEvidence(value);
375
637
  return validation.ok && validation.value.taskId === snapshot.task.id &&
376
638
  validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
377
- 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))
378
642
  ? validation.value : null;
379
643
  };
380
644
  for (const criterion of snapshot.task.criteria) {
381
- const commands = config.verification.commands.filter(({ id, required }) => required && criterion.verifierIds.includes(id));
382
- 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)
383
648
  throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task criteria lack required coverage.");
384
649
  for (const command of commands) {
385
650
  let original = false;
@@ -394,7 +659,7 @@ export class TaskService {
394
659
  }
395
660
  for (const reference of evidenceInput[criterion.id] ?? []) {
396
661
  const evidence = matches(await store.load(reference), criterion.id, command.id);
397
- if (evidence !== null && evidence.schemaVersion === 3 &&
662
+ if (evidence !== null && evidence.schemaVersion >= 3 &&
398
663
  !snapshot.evidence[criterion.id]?.includes(reference) && isPassingVerificationEvidence(command, evidence))
399
664
  fresh = true;
400
665
  }
@@ -406,13 +671,25 @@ export class TaskService {
406
671
  for (const [criterionId, references] of Object.entries(appended)) {
407
672
  for (const reference of references) {
408
673
  const validation = validateEvidence(await store.load(reference));
409
- 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) {
410
681
  throw taskError("TASK_EVIDENCE_INVALID", "Fresh verification references must belong to this task, criterion, source and config.");
411
682
  }
412
683
  }
413
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
+ }
414
690
  const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
415
- ...(completion.base === undefined ? {} : { base: completion.base }) });
691
+ ...(completion.base === undefined ? {} : { base: completion.base }),
692
+ ...(snapshot.noChangePaths === undefined ? {} : { noChangePaths: snapshot.noChangePaths }) });
416
693
  if (sourceFingerprint !== await calculateSourceFingerprint(completion.root, scope, completion.gitRunner) ||
417
694
  configHash !== calculateConfigHash(await completion.loadConfig())) {
418
695
  throw taskError("TASK_REVERIFICATION_CHANGED", "Source or config changed during reverification.");
@@ -480,4 +757,78 @@ export class TaskService {
480
757
  });
481
758
  });
482
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
+ }
483
834
  }
@@ -3,6 +3,8 @@ import { TASK_SCHEMA_VERSION } from "../contracts.js";
3
3
  import { AgentOpsError } from "../fs/paths.js";
4
4
  import { validateTask } from "../schema/validate.js";
5
5
  import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
6
+ import { taskContractHash } from "./contract.js";
7
+ import { commitIdentity, acceptanceSourcePath } from "../schema/acceptance.js";
6
8
  const DEFAULT_MAX_BYTES = 1024 * 1024;
7
9
  const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
8
10
  function isMissing(error) {
@@ -66,12 +68,9 @@ function parseTaskRecord(value) {
66
68
  ];
67
69
  // Every record written before an optional field existed must still parse, so
68
70
  // the accepted shapes are every combination of them.
69
- const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase"];
70
- const allowedKeys = new Set(Array.from({ length: 1 << optionalKeys.length }, (_unused, mask) => [
71
- ...baseKeys,
72
- ...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
73
- ].sort().join("\0")));
74
- if (!isRecord(value) || !allowedKeys.has(Object.keys(value).sort().join("\0"))) {
71
+ const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase", "createdSourceCommit", "revisions", "supersededBy", "noChangePaths"];
72
+ if (!isRecord(value) || !baseKeys.every(k => Object.hasOwn(value, k)) ||
73
+ Object.keys(value).some(k => !baseKeys.includes(k) && !optionalKeys.includes(k))) {
75
74
  return invalidState("Task state contains an invalid task record.");
76
75
  }
77
76
  const task = validateTask(value.task);
@@ -150,8 +149,43 @@ function parseTaskRecord(value) {
150
149
  (typeof value.reviewBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.reviewBase))) {
151
150
  return invalidState("Task state contains an invalid review base.");
152
151
  }
152
+ if (value.noChangePaths !== undefined && (!Array.isArray(value.noChangePaths) || value.noChangePaths.length === 0 || value.noChangePaths.length > 128 ||
153
+ value.noChangePaths.some(p => typeof p !== "string" || !acceptanceSourcePath(p)) || new Set(value.noChangePaths).size !== value.noChangePaths.length))
154
+ return invalidState("Invalid no-change scope.");
155
+ if (value.supersededBy !== undefined && (!Array.isArray(value.supersededBy) || value.supersededBy.length === 0 || value.supersededBy.length > 128 ||
156
+ value.supersededBy.some(v => typeof v !== "string" || !/^[a-z][a-z0-9-]{0,127}$/u.test(v)) || new Set(value.supersededBy).size !== value.supersededBy.length || status !== "archived"))
157
+ return invalidState("Invalid supersession mapping.");
158
+ if (value.createdSourceCommit !== undefined && !commitIdentity(value.createdSourceCommit))
159
+ return invalidState("Invalid task creation commit.");
160
+ if (value.revisions !== undefined) {
161
+ if (!Array.isArray(value.revisions) || value.revisions.length > 1024)
162
+ return invalidState("Invalid revision history.");
163
+ let previousCurrent;
164
+ for (const revision of value.revisions) {
165
+ if (!isRecord(revision) || !hasExactKeys(revision, ["previousTask", "previousEvidence", "previousFailure", "previousHash", "currentHash", "reason", "at", "diagnostics"]) ||
166
+ typeof revision.reason !== "string" || revision.reason.trim() === "" || revision.reason.length > 8192 || revision.reason.includes("\0") ||
167
+ !isTimestamp(revision.at) || !Array.isArray(revision.diagnostics) || revision.diagnostics.length > 512 ||
168
+ revision.diagnostics.some(d => typeof d !== "string" || d.length > 4096 || d.includes("\0")) ||
169
+ typeof revision.currentHash !== "string" || !/^[a-f0-9]{64}$/u.test(revision.currentHash))
170
+ return invalidState("Invalid revision metadata.");
171
+ const before = validateTask(revision.previousTask);
172
+ if (!before.ok || before.value.id !== task.value.id || taskContractHash(before.value) !== revision.previousHash ||
173
+ (previousCurrent !== undefined && revision.previousHash !== previousCurrent))
174
+ return invalidState("Invalid revision chain.");
175
+ parseEvidence(revision.previousEvidence, before.value, false);
176
+ if (revision.previousFailure !== null && (!isRecord(revision.previousFailure) || typeof revision.previousFailure.value !== "string" || !/^[a-f0-9]{64}$/u.test(revision.previousFailure.value)))
177
+ return invalidState("Invalid revision failure provenance.");
178
+ previousCurrent = revision.currentHash;
179
+ }
180
+ if (previousCurrent !== undefined && previousCurrent !== taskContractHash(task.value))
181
+ return invalidState("Revision history does not match current contract.");
182
+ }
153
183
  return {
154
184
  task: task.value,
185
+ ...(value.noChangePaths === undefined ? {} : { noChangePaths: value.noChangePaths }),
186
+ ...(value.createdSourceCommit === undefined ? {} : { createdSourceCommit: value.createdSourceCommit }),
187
+ ...(value.revisions === undefined ? {} : { revisions: value.revisions }),
188
+ ...(value.supersededBy === undefined ? {} : { supersededBy: value.supersededBy }),
155
189
  status,
156
190
  evidence,
157
191
  createdAt: value.createdAt,
@@ -204,7 +238,7 @@ function parseState(source) {
204
238
  }
205
239
  if (!isRecord(value) ||
206
240
  !hasExactKeys(value, ["schemaVersion", "sessions", "tasks"]) ||
207
- value.schemaVersion !== TASK_SCHEMA_VERSION ||
241
+ (value.schemaVersion !== 1 && value.schemaVersion !== TASK_SCHEMA_VERSION) ||
208
242
  !Array.isArray(value.tasks) ||
209
243
  !Array.isArray(value.sessions)) {
210
244
  return invalidState("Task state has an unsupported structure.");
@@ -217,6 +251,22 @@ function parseState(source) {
217
251
  return invalidState("Task and session identifiers must be unique.");
218
252
  }
219
253
  const tasksById = new Map(tasks.map((record) => [record.task.id, record]));
254
+ for (const old of tasks) {
255
+ for (const successor of old.supersededBy ?? []) {
256
+ if (successor === old.task.id || !tasksById.has(successor))
257
+ return invalidState("Supersession must name a distinct known task.");
258
+ const seen = new Set([old.task.id]);
259
+ const visit = (id) => {
260
+ if (seen.has(id))
261
+ return invalidState("Cyclic task supersession.");
262
+ seen.add(id);
263
+ for (const next of tasksById.get(id)?.supersededBy ?? [])
264
+ visit(next);
265
+ seen.delete(id);
266
+ };
267
+ visit(successor);
268
+ }
269
+ }
220
270
  if (sessions.some(({ taskId }) => {
221
271
  const record = tasksById.get(taskId);
222
272
  return record === undefined || record.status === "archived";