@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
@@ -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,6 +76,7 @@ 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
81
  title: record.task.intent === undefined
75
82
  ? record.task.title
@@ -114,7 +121,16 @@ async function treeContext(options) {
114
121
  if (treeTasks.some(({ policyConfigHash }) => policyConfigHash !== root.policyConfigHash)) {
115
122
  throw new AgentOpsError("REVIEW_TREE_POLICY_MISMATCH", "Tree tasks must share one verifier policy.");
116
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
+ }
117
131
  return {
132
+ historyRecords: all.filter(record => historical.has(record.task.id) && record.supersededBy !== undefined),
133
+ records: treeTasks,
118
134
  taskId: root.task.id,
119
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"),
120
136
  active: treeTasks.every(({ status }) => status === "active"),
@@ -163,7 +179,10 @@ async function currentEvidence(options, context, criterionId, commandId, configH
163
179
  evidence.criterionId !== (owner?.criterionId ?? criterionId) ||
164
180
  evidence.commandId !== commandId ||
165
181
  evidence.configHash !== configHash ||
166
- 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))) {
167
186
  stale = true;
168
187
  continue;
169
188
  }
@@ -190,14 +209,18 @@ async function preflightReview(options, context, sourceFingerprint) {
190
209
  const configHash = calculateConfigHash(options.config);
191
210
  const commands = [];
192
211
  for (const criterion of context.criteria) {
193
- 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) : [])])) {
194
217
  const command = options.config.verification.commands.find((candidate) => candidate.id === commandId);
195
218
  if (command === undefined) {
196
219
  return { ok: false, reason: "missing-verification-evidence" };
197
220
  }
198
221
  const found = await currentEvidence(options, context, criterion.id, commandId, configHash, sourceFingerprint);
199
222
  const compatible = command.evidence.kind === "test-count"
200
- ? found.current.filter(({ evidence }) => evidence.schemaVersion === 3)
223
+ ? found.current.filter(({ evidence }) => evidence.schemaVersion >= 3)
201
224
  : found.current;
202
225
  if (command.required && found.current.length > 0 && compatible.length === 0) {
203
226
  return { ok: false, reason: "stale-verification" };
@@ -258,9 +281,11 @@ export async function hasFreshVerification(options, taskId, base) {
258
281
  if (context === undefined) {
259
282
  return false;
260
283
  }
284
+ options = { ...options, noChangePaths: options.noChangePaths ?? context.records.find(r => r.task.id === context.taskId)?.noChangePaths };
261
285
  const scope = await resolveReviewScope({
262
286
  root: options.root,
263
287
  runner: options.gitRunner,
288
+ ...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
264
289
  ...(base === undefined ? {} : { base })
265
290
  });
266
291
  const fingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
@@ -352,7 +377,10 @@ async function reusableReview(options, sourceFingerprint, context) {
352
377
  return null;
353
378
  }
354
379
  const attestation = await findReviewAttestation(options.root, sourceFingerprint, context?.taskId);
355
- 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))) {
356
384
  return null;
357
385
  }
358
386
  if (context?.treeTasks !== undefined &&
@@ -420,7 +448,7 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
420
448
  const reportArtifacts = [];
421
449
  try {
422
450
  for (const taskId of taskIds) {
423
- reportArtifacts.push({ taskId, path: await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, taskId, complete ? tree : undefined) });
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) });
424
452
  }
425
453
  }
