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