@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.
- package/README.md +48 -0
- package/dist/packages/cli/src/args.js +165 -2
- package/dist/packages/cli/src/bin.js +64 -17
- package/dist/packages/cli/src/cli.js +33 -6
- package/dist/packages/cli/src/commands/advance.js +23 -3
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +100 -9
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +288 -10
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +71 -5
- package/dist/runtime/src/parallel/service.js +41 -1
- package/dist/runtime/src/review/attestation.js +36 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +67 -7
- package/dist/runtime/src/task/completion.js +14 -2
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/service.js +316 -13
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/spec/acceptance-and-evidence.md +48 -0
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +265 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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) =>
|
|
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"
|