426
454
  catch {
@@ -441,7 +469,8 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
441
469
  const plannedTargets = result.plannedTargets;
442
470
  for (const artifact of reportArtifacts) {
443
471
  await saveReviewAttestation(options.root, {
444
- schemaVersion: 2,
472
+ schemaVersion: context?.binding === undefined ? 2 : 3,
473
+ ...(context?.binding === undefined ? {} : { ...context.binding, taskContractHash: context.binding.taskContracts[artifact.taskId ?? context.taskId] }),
445
474
  ...(artifact.taskId === undefined ? {} : { taskId: artifact.taskId }),
446
475
  ...(tree === undefined ? {} : { tree }),
447
476
  harness: result.harness,
@@ -502,6 +531,7 @@ export async function runReviewCommand(options) {
502
531
  const context = options.args.tree === true
503
532
  ? await treeContext(options)
504
533
  : await taskContext(options);
534
+ options = { ...options, noChangePaths: options.noChangePaths ?? context?.records.find(r => r.task.id === context.taskId)?.noChangePaths };
505
535
  const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
506
536
  const separator = value.indexOf("=");
507
537
  return {
@@ -517,6 +547,7 @@ export async function runReviewCommand(options) {
517
547
  scope = await resolveReviewScope({
518
548
  root: options.root,
519
549
  runner: options.gitRunner,
550
+ ...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
520
551
  ...(options.args.base === undefined ? {} : { base: options.args.base })
521
552
  });
522
553
  }
@@ -574,7 +605,7 @@ export async function runReviewCommand(options) {
574
605
  // source was reviewed; it says nothing about the policy in force now or
575
606
  // about verification that has failed since, and both of those are
576
607
  // decided above.
577
- const reused = options.args.rerun || context?.treeTasks !== undefined
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)
578
609
  ? null
579
610
  : await reusableReview(options, sourceFingerprint, context);
580
611
  if (reused !== null) {
@@ -593,6 +624,23 @@ export async function runReviewCommand(options) {
593
624
  pendingInvalidation = false;
594
625
  }
595
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
+ }
596
644
  const criteria = [
597
645
  ...(context?.criteria ?? GENERIC_CRITERIA)
598
646
  ];
@@ -601,7 +649,44 @@ export async function runReviewCommand(options) {
601
649
  }
602
650
  let packet;
603
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
+ }
604
688
  packet = buildReviewPacket({
689
+ ...(contractManifest === undefined ? {} : { contractManifest, contractArtifacts }),
605
690
  request: context?.title ?? GENERIC_REQUEST,
606
691
  criteria,
607
692
  artifactRefs: scope?.changedFiles ?? [],
@@ -676,6 +761,7 @@ export async function runReviewCommand(options) {
676
761
  const postflight = await resolveReviewScope({
677
762
  root: options.root,
678
763
  runner: options.gitRunner,
764
+ ...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
679
765
  ...(options.args.base === undefined ? {} : { base: options.args.base })
680
766
  });
681
767
  const currentHash = options.currentPolicyConfigHash === undefined
@@ -684,7 +770,12 @@ export async function runReviewCommand(options) {
684
770
  const postflightFingerprint = await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
685
771
  if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
686
772
  (options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
687
- 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)))) {
688
779
  const changed = await persistReviewEvidence(options, sourceChangedResult(result), sourceFingerprint, context);
689
780
  return notRunEnvelope(changed);
690
781
  }
@@ -712,7 +803,7 @@ export async function runReviewCommand(options) {
712
803
  ? criterion.id : `${record.task.id}:${criterion.id}`;
713
804
  const result = finalResult.results?.find((item) => item.criterionId === id);
714
805
  return [criterion.id, (result?.evidence ?? []).map((reference) => `review:${finalResult.harness}:${reference}`)];
715
- })), scope?.mode === "base" ? scope.resolvedBase : null);
806
+ })), scope?.mode === "base" ? scope.resolvedBase : null, scope?.mode === "base" && scope.noChange === true ? scope.changedFiles : undefined);
716
807
  }
717
808
  }
718
809
  const message = finalResult.status === "PASS"
@@ -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) {
@@ -93,7 +97,10 @@ export async function runTaskCommand(options) {
93
97
  const record = await (worktree?.service ?? options.service).create({
94
98
  title: options.args.title,
95
99
  ...(options.args.intent === undefined ? {} : { intent: options.args.intent }),
96
- criteria: (options.args.criteria ?? []).map(parseCriterion),
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 } }),
97
104
  ...(options.policyConfigHash === undefined
98
105
  ? {}
99
106
  : { policyConfigHash: options.policyConfigHash }),
@@ -127,6 +134,35 @@ export async function runTaskCommand(options) {
127
134
  }
128
135
  };
129
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
+ }
130
166
  if (action === "status") {
131
167
  if (options.args.taskId === undefined && sessionId === undefined) {
132
168
  const parentTaskId = options.args.parentTaskId;
@@ -143,7 +179,15 @@ export async function runTaskCommand(options) {
143
179
  const record = await options.service.status(options.args.taskId === undefined
144
180
  ? { sessionId }
145
181
  : { taskId: options.args.taskId });
146
- 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 } };
147
191
  }
