@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
@@ -4,6 +4,12 @@ import { renderReviewResult } from "../../../../runtime/src/review/render.js";
4
4
  import { reviewReportResults } from "../../../../runtime/src/review/report.js";
5
5
  import { findPriorFailingFindings, findReviewAttestation, invalidateReviewAttestation, readReviewReportArtifact, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
6
6
  import { detectHostTarget, planReviewTargets, resolveReviewRole } from "../../../../runtime/src/review/roles.js";
7
+ import { taskContractHash, treeContractHash, goalHash } from "../../../../runtime/src/task/contract.js";
8
+ import { acceptanceCoverage, coverageDigest } from "../../../../runtime/src/verify/acceptance-coverage.js";
9
+ import { resolveGitCommit } from "../../../../runtime/src/verify/change-surface.js";
10
+ import { sha256 } from "../../../../runtime/src/fs/hash.js";
11
+ import { readPrivateFile, writePrivateFile } from "../../../../runtime/src/security/permissions.js";
12
+ import { redactSecrets } from "../../../../runtime/src/security/redact.js";
7
13
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
8
14
  import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
9
15
  import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
@@ -70,8 +76,11 @@ async function taskContext(options) {
70
76
  throw new AgentOpsError("REVIEW_CRITERIA_NOT_FOUND", "Every requested review criterion must exist on the selected task.");
71
77
  }
72
78
  return {
79
+ records: [record],
73
80
  taskId: record.task.id,
74
- title: record.task.title,
81
+ title: record.task.intent === undefined
82
+ ? record.task.title
83
+ : `${record.task.title}\nModification intent: ${record.task.intent}`,
75
84
  active: record.status === "active",
76
85
  policyConfigHash: record.policyConfigHash,
77
86
  evidence: record.evidence,
@@ -80,6 +89,60 @@ async function taskContext(options) {
80
89
  allCriteriaReviewed: criteria.length === record.task.criteria.length
81
90
  };
82
91
  }
92
+ async function treeContext(options) {
93
+ if (options.tasks === undefined || options.taskId === undefined) {
94
+ throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "Tree review requires a parent task.");
95
+ }
96
+ const all = await options.tasks.list();
97
+ const root = all.find(({ task }) => task.id === options.taskId);
98
+ if (root === undefined || root.status === "archived") {
99
+ throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "The selected review tree does not exist.");
100
+ }
101
+ const selected = new Set([root.task.id]);
102
+ for (let changed = true; changed;) {
103
+ changed = false;
104
+ for (const record of all) {
105
+ if (record.status !== "archived" && record.task.parentTaskId !== undefined &&
106
+ selected.has(record.task.parentTaskId) && !selected.has(record.task.id)) {
107
+ selected.add(record.task.id);
108
+ changed = true;
109
+ }
110
+ }
111
+ }
112
+ const treeTasks = all.filter(({ task }) => selected.has(task.id));
113
+ const owners = new Map();
114
+ const evidence = {};
115
+ const criteria = treeTasks.flatMap((record) => record.task.criteria.map((criterion) => {
116
+ const id = `${record.task.id}:${criterion.id}`;
117
+ owners.set(id, { taskId: record.task.id, criterionId: criterion.id });
118
+ evidence[id] = record.evidence[criterion.id] ?? [];
119
+ return { id, description: `${record.task.title}: ${criterion.description}`, verifierIds: [...criterion.verifierIds] };
120
+ }));
121
+ if (treeTasks.some(({ policyConfigHash }) => policyConfigHash !== root.policyConfigHash)) {
122
+ throw new AgentOpsError("REVIEW_TREE_POLICY_MISMATCH", "Tree tasks must share one verifier policy.");
123
+ }
124
+ const historical = new Set([root.task.id]);
125
+ for (let size = 0; size !== historical.size;) {
126
+ size = historical.size;
127
+ for (const record of all)
128
+ if (record.task.parentTaskId !== undefined && historical.has(record.task.parentTaskId))
129
+ historical.add(record.task.id);
130
+ }
131
+ return {
132
+ historyRecords: all.filter(record => historical.has(record.task.id) && record.supersededBy !== undefined),
133
+ records: treeTasks,
134
+ taskId: root.task.id,
135
+ title: [root.task.title, ...treeTasks.map(({ task }) => `Task ${task.id}${task.parentTaskId === undefined ? "" : ` under ${task.parentTaskId}`}: ${task.title}\nModification intent: ${task.intent ?? task.title}`)].join("\n\n"),
136
+ active: treeTasks.every(({ status }) => status === "active"),
137
+ policyConfigHash: root.policyConfigHash,
138
+ evidence,
139
+ failureFingerprint: treeTasks.find(({ failureFingerprint }) => failureFingerprint !== null)?.failureFingerprint ?? null,
140
+ criteria,
141
+ allCriteriaReviewed: true,
142
+ treeTasks,
143
+ criterionOwners: owners
144
+ };
145
+ }
83
146
  function newestEvidence(values) {
84
147
  return [...values].sort((left, right) => {
85
148
  const leftTime = Date.parse(left.evidence.finishedAt);
@@ -111,11 +174,15 @@ async function currentEvidence(options, context, criterionId, commandId, configH
111
174
  continue;
112
175
  }
113
176
  const evidence = validation.value;
114
- if (evidence.taskId !== context.taskId ||
115
- evidence.criterionId !== criterionId ||
177
+ const owner = context.criterionOwners?.get(criterionId);
178
+ if (evidence.taskId !== (owner?.taskId ?? context.taskId) ||
179
+ evidence.criterionId !== (owner?.criterionId ?? criterionId) ||
116
180
  evidence.commandId !== commandId ||
117
181
  evidence.configHash !== configHash ||
118
- evidence.sourceFingerprint !== sourceFingerprint) {
182
+ evidence.sourceFingerprint !== sourceFingerprint ||
183
+ (context.records.some(r => r.task.id === (owner?.taskId ?? context.taskId) &&
184
+ (r.task.goal !== undefined || (r.task.contractRevision ?? 0) > 0 || r.task.criteria.some(c => c.acceptance !== undefined))) &&
185
+ evidence.taskContractHash !== taskContractHash(context.records.find(r => r.task.id === (owner?.taskId ?? context.taskId)).task))) {
119
186
  stale = true;
120
187
  continue;
121
188
  }
@@ -142,14 +209,18 @@ async function preflightReview(options, context, sourceFingerprint) {
142
209
  const configHash = calculateConfigHash(options.config);
143
210
  const commands = [];
144
211
  for (const criterion of context.criteria) {
145
- for (const commandId of criterion.verifierIds ?? []) {
212
+ const owner = context.criterionOwners?.get(criterion.id);
213
+ const typed = context.records.find(r => r.task.id === (owner?.taskId ?? context.taskId))?.task.criteria
214
+ .find(c => c.id === (owner?.criterionId ?? criterion.id))?.acceptance !== undefined;
215
+ for (const commandId of new Set([...(criterion.verifierIds ?? []),
216
+ ...(typed ? options.config.verification.commands.filter(c => c.required).map(c => c.id) : [])])) {
146
217
  const command = options.config.verification.commands.find((candidate) => candidate.id === commandId);
147
218
  if (command === undefined) {
148
219
  return { ok: false, reason: "missing-verification-evidence" };
149
220
  }
150
221
  const found = await currentEvidence(options, context, criterion.id, commandId, configHash, sourceFingerprint);
151
222
  const compatible = command.evidence.kind === "test-count"
152
- ? found.current.filter(({ evidence }) => evidence.schemaVersion === 3)
223
+ ? found.current.filter(({ evidence }) => evidence.schemaVersion >= 3)
153
224
  : found.current;
154
225
  if (command.required && found.current.length > 0 && compatible.length === 0) {
155
226
  return { ok: false, reason: "stale-verification" };
@@ -210,9 +281,11 @@ export async function hasFreshVerification(options, taskId, base) {
210
281
  if (context === undefined) {
211
282
  return false;
212
283
  }
284
+ options = { ...options, noChangePaths: options.noChangePaths ?? context.records.find(r => r.task.id === context.taskId)?.noChangePaths };
213
285
  const scope = await resolveReviewScope({
214
286
  root: options.root,
215
287
  runner: options.gitRunner,
288
+ ...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
216
289
  ...(base === undefined ? {} : { base })
217
290
  });
218
291
  const fingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
@@ -304,7 +377,16 @@ async function reusableReview(options, sourceFingerprint, context) {
304
377
  return null;
305
378
  }
306
379
  const attestation = await findReviewAttestation(options.root, sourceFingerprint, context?.taskId);
307
- if (attestation === null || attestation.taskId !== context?.taskId) {
380
+ if (attestation === null || attestation.taskId !== context?.taskId ||
381
+ (context?.binding !== undefined && (attestation.schemaVersion !== 3 ||
382
+ JSON.stringify(attestation.taskContracts) !== JSON.stringify(context.binding.taskContracts) ||
383
+ attestation.coverageDigest !== context.binding.coverageDigest))) {
384
+ return null;
385
+ }
386
+ if (context?.treeTasks !== undefined &&
387
+ (attestation.tree?.rootTaskId !== context.taskId ||
388
+ JSON.stringify(attestation.tree.taskIds) !== JSON.stringify(context.treeTasks.map(({ task }) => task.id)) ||
389
+ JSON.stringify(attestation.tree.criterionIds) !== JSON.stringify(context.criteria.map(({ id }) => id)))) {
308
390
  return null;
309
391
  }
310
392
  // The task's own policy baseline must still be the active one. This is
@@ -355,9 +437,19 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
355
437
  reason: "adversarial-review-missing"
356
438
  }
357
439
  : result;
358
- let reportArtifact;
440
+ const taskIds = complete && context?.treeTasks !== undefined
441
+ ? context.treeTasks.map(({ task }) => task.id)
442
+ : [context?.taskId];
443
+ const tree = context?.treeTasks === undefined ? undefined : {
444
+ rootTaskId: context.taskId,
445
+ taskIds: context.treeTasks.map(({ task }) => task.id),
446
+ criterionIds: context.criteria.map(({ id }) => id)
447
+ };
448
+ const reportArtifacts = [];
359
449
  try {
360
- reportArtifact = await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, context?.taskId);
450
+ for (const taskId of taskIds) {
451
+ reportArtifacts.push({ taskId, path: await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, taskId, complete ? tree : undefined, context?.binding === undefined ? undefined : { ...context.binding, taskContractHash: context.binding.taskContracts[taskId ?? context.taskId] }, context?.coverage) });
452
+ }
361
453
  }
362
454
  catch {
363
455
  return {
@@ -375,21 +467,25 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
375
467
  try {
376
468
  const attempts = result.attempts;
377
469
  const plannedTargets = result.plannedTargets;
378
- await saveReviewAttestation(options.root, {
379
- schemaVersion: 2,
380
- ...(context === undefined ? {} : { taskId: context.taskId }),
381
- harness: result.harness,
382
- status: "PASS",
383
- sourceFingerprint,
384
- reviewTargets: [plannedTargets[0], plannedTargets[1]],
385
- reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
386
- sessionIsolation: "fresh",
387
- primaryReportDigest: reviewReportDigest(result.report),
388
- adversarialReportDigest: reviewReportDigest(result.adversarial.report),
389
- reportArtifact,
390
- ...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
391
- createdAt: new Date().toISOString()
392
- });
470
+ for (const artifact of reportArtifacts) {
471
+ await saveReviewAttestation(options.root, {
472
+ schemaVersion: context?.binding === undefined ? 2 : 3,
473
+ ...(context?.binding === undefined ? {} : { ...context.binding, taskContractHash: context.binding.taskContracts[artifact.taskId ?? context.taskId] }),
474
+ ...(artifact.taskId === undefined ? {} : { taskId: artifact.taskId }),
475
+ ...(tree === undefined ? {} : { tree }),
476
+ harness: result.harness,
477
+ status: "PASS",
478
+ sourceFingerprint,
479
+ reviewTargets: [plannedTargets[0], plannedTargets[1]],
480
+ reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
481
+ sessionIsolation: "fresh",
482
+ primaryReportDigest: reviewReportDigest(result.report),
483
+ adversarialReportDigest: reviewReportDigest(result.adversarial.report),
484
+ reportArtifact: artifact.path,
485
+ ...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
486
+ createdAt: new Date().toISOString()
487
+ });
488
+ }
393
489
  }
394
490
  catch {
395
491
  return {
@@ -432,7 +528,10 @@ export async function runReviewCommand(options) {
432
528
  reason: targetPlan.reason
433
529
  });
434
530
  }
435
- const context = await taskContext(options);
531
+ const context = options.args.tree === true
532
+ ? await treeContext(options)
533
+ : await taskContext(options);
534
+ options = { ...options, noChangePaths: options.noChangePaths ?? context?.records.find(r => r.task.id === context.taskId)?.noChangePaths };
436
535
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
437
536
  const separator = value.indexOf("=");
438
537
  return {
@@ -448,6 +547,7 @@ export async function runReviewCommand(options) {
448
547
  scope = await resolveReviewScope({
449
548
  root: options.root,
450
549
  runner: options.gitRunner,
550
+ ...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
451
551
  ...(options.args.base === undefined ? {} : { base: options.args.base })
452
552
  });
453
553
  }
@@ -505,7 +605,7 @@ export async function runReviewCommand(options) {
505
605
  // source was reviewed; it says nothing about the policy in force now or
506
606
  // about verification that has failed since, and both of those are
507
607
  // decided above.
508
- const reused = options.args.rerun
608
+ const reused = options.args.rerun || context?.treeTasks !== undefined || context?.records.some(r => r.task.goal !== undefined || r.task.criteria.some(c => c.acceptance !== undefined) || (r.task.contractRevision ?? 0) > 0)
509
609
  ? null
510
610
  : await reusableReview(options, sourceFingerprint, context);
511
611
  if (reused !== null) {
@@ -518,16 +618,75 @@ export async function runReviewCommand(options) {
518
618
  })
519
619
  });
520
620
  }
521
- await invalidateReviewAttestation(options.root, sourceFingerprint, context?.taskId);
621
+ for (const taskId of context?.treeTasks?.map(({ task }) => task.id) ?? [context?.taskId]) {
622
+ await invalidateReviewAttestation(options.root, sourceFingerprint, taskId);
623
+ }
522
624
  pendingInvalidation = false;
523
625
  }
524
626
  }
627
+ if (context !== undefined && sourceFingerprint !== undefined && options.config !== undefined &&
628
+ options.evidenceStore !== undefined && options.gitRunner !== undefined) {
629
+ const rows = [];
630
+ for (const record of context.records) {
631
+ const coverage = await acceptanceCoverage(record, options.config, sourceFingerprint, options.evidenceStore);
632
+ rows.push(...coverage.map(row => ({ ...row, criterionId: context.treeTasks === undefined
633
+ ? row.criterionId : record.task.id + ":" + row.criterionId })));
634
+ }
635
+ context.coverage = rows;
636
+ context.binding = {
637
+ taskContractHash: taskContractHash(context.records.find(r => r.task.id === context.taskId).task),
638
+ taskContracts: Object.fromEntries(context.records.map(r => [r.task.id, taskContractHash(r.task)])),
639
+ ...(context.treeTasks === undefined ? {} : { treeContractHash: treeContractHash(context.records.map(r => r.task)) }),
640
+ goalHash: goalHash(context.records.find(r => r.task.id === context.taskId).task),
641
+ candidateCommit: await resolveGitCommit(options.gitRunner, "HEAD"), coverageDigest: coverageDigest(rows)
642
+ };
643
+ }
525
644
  const criteria = [
526
645
  ...(context?.criteria ?? GENERIC_CRITERIA)
527
646
  ];
647
+ if (context?.treeTasks !== undefined && criteria.length > 128) {
648
+ return notRunEnvelope({ ...resultBase, status: "NOT_RUN", reason: "scope-too-large" });
649
+ }
528
650
  let packet;
529
651
  try {
652
+ let contractManifest;
653
+ const contractArtifacts = [];
654
+ if (context?.binding !== undefined && options.root !== undefined) {
655
+ const historicalRefs = new Set([
656
+ ...[...context.records, ...(context.historyRecords ?? [])].flatMap(r => (r.revisions ?? []).flatMap(revision => Object.values(revision.previousEvidence).flat())),
657
+ ...(context.historyRecords ?? []).flatMap(r => Object.values(r.evidence).flat())
658
+ ]);
659
+ const refs = new Set([...context.records.flatMap(r => Object.values(r.evidence).flat()), ...historicalRefs].filter(ref => !ref.startsWith("review:")));
660
+ for (const path of refs) {
661
+ const loaded = await options.evidenceStore?.load(path);
662
+ if (loaded == null)
663
+ continue;
664
+ if (!historicalRefs.has(path) && loaded.sourceFingerprint !== sourceFingerprint)
665
+ continue;
666
+ const content = await readPrivateFile(options.root + "/" + path, options.root);
667
+ if (content !== null)
668
+ contractArtifacts.push({ path, digest: sha256(content), content });
669
+ const executionPath = loaded.acceptance?.executionArtifact;
670
+ if (executionPath !== undefined && !contractArtifacts.some(artifact => artifact.path === executionPath)) {
671
+ const execution = await readPrivateFile(options.root + "/" + executionPath, options.root);
672
+ if (execution !== null)
673
+ contractArtifacts.push({ path: executionPath, digest: sha256(execution), content: execution });
674
+ }
675
+ }
676
+ const content = redactSecrets(JSON.stringify({ binding: context.binding, coverage: context.coverage,
677
+ executionPolicy: options.config,
678
+ artifacts: contractArtifacts.map(({ path, digest }) => ({ path, digest })),
679
+ tasks: context.records.map(r => ({ task: r.task, revisions: r.revisions ?? [] })),
680
+ superseded: context.historyRecords ?? [] }, null, 2));
681
+ const digest = sha256(content);
682
+ const path = ".agent-ops/tasks/review-contracts/" + digest + ".json";
683
+ contractManifest = { path, digest, content };
684
+ // Validated before any manifest is written or handed to a snapshot.
685
+ buildReviewPacket({ request: context.title, criteria, artifactRefs: [], evidenceRequirements, contractManifest, contractArtifacts });
686
+ await writePrivateFile(options.root + "/" + path, content, options.root);
687
+ }
530
688
  packet = buildReviewPacket({
689
+ ...(contractManifest === undefined ? {} : { contractManifest, contractArtifacts }),
531
690
  request: context?.title ?? GENERIC_REQUEST,
532
691
  criteria,
533
692
  artifactRefs: scope?.changedFiles ?? [],
@@ -602,6 +761,7 @@ export async function runReviewCommand(options) {
602
761
  const postflight = await resolveReviewScope({
603
762
  root: options.root,
604
763
  runner: options.gitRunner,
764
+ ...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
605
765
  ...(options.args.base === undefined ? {} : { base: options.args.base })
606
766
  });
607
767
  const currentHash = options.currentPolicyConfigHash === undefined
@@ -610,7 +770,12 @@ export async function runReviewCommand(options) {
610
770
  const postflightFingerprint = await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
611
771
  if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
612
772
  (options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
613
- postflightFingerprint !== sourceFingerprint) {
773
+ postflightFingerprint !== sourceFingerprint ||
774
+ (context?.binding?.treeContractHash !== undefined && options.tasks !== undefined &&
775
+ await options.tasks.treeContract(context.taskId) !== context.binding.treeContractHash) ||
776
+ (context !== undefined && options.tasks !== undefined &&
777
+ (await Promise.all(context.records.map(async (r) => taskContractHash((await options.tasks.status({ taskId: r.task.id })).task))))
778
+ .some((hash, i) => hash !== taskContractHash(context.records[i].task)))) {
614
779
  const changed = await persistReviewEvidence(options, sourceChangedResult(result), sourceFingerprint, context);
615
780
  return notRunEnvelope(changed);
616
781
  }
@@ -632,12 +797,14 @@ export async function runReviewCommand(options) {
632
797
  finalResult.status === "PASS" &&
633
798
  context.active &&
634
799
  finalResult.results !== undefined) {
635
- await options.tasks.recordEvidence(context.taskId, Object.fromEntries(finalResult.results.map((item) => [
636
- item.criterionId,
637
- item.evidence.map((reference) => `review:${finalResult.harness}:${reference}`)
638
- ])),
639
- // What `worktree finish` completes this task against.
640
- scope?.mode === "base" ? scope.resolvedBase : null);
800
+ for (const record of context.treeTasks ?? [await options.tasks.status({ taskId: context.taskId })]) {
801
+ await options.tasks.recordEvidence(record.task.id, Object.fromEntries(record.task.criteria.map((criterion) => {
802
+ const id = context.treeTasks === undefined
803
+ ? criterion.id : `${record.task.id}:${criterion.id}`;
804
+ const result = finalResult.results?.find((item) => item.criterionId === id);
805
+ return [criterion.id, (result?.evidence ?? []).map((reference) => `review:${finalResult.harness}:${reference}`)];
806
+ })), scope?.mode === "base" ? scope.resolvedBase : null, scope?.mode === "base" && scope.noChange === true ? scope.changedFiles : undefined);
807
+ }
641
808
  }
642
809
  const message = finalResult.status === "PASS"
643
810
  ? "Independent review passed."
@@ -0,0 +1,111 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { AgentOpsError, resolveContainedPath } from "../../../../runtime/src/fs/paths.js";
3
+ import { errorEnvelope, okEnvelope } from "../output.js";
4
+ function dynamic(args) {
5
+ return args;
6
+ }
7
+ function actionOf(args) {
8
+ const value = dynamic(args).runAction ?? dynamic(args).action;
9
+ if (value === undefined || value === "start")
10
+ return "start";
11
+ if (["status", "logs", "resume", "stop", "respond"].includes(String(value)))
12
+ return value;
13
+ throw new AgentOpsError("RUN_ACTION_INVALID", `Unsupported run action: ${String(value)}`);
14
+ }
15
+ function runIdOf(args) {
16
+ const runId = args.runId ?? dynamic(args).taskId;
17
+ if (typeof runId !== "string" || runId.length === 0)
18
+ throw new AgentOpsError("RUN_ID_REQUIRED", "This run action requires a run id.");
19
+ return runId;
20
+ }
21
+ function textFor(action, state, message) {
22
+ const active = state.workers.filter((worker) => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)).length;
23
+ return [
24
+ message,
25
+ `Status: ${state.status}`,
26
+ `Host: ${state.host}`,
27
+ `Workers: ${active}/${state.jobs}`,
28
+ `Budget: ${Math.max(0, state.budget.limitMs - state.budget.accumulatedMs)}ms remaining`,
29
+ `Usage: ${state.budget.usage.tokens ?? "unknown"} tokens; ${state.budget.usage.usd === null ? "unknown" : "$" + state.budget.usage.usd} cost (${state.budget.usage.completeness})`,
30
+ action === "logs" ? "" : "Native goal completion is not final proof; verify, review, task state, and receipt are still required."
31
+ ].filter((line) => line.length > 0).join("\n");
32
+ }
33
+ function data(action, result) {
34
+ return {
35
+ action,
36
+ runId: result.state.runId,
37
+ state: result.state,
38
+ message: result.message,
39
+ text: textFor(action, result.state, result.message)
40
+ };
41
+ }
42
+ function stateData(action, state, message) {
43
+ return { action, runId: state.runId, state, message, text: textFor(action, state, message) };
44
+ }
45
+ async function goalFromArgs(args, root) {
46
+ if (args.goal !== undefined && args.goalFile !== undefined)
47
+ throw new AgentOpsError("RUN_GOAL_CONFLICT", "Use either a positional goal or --goal-file, not both.");
48
+ if (args.goalFile !== undefined) {
49
+ const path = await resolveContainedPath(root, args.goalFile);
50
+ return (await readFile(path, "utf8")).trim();
51
+ }
52
+ if (args.goal === undefined || args.goal.trim() === "")
53
+ throw new AgentOpsError("RUN_GOAL_REQUIRED", "Run start requires a non-empty goal.");
54
+ return args.goal.trim();
55
+ }
56
+ async function answerFromArgs(args, root) {
57
+ if (args.answer !== undefined && args.answerFile !== undefined)
58
+ throw new AgentOpsError("RUN_ANSWER_CONFLICT", "Use either --answer or --answer-file, not both.");
59
+ if (args.answerFile !== undefined)
60
+ return (await readFile(await resolveContainedPath(root, args.answerFile), "utf8")).trim();
61
+ if (args.answer === undefined || args.answer.trim() === "")
62
+ throw new AgentOpsError("RUN_ANSWER_REQUIRED", "Run respond requires a non-empty answer.");
63
+ return args.answer.trim();
64
+ }
65
+ /**
66
+ * External lifecycle surface. The command only starts/controls a supervisor;
67
+ * it never marks the task complete or treats a native process exit as proof.
68
+ */
69
+ export async function runRunCommand(options) {
70
+ const action = actionOf(options.args);
71
+ try {
72
+ if (action === "start") {
73
+ const result = await options.service.start({
74
+ root: options.root,
75
+ commonDir: options.commonDir,
76
+ targetBranch: options.targetBranch,
77
+ goal: await goalFromArgs(options.args, options.root),
78
+ host: options.args.host ?? "codex",
79
+ ownerSessionId: options.ownerSessionId,
80
+ jobs: options.args.jobs,
81
+ timeBudgetMs: options.args.timeBudgetMs
82
+ });
83
+ const state = options.args.wait === true && options.waitForCompletion !== undefined
84
+ ? await options.waitForCompletion(result.state.runId)
85
+ : result.state;
86
+ const response = data(action, { ...result, state });
87
+ return okEnvelope(options.args.wait === true ? "RUN_FINISHED" : "RUN_STARTED", response);
88
+ }
89
+ const runId = runIdOf(options.args);
90
+ if (action === "status")
91
+ return okEnvelope("RUN_STATUS", stateData(action, await options.service.status(runId), `Read run ${runId}.`));
92
+ if (action === "logs") {
93
+ const state = await options.service.status(runId);
94
+ const events = await options.service.logs(runId);
95
+ return okEnvelope("RUN_LOGS", { ...stateData(action, state, `Read ${events.length} event(s) for run ${runId}.`), events });
96
+ }
97
+ if (action === "resume")
98
+ return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId)));
99
+ if (action === "stop")
100
+ return okEnvelope("RUN_STOP_REQUESTED", data(action, await options.service.stop(runId, options.args.answer ?? "user requested stop")));
101
+ const questionId = options.args.questionId;
102
+ if (questionId === undefined || questionId.trim() === "")
103
+ throw new AgentOpsError("RUN_QUESTION_REQUIRED", "Run respond requires --question-id.");
104
+ return okEnvelope("RUN_RESPONDED", data(action, await options.service.respond(runId, questionId, await answerFromArgs(options.args, options.root))));
105
+ }
106
+ catch (cause) {
107
+ if (cause instanceof AgentOpsError)
108
+ return errorEnvelope(cause.code, cause.message);
109
+ throw cause;
110
+ }
111
+ }
@@ -1,3 +1,5 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import { taskContractHash } from "../../../../runtime/src/task/contract.js";
1
3
  import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
2
4
  import { renderTaskMarkdown, safeTaskText } from "../../../../runtime/src/task/render.js";
3
5
  import { errorEnvelope, okEnvelope } from "../output.js";
@@ -12,20 +14,21 @@ function parseCriterion(source) {
12
14
  catch {
13
15
  throw new AgentOpsError("TASK_CRITERION_INVALID", "Each --criterion value must be a JSON criterion object.");
14
16
  }
15
- if (!isRecord(value) ||
16
- Object.keys(value).sort().join(",") !==
17
- "description,id,verifierIds" ||
18
- typeof value.id !== "string" ||
19
- typeof value.description !== "string" ||
20
- !Array.isArray(value.verifierIds) ||
21
- value.verifierIds.some((id) => typeof id !== "string")) {
22
- throw new AgentOpsError("TASK_CRITERION_INVALID", "Each criterion requires only id, description, and verifierIds.");
17
+ if (!isRecord(value))
18
+ throw new AgentOpsError("TASK_CRITERION_INVALID", "Criterion must be an object.");
19
+ // TaskService normalizes the creation baseline, then uses the shared strict validator.
20
+ return value;
21
+ }
22
+ async function jsonFile(path) {
23
+ const status = await lstat(path);
24
+ if (!status.isFile() || status.isSymbolicLink() || status.size > 65536)
25
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Payload must be a regular JSON file at most 64 KiB.");
26
+ try {
27
+ return JSON.parse(await readFile(path, "utf8"));
28
+ }
29
+ catch {
30
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Invalid JSON payload.");
23
31
  }
24
- return {
25
- id: value.id,
26
- description: value.description,
27
- verifierIds: [...value.verifierIds]
28
- };
29
32
  }
30
33
  function parseEvidence(values) {
31
34
  const evidence = {};
@@ -57,7 +60,7 @@ function taskAction(args) {
57
60
  "complete",
58
61
  "create",
59
62
  "export",
60
- "status"
63
+ "status", "revise", "replan", "pin-finding"
61
64
  ].includes(args.action)) {
62
65
  throw new AgentOpsError("TASK_ACTION_INVALID", "A supported task action is required.");
63
66
  }
@@ -68,7 +71,8 @@ function taskEnvelope(action, code, message, record) {
68
71
  action,
69
72
  message,
70
73
  record,
71
- text: renderTaskMarkdown(record)
74
+ contractHash: taskContractHash(record.task),
75
+ text: renderTaskMarkdown(record) + "\nContract: " + taskContractHash(record.task)
72
76
  });
73
77
  }
74
78
  function renderTaskList(records) {
@@ -92,7 +96,11 @@ export async function runTaskCommand(options) {
92
96
  : await options.sessionWorktree(sessionId);
93
97
  const record = await (worktree?.service ?? options.service).create({
94
98
  title: options.args.title,
95
- criteria: (options.args.criteria ?? []).map(parseCriterion),
99
+ ...(options.args.intent === undefined ? {} : { intent: options.args.intent }),
100
+ criteria: [...(options.args.criteria ?? []).map(parseCriterion),
101
+ ...await Promise.all((options.args.criterionFiles ?? []).map(async (path) => await jsonFile(path)))]
102
+ .map(c => c.acceptance === undefined || options.args.baseline === undefined ? c :
103
+ { ...c, acceptance: { ...c.acceptance, baselineCommit: c.acceptance.baselineCommit ?? options.args.baseline } }),
96
104
  ...(options.policyConfigHash === undefined
97
105
  ? {}
98
106
  : { policyConfigHash: options.policyConfigHash }),
@@ -126,6 +134,35 @@ export async function runTaskCommand(options) {
126
134
  }
127
135
  };
128
136
  }
137
+ if (action === "revise") {
138
+ const payload = await jsonFile(options.args.criteriaFile);
139
+ if (!Array.isArray(payload))
140
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Criteria file must contain an array.");
141
+ const record = await options.service.revise(requireTaskId(options.args), {
142
+ expectedContractHash: options.args.expectedContract, criteria: payload,
143
+ reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
144
+ });
145
+ return taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain new verification and fresh review.", record);
146
+ }
147
+ if (action === "replan") {
148
+ const payload = await jsonFile(options.args.planFile);
149
+ if (!Array.isArray(payload))
150
+ throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Plan file must contain split task objects.");
151
+ const records = await options.service.replan(requireTaskId(options.args), {
152
+ expectedTreeContractHash: options.args.expectedTreeContract, reason: options.args.reason,
153
+ tasks: payload
154
+ });
155
+ return okEnvelope("TASK_REPLANNED", { action, message: "Task tree split atomically; reverify and review.", records, text: renderTaskList(records) });
156
+ }
157
+ if (action === "pin-finding") {
158
+ const criterion = parseCriterion(JSON.stringify(await jsonFile(options.args.criterionFiles[0])));
159
+ const pinned = await options.service.pinFinding(requireTaskId(options.args), options.args.expectedContract, options.args.findingReference, criterion, options.args.reason);
160
+ if (pinned.record === null)
161
+ return { code: "NEEDS_REPLAN", status: "error",
162
+ data: { action, message: "Pin requires atomic replan to retain two to five criteria.", pendingCriterion: pinned.pendingCriterion, text: "NEEDS_REPLAN: include the pending criterion in task replan; no task was changed." },
163
+ errors: [{ code: "NEEDS_REPLAN", message: "Replan the full current tree and pending pin." }] };
164
+ return taskEnvelope(action, "TASK_FINDING_PINNED", "Pinned saved finding; reverify and review the new contract.", pinned.record);
165
+ }
129
166
  if (action === "status") {
130
167
  if (options.args.taskId === undefined && sessionId === undefined) {
131
168
  const parentTaskId = options.args.parentTaskId;
@@ -142,7 +179,15 @@ export async function runTaskCommand(options) {
142
179
  const record = await options.service.status(options.args.taskId === undefined
143
180
  ? { sessionId }
144
181
  : { taskId: options.args.taskId });
145
- return taskEnvelope(action, "TASK_STATUS", `Read task ${record.task.id}.`, record);
182
+ const coverage = await options.service.coverage(record.task.id);
183
+ const proven = coverage.rows.filter(row => row.status === "proven").length;
184
+ const mechanical = coverage.rows.filter(row => row.mode === "behavioral" || row.mode === "invariant").length;
185
+ const reviewOnly = coverage.rows.filter(row => row.mode === "review-only").length;
186
+ const legacy = coverage.rows.filter(row => row.mode === "legacy").length;
187
+ const envelope = taskEnvelope(action, "TASK_STATUS", `Read task ${record.task.id}.`, record);
188
+ return { ...envelope, data: { ...envelope.data, coverage, treeContractHash: await options.service.treeContract(record.task.id),
189
+ text: envelope.data.text + "\nAcceptance: " + proven + "/" + mechanical + " mechanical proven; " + reviewOnly + " review-only; " + legacy + " legacy; originally mechanical " + coverage.originalMechanical +
190
+ "; reviewed " + coverage.reviewed + "; undischarged " + coverage.rows.filter(row => row.status !== "proven" && !coverage.reviewed).length } };
146
191
  }
147
192
  if (action === "attach") {
148
193
  if (sessionId === undefined) {
@@ -7,6 +7,7 @@ function safe(value) {
7
7
  }
8
8
  function publicReport(report) {
9
9
  return {
10
+ ...(report.acceptance === undefined ? {} : { acceptance: report.acceptance }),
10
11
  taskId: report.taskId,
11
12
  status: report.status,
12
13
  surface: {