148
192
  if (action === "attach") {
149
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: {
@@ -7,7 +7,9 @@ import { sha256 } from "../../../runtime/src/fs/hash.js";
7
7
  import { loadConfigFile } from "../../../runtime/src/config/load.js";
8
8
  import { mergeConfigLayers } from "../../../runtime/src/config/merge.js";
9
9
  import { AgentOpsError } from "../../../runtime/src/fs/paths.js";
10
- import { localStatePaths } from "../../../runtime/src/security/permissions.js";
10
+ import { localStatePaths, readPrivateFile } from "../../../runtime/src/security/permissions.js";
11
+ import { FileRunRepository } from "../../../runtime/src/run/service.js";
12
+ import { readRunPolicy, runRuntimeHash } from "../../../runtime/src/run/policy.js";
11
13
  import { calculateTrustBinding, FileTrustStore } from "../../../runtime/src/security/trust.js";
12
14
  export const DEFAULT_CONFIG = {
13
15
  schemaVersion: 3,
@@ -72,6 +74,14 @@ async function loadOptionalConfig(path) {
72
74
  }
73
75
  }
74
76
  export async function loadEffectiveConfig(root, scope, projectOverride) {
77
+ if (scope === "project" && projectOverride === undefined) {
78
+ const run = await runPolicyContext(root);
79
+ if (run !== null)
80
+ return mergeConfigLayers([defaultConfigLayer(), {
81
+ source: "project", sourcePath: join(run.state.commonDir, "agent-ops/runs", run.state.runId, "policies", run.state.policyBinding.artifactDigest + ".json"),
82
+ config: run.policy.config
83
+ }]);
84
+ }
75
85
  const home = process.env.AGENT_OPS_HOME ?? homedir();
76
86
  const userPath = join(home, ".agent-ops", "config.json");
77
87
  const projectPath = join(scope === "user" ? root : projectConfigRoot(root), ".agent-ops", "config.json");
@@ -184,6 +194,14 @@ export async function repositoryTrust(root, config, cliVersion) {
184
194
  const home = process.env.AGENT_OPS_HOME ?? homedir();
185
195
  const state = localStatePaths(home);
186
196
  try {
197
+ const run = await runPolicyContext(root);
198
+ if (run !== null) {
199
+ if (run.state.status !== "active" || run.state.disableRestart || run.state.awaitingResume ||
200
+ Date.parse(run.policy.expiresAt) <= Date.now() || calculateConfigHash(config) !== run.state.policyBinding.configHash)
201
+ return "STALE";
202
+ const store = new FileTrustStore(state.trustStore, state.anchorDirectory);
203
+ return (await store.status(run.policy.baseTrustBinding)).status;
204
+ }
187
205
  const binding = await repositoryTrustBinding(root, config, cliVersion);
188
206
  return (await new FileTrustStore(state.trustStore, state.anchorDirectory).status(binding)).status;
189
207
  }
@@ -191,6 +209,50 @@ export async function repositoryTrust(root, config, cliVersion) {
191
209
  return "UNTRUSTED";
192
210
  }
193
211
  }
212
+ /** A run-private capability is usable only by its registered checkout or proof group. */
213
+ export async function runPolicyContext(root) {
214
+ let common;
215
+ try {
216
+ common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ const source = await readPrivateFile(join(root, ".agent-ops/tasks/worktree.json"), root);
222
+ const record = source === null ? null : JSON.parse(source);
223
+ const runId = process.env.AGENT_OPS_RUN_ID ?? record?.runId;
224
+ if (runId === undefined)
225
+ return null;
226
+ const repository = new FileRunRepository(join(common, "agent-ops/runs"), common);
227
+ const saved = await repository.read(runId);
228
+ if (saved === null || saved.policyBinding === undefined)
229
+ return null;
230
+ const here = realpathSync(root);
231
+ const worker = saved.workers.find(w => w.worktree === here);
232
+ const proofProcess = worker?.proofProcess ?? saved.proofProcess;
233
+ let proof = false;
234
+ if (proofProcess != null && String(proofProcess.processId) === process.env.AGENT_OPS_RUN_PROOF_PID) {
235
+ try {
236
+ proof = execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() === String(proofProcess.processId) &&
237
+ execFileSync("ps", ["-o", "lstart=", "-p", String(proofProcess.processId)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() === proofProcess.processIdentity;
238
+ }
239
+ catch {
240
+ proof = false;
241
+ }
242
+ }
243
+ if ((!proof && (worker === undefined || record?.runId !== runId || record.workerId !== worker.workerId ||
244
+ record.ownerSessionId !== worker.ownerSessionId || record.coordinatorId !== saved.coordinatorId || record.workerGeneration !== worker.generation)) ||
245
+ (worker === undefined && !(proof && saved.root === here)) ||
246
+ (process.env.AGENT_OPS_WORKER_ID !== undefined && (process.env.AGENT_OPS_WORKER_ID !== (worker?.workerId ?? saved.coordinatorId) ||
247
+ process.env.AGENT_OPS_WORKER_GENERATION !== String((worker ?? saved.workers.find(w => w.workerId === saved.coordinatorId))?.generation))))
248
+ throw new AgentOpsError("RUN_WORKER_STALE", "Run policy requires the current registered worker generation or proof process.");
249
+ if (saved.status !== "active" || saved.disableRestart || saved.awaitingResume)
250
+ throw new AgentOpsError("RUN_POLICY_DISABLED", "Run execution policy is inactive; explicit resume is required.");
251
+ const policy = await readRunPolicy(saved, await runRuntimeHash());
252
+ if (policy === null)
253
+ return null;
254
+ return { repository, state: saved, policy, worker };
255
+ }
194
256
  export async function repositoryTrustBinding(root, config, cliVersion) {
195
257
  return await calculateTrustBinding({
196
258
  repositoryPath: root,
@@ -16,6 +16,9 @@ import { CompletionGateService } from "../../../runtime/src/hooks/completion-gat
16
16
  import { TaskService } from "../../../runtime/src/task/service.js";
17
17
  import { FileTaskStore } from "../../../runtime/src/task/store.js";
18
18
  import { FileEvidenceStore } from "../../../runtime/src/verify/evidence.js";
19
+ import { readWorktreeRecord } from "../../../runtime/src/parallel/service.js";
20
+ import { FileRunRepository } from "../../../runtime/src/run/service.js";
21
+ import { validateWorkerStopHandoff } from "../../../runtime/src/run/worker-gate.js";
19
22
  import { STOP_VERIFICATION_ENV, StopVerificationService } from "../../../runtime/src/hooks/stop-service.js";
20
23
  import { NodeVerificationProcessRunner } from "../../../runtime/src/verify/spawn.js";
21
24
  import { normalizeHookInput, runHookCommand, HOOK_EVENTS } from "./commands/hook.js";
@@ -343,7 +346,34 @@ export async function runHookProcess(argv, io, cliVersion, dependencies = {}) {
343
346
  const completionGate = (harnessId === "agy" || harnessId === "claude") &&
344
347
  config.features.completionGate.enabled
345
348
  ? dependencies.completionGate ?? {
346
- handle: async (normalized) => await completionGateFor(root, config, gitRunner, harnessId).handle(normalized)
349
+ handle: async (normalized) => {
350
+ if (normalized.event === "stop" && normalized.terminationReason === "model_stop" && normalized.fullyIdle === true && normalized.sessionId !== undefined) {
351
+ const record = await readWorktreeRecord(root);
352
+ if (record?.runId !== undefined && record.workerId !== undefined && record.workerGeneration !== undefined) {
353
+ const common = await gitRunner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
354
+ if (common.exitCode === 0) {
355
+ const commonDir = Buffer.from(common.stdout).toString("utf8").trim();
356
+ const repository = new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir);
357
+ const state = await repository.read(record.runId);
358
+ const worker = state?.workers.find(worker => worker.workerId === record.workerId);
359
+ if (worker !== undefined && worker.workerId !== state.coordinatorId && worker.stopIntent !== null) {
360
+ const head = await gitRunner.run(["rev-parse", "HEAD"]);
361
+ const tasks = new TaskService(new FileTaskStore(join(root, ".agent-ops", "tasks", "state.json"), root));
362
+ if (head.exitCode === 0 && Buffer.from(head.stdout).toString("utf8").trim() === worker.stopIntent.sourceCommit &&
363
+ await tasks.treeContract(worker.taskId) === worker.stopIntent.contractDigest) {
364
+ const handoff = await validateWorkerStopHandoff(repository, { root, sessionId: normalized.sessionId,
365
+ ...(normalized.agentId === undefined ? {} : { agentId: normalized.agentId }), runId: record.runId,
366
+ workerId: record.workerId, generation: record.workerGeneration,
367
+ deliveryDigest: worker.stopIntent.deliveryDigest ?? undefined, contractDigest: worker.stopIntent.contractDigest ?? undefined });
368
+ if (handoff !== null)
369
+ return handoff;
370
+ }
371
+ }
372
+ }
373
+ }
374
+ }
375
+ return await completionGateFor(root, config, gitRunner, harnessId).handle(normalized);
376
+ }
347
377
  }
348
378
  : undefined;
349
379
  const worktreeGuard = config.worktree?.mode === "auto"