@kylecheng3146/agent-ops 0.5.1 → 0.5.3
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 +16 -0
- package/dist/packages/cli/src/args.js +49 -8
- package/dist/packages/cli/src/bin.js +13 -0
- package/dist/packages/cli/src/cli.js +11 -6
- package/dist/packages/cli/src/commands/advance.js +153 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +105 -29
- package/dist/packages/cli/src/commands/task.js +1 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +138 -23
- package/dist/runtime/src/parallel/integrate.js +138 -0
- package/dist/runtime/src/parallel/receipt.js +87 -0
- package/dist/runtime/src/parallel/service.js +4 -2
- package/dist/runtime/src/review/attestation.js +27 -3
- package/dist/runtime/src/review/render.js +71 -0
- package/dist/runtime/src/review/report.js +1 -1
- package/dist/runtime/src/schema/validate.js +6 -1
- package/dist/runtime/src/task/completion.js +13 -5
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +48 -0
- package/docs/en/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +10 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +10 -0
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/task.schema.json +3 -0
|
@@ -71,7 +71,9 @@ async function taskContext(options) {
|
|
|
71
71
|
}
|
|
72
72
|
return {
|
|
73
73
|
taskId: record.task.id,
|
|
74
|
-
title: record.task.
|
|
74
|
+
title: record.task.intent === undefined
|
|
75
|
+
? record.task.title
|
|
76
|
+
: `${record.task.title}\nModification intent: ${record.task.intent}`,
|
|
75
77
|
active: record.status === "active",
|
|
76
78
|
policyConfigHash: record.policyConfigHash,
|
|
77
79
|
evidence: record.evidence,
|
|
@@ -80,6 +82,51 @@ async function taskContext(options) {
|
|
|
80
82
|
allCriteriaReviewed: criteria.length === record.task.criteria.length
|
|
81
83
|
};
|
|
82
84
|
}
|
|
85
|
+
async function treeContext(options) {
|
|
86
|
+
if (options.tasks === undefined || options.taskId === undefined) {
|
|
87
|
+
throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "Tree review requires a parent task.");
|
|
88
|
+
}
|
|
89
|
+
const all = await options.tasks.list();
|
|
90
|
+
const root = all.find(({ task }) => task.id === options.taskId);
|
|
91
|
+
if (root === undefined || root.status === "archived") {
|
|
92
|
+
throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "The selected review tree does not exist.");
|
|
93
|
+
}
|
|
94
|
+
const selected = new Set([root.task.id]);
|
|
95
|
+
for (let changed = true; changed;) {
|
|
96
|
+
changed = false;
|
|
97
|
+
for (const record of all) {
|
|
98
|
+
if (record.status !== "archived" && record.task.parentTaskId !== undefined &&
|
|
99
|
+
selected.has(record.task.parentTaskId) && !selected.has(record.task.id)) {
|
|
100
|
+
selected.add(record.task.id);
|
|
101
|
+
changed = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const treeTasks = all.filter(({ task }) => selected.has(task.id));
|
|
106
|
+
const owners = new Map();
|
|
107
|
+
const evidence = {};
|
|
108
|
+
const criteria = treeTasks.flatMap((record) => record.task.criteria.map((criterion) => {
|
|
109
|
+
const id = `${record.task.id}:${criterion.id}`;
|
|
110
|
+
owners.set(id, { taskId: record.task.id, criterionId: criterion.id });
|
|
111
|
+
evidence[id] = record.evidence[criterion.id] ?? [];
|
|
112
|
+
return { id, description: `${record.task.title}: ${criterion.description}`, verifierIds: [...criterion.verifierIds] };
|
|
113
|
+
}));
|
|
114
|
+
if (treeTasks.some(({ policyConfigHash }) => policyConfigHash !== root.policyConfigHash)) {
|
|
115
|
+
throw new AgentOpsError("REVIEW_TREE_POLICY_MISMATCH", "Tree tasks must share one verifier policy.");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
taskId: root.task.id,
|
|
119
|
+
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
|
+
active: treeTasks.every(({ status }) => status === "active"),
|
|
121
|
+
policyConfigHash: root.policyConfigHash,
|
|
122
|
+
evidence,
|
|
123
|
+
failureFingerprint: treeTasks.find(({ failureFingerprint }) => failureFingerprint !== null)?.failureFingerprint ?? null,
|
|
124
|
+
criteria,
|
|
125
|
+
allCriteriaReviewed: true,
|
|
126
|
+
treeTasks,
|
|
127
|
+
criterionOwners: owners
|
|
128
|
+
};
|
|
129
|
+
}
|
|
83
130
|
function newestEvidence(values) {
|
|
84
131
|
return [...values].sort((left, right) => {
|
|
85
132
|
const leftTime = Date.parse(left.evidence.finishedAt);
|
|
@@ -111,8 +158,9 @@ async function currentEvidence(options, context, criterionId, commandId, configH
|
|
|
111
158
|
continue;
|
|
112
159
|
}
|
|
113
160
|
const evidence = validation.value;
|
|
114
|
-
|
|
115
|
-
|
|
161
|
+
const owner = context.criterionOwners?.get(criterionId);
|
|
162
|
+
if (evidence.taskId !== (owner?.taskId ?? context.taskId) ||
|
|
163
|
+
evidence.criterionId !== (owner?.criterionId ?? criterionId) ||
|
|
116
164
|
evidence.commandId !== commandId ||
|
|
117
165
|
evidence.configHash !== configHash ||
|
|
118
166
|
evidence.sourceFingerprint !== sourceFingerprint) {
|
|
@@ -307,6 +355,12 @@ async function reusableReview(options, sourceFingerprint, context) {
|
|
|
307
355
|
if (attestation === null || attestation.taskId !== context?.taskId) {
|
|
308
356
|
return null;
|
|
309
357
|
}
|
|
358
|
+
if (context?.treeTasks !== undefined &&
|
|
359
|
+
(attestation.tree?.rootTaskId !== context.taskId ||
|
|
360
|
+
JSON.stringify(attestation.tree.taskIds) !== JSON.stringify(context.treeTasks.map(({ task }) => task.id)) ||
|
|
361
|
+
JSON.stringify(attestation.tree.criterionIds) !== JSON.stringify(context.criteria.map(({ id }) => id)))) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
310
364
|
// The task's own policy baseline must still be the active one. This is
|
|
311
365
|
// checked before this point too; repeating it here keeps the reuse path
|
|
312
366
|
// safe wherever it is called from.
|
|
@@ -355,9 +409,19 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
|
|
|
355
409
|
reason: "adversarial-review-missing"
|
|
356
410
|
}
|
|
357
411
|
: result;
|
|
358
|
-
|
|
412
|
+
const taskIds = complete && context?.treeTasks !== undefined
|
|
413
|
+
? context.treeTasks.map(({ task }) => task.id)
|
|
414
|
+
: [context?.taskId];
|
|
415
|
+
const tree = context?.treeTasks === undefined ? undefined : {
|
|
416
|
+
rootTaskId: context.taskId,
|
|
417
|
+
taskIds: context.treeTasks.map(({ task }) => task.id),
|
|
418
|
+
criterionIds: context.criteria.map(({ id }) => id)
|
|
419
|
+
};
|
|
420
|
+
const reportArtifacts = [];
|
|
359
421
|
try {
|
|
360
|
-
|
|
422
|
+
for (const taskId of taskIds) {
|
|
423
|
+
reportArtifacts.push({ taskId, path: await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, taskId, complete ? tree : undefined) });
|
|
424
|
+
}
|
|
361
425
|
}
|
|
362
426
|
catch {
|
|
363
427
|
return {
|
|
@@ -375,21 +439,24 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
|
|
|
375
439
|
try {
|
|
376
440
|
const attempts = result.attempts;
|
|
377
441
|
const plannedTargets = result.plannedTargets;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
442
|
+
for (const artifact of reportArtifacts) {
|
|
443
|
+
await saveReviewAttestation(options.root, {
|
|
444
|
+
schemaVersion: 2,
|
|
445
|
+
...(artifact.taskId === undefined ? {} : { taskId: artifact.taskId }),
|
|
446
|
+
...(tree === undefined ? {} : { tree }),
|
|
447
|
+
harness: result.harness,
|
|
448
|
+
status: "PASS",
|
|
449
|
+
sourceFingerprint,
|
|
450
|
+
reviewTargets: [plannedTargets[0], plannedTargets[1]],
|
|
451
|
+
reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
|
|
452
|
+
sessionIsolation: "fresh",
|
|
453
|
+
primaryReportDigest: reviewReportDigest(result.report),
|
|
454
|
+
adversarialReportDigest: reviewReportDigest(result.adversarial.report),
|
|
455
|
+
reportArtifact: artifact.path,
|
|
456
|
+
...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
|
|
457
|
+
createdAt: new Date().toISOString()
|
|
458
|
+
});
|
|
459
|
+
}
|
|
393
460
|
}
|
|
394
461
|
catch {
|
|
395
462
|
return {
|
|
@@ -432,7 +499,9 @@ export async function runReviewCommand(options) {
|
|
|
432
499
|
reason: targetPlan.reason
|
|
433
500
|
});
|
|
434
501
|
}
|
|
435
|
-
const context =
|
|
502
|
+
const context = options.args.tree === true
|
|
503
|
+
? await treeContext(options)
|
|
504
|
+
: await taskContext(options);
|
|
436
505
|
const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
|
|
437
506
|
const separator = value.indexOf("=");
|
|
438
507
|
return {
|
|
@@ -505,7 +574,7 @@ export async function runReviewCommand(options) {
|
|
|
505
574
|
// source was reviewed; it says nothing about the policy in force now or
|
|
506
575
|
// about verification that has failed since, and both of those are
|
|
507
576
|
// decided above.
|
|
508
|
-
const reused = options.args.rerun
|
|
577
|
+
const reused = options.args.rerun || context?.treeTasks !== undefined
|
|
509
578
|
? null
|
|
510
579
|
: await reusableReview(options, sourceFingerprint, context);
|
|
511
580
|
if (reused !== null) {
|
|
@@ -518,13 +587,18 @@ export async function runReviewCommand(options) {
|
|
|
518
587
|
})
|
|
519
588
|
});
|
|
520
589
|
}
|
|
521
|
-
|
|
590
|
+
for (const taskId of context?.treeTasks?.map(({ task }) => task.id) ?? [context?.taskId]) {
|
|
591
|
+
await invalidateReviewAttestation(options.root, sourceFingerprint, taskId);
|
|
592
|
+
}
|
|
522
593
|
pendingInvalidation = false;
|
|
523
594
|
}
|
|
524
595
|
}
|
|
525
596
|
const criteria = [
|
|
526
597
|
...(context?.criteria ?? GENERIC_CRITERIA)
|
|
527
598
|
];
|
|
599
|
+
if (context?.treeTasks !== undefined && criteria.length > 128) {
|
|
600
|
+
return notRunEnvelope({ ...resultBase, status: "NOT_RUN", reason: "scope-too-large" });
|
|
601
|
+
}
|
|
528
602
|
let packet;
|
|
529
603
|
try {
|
|
530
604
|
packet = buildReviewPacket({
|
|
@@ -632,12 +706,14 @@ export async function runReviewCommand(options) {
|
|
|
632
706
|
finalResult.status === "PASS" &&
|
|
633
707
|
context.active &&
|
|
634
708
|
finalResult.results !== undefined) {
|
|
635
|
-
await options.tasks.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
709
|
+
for (const record of context.treeTasks ?? [await options.tasks.status({ taskId: context.taskId })]) {
|
|
710
|
+
await options.tasks.recordEvidence(record.task.id, Object.fromEntries(record.task.criteria.map((criterion) => {
|
|
711
|
+
const id = context.treeTasks === undefined
|
|
712
|
+
? criterion.id : `${record.task.id}:${criterion.id}`;
|
|
713
|
+
const result = finalResult.results?.find((item) => item.criterionId === id);
|
|
714
|
+
return [criterion.id, (result?.evidence ?? []).map((reference) => `review:${finalResult.harness}:${reference}`)];
|
|
715
|
+
})), scope?.mode === "base" ? scope.resolvedBase : null);
|
|
716
|
+
}
|
|
641
717
|
}
|
|
642
718
|
const message = finalResult.status === "PASS"
|
|
643
719
|
? "Independent review passed."
|
|
@@ -92,6 +92,7 @@ export async function runTaskCommand(options) {
|
|
|
92
92
|
: await options.sessionWorktree(sessionId);
|
|
93
93
|
const record = await (worktree?.service ?? options.service).create({
|
|
94
94
|
title: options.args.title,
|
|
95
|
+
...(options.args.intent === undefined ? {} : { intent: options.args.intent }),
|
|
95
96
|
criteria: (options.args.criteria ?? []).map(parseCriterion),
|
|
96
97
|
...(options.policyConfigHash === undefined
|
|
97
98
|
? {}
|
|
@@ -322,7 +322,7 @@ export function managedRules(descriptor, context) {
|
|
|
322
322
|
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
323
323
|
}
|
|
324
324
|
if (context.capabilities.includes("task")) {
|
|
325
|
-
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria
|
|
325
|
+
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria and verification; final review covers all", "tasks together when their worktrees are integrated;", "completing one never completes its parent.", "", "To review every active subtask of a parent together, run", "`agent-ops batch --parent <task-id> --yes` instead of one review per", "subtask. It needs what review needs: start it through the trusted outer", "host runner as its first and only invocation, with the same Codex", "elevation, and keep `--output <file>` instead of a shell redirect. It", "verifies, one task at a time, each task that lacks fresh evidence, and", "reviews a few tasks at once. Do not commit or edit the worktree while it", "runs: a moved HEAD voids every verify and review in the batch.", "For subagents in separate worktrees, use `task advance` instead.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "", "For a session with writing subagents, record each child task's complete", "modification intent with `task create --intent <text>` before editing.", "Each child commits and runs local `verify --task` in its own worktree.", "From the main checkout run `agent-ops task advance --task <parent-id>", "--session <session-id> --yes` through the trusted outer host runner,", "unsetting `CODEX_SANDBOX_NETWORK_DISABLED` on Codex. Advance integrates", "child commits and intent into the coordinator worktree, verifies every", "task on the final candidate, then runs one two-round tree review.", "Only `NOT_RUN / scope-too-large` falls back to per-task two-round review;", "a substantive FAIL must be fixed. After PASS, advance writes a local", "receipt under the main checkout's Git common directory and calls finish.", "If the target moves, it retries the final gate once. An unresolved", "failure preserves worktrees and cannot be reported as finished.", "");
|
|
326
326
|
}
|
|
327
327
|
if (context.capabilities.includes("lifecycle-summary")) {
|
|
328
328
|
lines.push("Advisory lifecycle summaries and local logs are informational. Advisory", "failures must remain fail-open and cannot become verification evidence.", "");
|
|
@@ -24,11 +24,13 @@ export const CLAUDE_PREAUTH_ALLOW = [
|
|
|
24
24
|
"Bash(npx lint-staged *)"
|
|
25
25
|
];
|
|
26
26
|
/**
|
|
27
|
-
* review and
|
|
27
|
+
* review, batch and task advance talk to the reviewer's API, which the sandbox withholds;
|
|
28
28
|
* worktree commit and lint-staged write the main .git, which a worktree's
|
|
29
29
|
* sandbox cannot.
|
|
30
30
|
*/
|
|
31
31
|
export const CLAUDE_PREAUTH_UNSANDBOXED = [
|
|
32
|
+
"agent-ops task advance",
|
|
33
|
+
"agent-ops task advance *",
|
|
32
34
|
"agent-ops review",
|
|
33
35
|
"agent-ops review *",
|
|
34
36
|
"agent-ops batch",
|
|
@@ -110,6 +112,13 @@ export const CODEX_RULES_CONTENT = `${[
|
|
|
110
112
|
match: `${env} batch --parent task-x --yes --output batch.json`,
|
|
111
113
|
notMatch: `${env} trust grant --scope project --yes`
|
|
112
114
|
}),
|
|
115
|
+
rule({
|
|
116
|
+
withHost,
|
|
117
|
+
command: ["task", "advance"],
|
|
118
|
+
justification: "agent-ops task advance runs the final reviewer chain and finish outside the sandbox.",
|
|
119
|
+
match: `${env} task advance --task task-x --session session-x --yes`,
|
|
120
|
+
notMatch: `${env} trust grant --scope project --yes`
|
|
121
|
+
}),
|
|
113
122
|
rule({
|
|
114
123
|
withHost,
|
|
115
124
|
command: ["doctor", "--check-auth"],
|
|
@@ -5,6 +5,8 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
|
5
5
|
import { currentGateFingerprint } from "../hooks/completion-gate.js";
|
|
6
6
|
import { collectChangeSurface } from "../verify/change-surface.js";
|
|
7
7
|
import { aggregateVerificationStatus, executeConfiguredCommand } from "../verify/command-executor.js";
|
|
8
|
+
import { prepareFinishReceipt } from "./receipt.js";
|
|
9
|
+
import { listWorktrees } from "./manage.js";
|
|
8
10
|
import { assertWorktreeName, NOTES_REF, noteSessionLine, parseWorktreeListPorcelain, readWorktreeRecord, removeCheckout, resolveCheckouts, worktreePath } from "./service.js";
|
|
9
11
|
const LOCK_NAME = "agent-ops-finish.lock";
|
|
10
12
|
const LOCK_POLL_MS = 2_000;
|
|
@@ -184,6 +186,7 @@ function noteText(record, tasks) {
|
|
|
184
186
|
const indent = " ".repeat(depth);
|
|
185
187
|
return [
|
|
186
188
|
`${indent}agent-ops task ${task.task.id}: ${task.task.title}`,
|
|
189
|
+
...(task.task.intent === undefined ? [] : [`${indent}intent: ${task.task.intent}`]),
|
|
187
190
|
`${indent}criteria:`,
|
|
188
191
|
...task.task.criteria.map(({ id, description }) => `${indent}- ${id}: ${description}`)
|
|
189
192
|
];
|
|
@@ -206,6 +209,30 @@ export async function finishWorktree(deps, options) {
|
|
|
206
209
|
throw finishError("WORKTREE_NOT_FOUND", `No agent-ops worktree named ${name}; see agent-ops worktree list.`);
|
|
207
210
|
}
|
|
208
211
|
return await withFinishLock(deps, commonDir, async () => {
|
|
212
|
+
const sessionChildren = (await listWorktrees(deps, mainRoot))
|
|
213
|
+
.map(({ record: child }) => child)
|
|
214
|
+
.filter((child) => child.sessionId === record.sessionId && child.agentId !== undefined);
|
|
215
|
+
if (options.finalProof === undefined && sessionChildren.length > 0) {
|
|
216
|
+
throw finishError("WORKTREE_CHILDREN_REQUIRE_ADVANCE", "This session has child worktrees; run task advance for integrated final evidence before finish.");
|
|
217
|
+
}
|
|
218
|
+
if (options.finalProof !== undefined &&
|
|
219
|
+
(sessionChildren.length !== options.finalProof.children.length ||
|
|
220
|
+
sessionChildren.some((child) => !options.finalProof.children.some(({ name }) => name === child.name)))) {
|
|
221
|
+
throw finishError("WORKTREE_CHILDREN_CHANGED", "The child worktree set changed after final review.");
|
|
222
|
+
}
|
|
223
|
+
const assertPinnedChild = async (child) => {
|
|
224
|
+
const expected = options.finalProof?.children.find(({ name }) => name === child.name)?.commit;
|
|
225
|
+
const head = await git(deps, child.path, ["rev-parse", "HEAD"], "WORKTREE_CHILDREN_CHANGED", `Cannot read child ${child.name}.`);
|
|
226
|
+
if (expected === undefined || head !== expected ||
|
|
227
|
+
(await collectChangeSurface(runner(deps, child.path))).paths.length > 0) {
|
|
228
|
+
throw finishError("WORKTREE_CHILDREN_CHANGED", `Child ${child.name} changed after integration; preserve its worktree.`);
|
|
229
|
+
}
|
|
230
|
+
return head;
|
|
231
|
+
};
|
|
232
|
+
if (options.finalProof !== undefined) {
|
|
233
|
+
for (const child of sessionChildren)
|
|
234
|
+
await assertPinnedChild(child);
|
|
235
|
+
}
|
|
209
236
|
const mainRunner = runner(deps, mainRoot);
|
|
210
237
|
const worktreeRunner = runner(deps, record.path);
|
|
211
238
|
const onBranch = (await deps.git(mainRoot, ["symbolic-ref", "--short", "-q", "HEAD"])).stdout.trim();
|
|
@@ -231,37 +258,60 @@ export async function finishWorktree(deps, options) {
|
|
|
231
258
|
throw finishError("WORKTREE_DIRTY", `Commit or discard the uncommitted changes in ${record.path} first.`);
|
|
232
259
|
}
|
|
233
260
|
const target = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.targetBranch}^{commit}`], "WORKTREE_TARGET_MISSING", `${record.targetBranch} does not resolve to a commit.`);
|
|
261
|
+
if (options.finalProof !== undefined && target !== options.finalProof.target) {
|
|
262
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review; rerun the final evidence gate.");
|
|
263
|
+
}
|
|
234
264
|
const ahead = Number(await git(deps, record.path, ["rev-list", "--count", `${target}..HEAD`], "WORKTREE_LOG_FAILED", "Git could not compare the branch with its target."));
|
|
235
265
|
if (ahead === 0) {
|
|
236
266
|
throw finishError("WORKTREE_NOTHING_TO_MERGE", `${record.branch} has no commits beyond ${record.targetBranch}; remove the worktree instead.`);
|
|
237
267
|
}
|
|
238
268
|
const worktreeConfig = await deps.loadConfig(record.path);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
269
|
+
const headBeforeCompletion = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
270
|
+
if (options.finalProof !== undefined) {
|
|
271
|
+
if (headBeforeCompletion !== options.finalProof.head) {
|
|
272
|
+
throw finishError("WORKTREE_SOURCE_MOVED", "The candidate changed after final review.");
|
|
273
|
+
}
|
|
274
|
+
if ((await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode !== 0) {
|
|
275
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The final candidate is not based on the target; rerun the final evidence gate.");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Direct finish completes before merge. A final-proof finish checks all
|
|
279
|
+
// evidence first, then completes only after the target's fast-forward:
|
|
280
|
+
// a target that moves before that point can still be re-reviewed once.
|
|
242
281
|
const tree = taskTree((await deps.tasks(record.path).list()).filter(({ status }) => status !== "archived"));
|
|
243
282
|
if (tree.length === 0) {
|
|
244
283
|
throw finishError("WORKTREE_TASK_INCOMPLETE", "The worktree has no task; create one, verify and review it before finishing.");
|
|
245
284
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
285
|
+
const completeTasks = async () => {
|
|
286
|
+
for (const { task } of [...tree].sort((left, right) => right.depth - left.depth)) {
|
|
287
|
+
if (task.status === "complete")
|
|
288
|
+
continue;
|
|
289
|
+
const base = options.finalProof === undefined ? task.reviewBase ?? record.base : target;
|
|
290
|
+
try {
|
|
291
|
+
await deps.tasks(record.path, base).complete(task.task.id, {});
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)} ` +
|
|
295
|
+
"A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
|
|
296
|
+
}
|
|
252
297
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
298
|
+
};
|
|
299
|
+
const validateGate = async () => {
|
|
300
|
+
const worktreeGate = await deps.gate(record.path, worktreeConfig);
|
|
301
|
+
const problem = await worktreeGate.validate(record.sessionId);
|
|
302
|
+
if (problem !== null) {
|
|
303
|
+
throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
|
|
256
304
|
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
throw finishError("WORKTREE_TASK_INCOMPLETE", `The worktree's task is not ready to merge (${problem.code}). ${problem.remedy ?? ""}`.trim());
|
|
305
|
+
};
|
|
306
|
+
if (options.finalProof === undefined) {
|
|
307
|
+
await completeTasks();
|
|
308
|
+
await validateGate();
|
|
262
309
|
}
|
|
263
310
|
const head = await git(deps, record.path, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
264
311
|
const fastForward = (await deps.git(record.path, ["merge-base", "--is-ancestor", target, "HEAD"])).exitCode === 0;
|
|
312
|
+
if (options.finalProof !== undefined && !fastForward) {
|
|
313
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target branch moved after final review.");
|
|
314
|
+
}
|
|
265
315
|
if (!fastForward) {
|
|
266
316
|
const before = await patchHash(deps, record.path, target);
|
|
267
317
|
const rebase = await deps.git(record.path, ["rebase", "--no-autostash", target]);
|
|
@@ -287,19 +337,64 @@ export async function finishWorktree(deps, options) {
|
|
|
287
337
|
throw finishError("WORKTREE_REVERIFY_FAILED", `After rebasing onto ${target}, verification failed: ${failed.join(", ")}. The branch was reset; fix it as a new task and finish again.`);
|
|
288
338
|
}
|
|
289
339
|
}
|
|
340
|
+
const receiptOptions = options.finalProof === undefined ? undefined : {
|
|
341
|
+
commonDir,
|
|
342
|
+
record,
|
|
343
|
+
candidateHead: head,
|
|
344
|
+
targetCommit: target,
|
|
345
|
+
expectedFingerprint: options.finalProof.sourceFingerprint,
|
|
346
|
+
children: options.finalProof.children,
|
|
347
|
+
config: worktreeConfig,
|
|
348
|
+
gitRunner: worktreeRunner
|
|
349
|
+
};
|
|
350
|
+
let receipt = receiptOptions === undefined ? undefined : await prepareFinishReceipt({
|
|
351
|
+
...receiptOptions,
|
|
352
|
+
tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
|
|
353
|
+
});
|
|
290
354
|
const mainConfig = await deps.loadConfig(mainRoot);
|
|
291
355
|
const gateEnabled = mainConfig.features.completionGate.enabled;
|
|
292
356
|
const before = gateEnabled ? await currentGateFingerprint(mainRoot, mainRunner) : "";
|
|
293
357
|
let mergedHead;
|
|
294
358
|
if (onTarget) {
|
|
295
|
-
|
|
359
|
+
if (options.finalProof !== undefined &&
|
|
360
|
+
await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
|
|
361
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target changed after the final proof was prepared.");
|
|
362
|
+
}
|
|
363
|
+
const merge = await deps.git(mainRoot, ["merge", "--ff-only", record.branch]);
|
|
364
|
+
if (merge.exitCode !== 0) {
|
|
365
|
+
if (options.finalProof !== undefined &&
|
|
366
|
+
await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.") !== target) {
|
|
367
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
|
|
368
|
+
}
|
|
369
|
+
throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${merge.stderr.trim()}`);
|
|
370
|
+
}
|
|
296
371
|
mergedHead = await git(deps, mainRoot, ["rev-parse", "HEAD"], "WORKTREE_LOG_FAILED", "Git could not read HEAD.");
|
|
297
372
|
}
|
|
298
373
|
else {
|
|
299
374
|
// Compare-and-swap on the target's value read above: a target that moved
|
|
300
375
|
// since fails here instead of being overwritten.
|
|
301
376
|
mergedHead = await git(deps, mainRoot, ["rev-parse", "--verify", `${record.branch}^{commit}`], "WORKTREE_LOG_FAILED", `Git could not read ${record.branch}.`);
|
|
302
|
-
await git(
|
|
377
|
+
const update = await deps.git(mainRoot, ["update-ref", "-m", `agent-ops finish ${record.branch}`, `refs/heads/${record.targetBranch}`, mergedHead, target]);
|
|
378
|
+
if (update.exitCode !== 0) {
|
|
379
|
+
if (options.finalProof !== undefined &&
|
|
380
|
+
await git(deps, mainRoot, ["rev-parse", `${record.targetBranch}^{commit}`], "WORKTREE_LOG_FAILED", "Git could not read the target.") !== target) {
|
|
381
|
+
throw finishError("WORKTREE_TARGET_MOVED", "The target moved during finish; rerun the final evidence gate.");
|
|
382
|
+
}
|
|
383
|
+
throw finishError("WORKTREE_MERGE_FAILED", `Git could not fast-forward ${record.targetBranch}. ${update.stderr.trim()}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (receiptOptions !== undefined) {
|
|
387
|
+
try {
|
|
388
|
+
await completeTasks();
|
|
389
|
+
await validateGate();
|
|
390
|
+
receipt = await prepareFinishReceipt({
|
|
391
|
+
...receiptOptions,
|
|
392
|
+
tasks: await deps.tasks(record.path).list().then((tasks) => tasks.filter(({ status }) => status !== "archived"))
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead}; final task state or receipt failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
397
|
+
}
|
|
303
398
|
}
|
|
304
399
|
// The merge happened; everything below is cleanup, reported, never undone.
|
|
305
400
|
const warnings = [];
|
|
@@ -308,10 +403,15 @@ export async function finishWorktree(deps, options) {
|
|
|
308
403
|
await step();
|
|
309
404
|
}
|
|
310
405
|
catch (error) {
|
|
311
|
-
|
|
406
|
+
const message = `${label}: ${error instanceof Error ? error.message : String(error)}`;
|
|
407
|
+
if (options.finalProof !== undefined) {
|
|
408
|
+
throw finishError("WORKTREE_FINISH_PARTIAL", `Code merged at ${mergedHead} with receipt ${receipt?.path}; ${message}`);
|
|
409
|
+
}
|
|
410
|
+
warnings.push(message);
|
|
312
411
|
}
|
|
313
412
|
};
|
|
314
|
-
|
|
413
|
+
const finishNote = `${noteText(record, tree)}${receipt === undefined ? "" : `\nreceipt: ${receipt.path}\nreceipt-sha256: ${receipt.digest}`}`;
|
|
414
|
+
await attempt("note", async () => await git(deps, mainRoot, ["notes", `--ref=${NOTES_REF}`, "add", "-f", "-m", finishNote, mergedHead], "WORKTREE_NOTE_FAILED", "Git could not write the agent-ops note."));
|
|
315
415
|
if (gateEnabled) {
|
|
316
416
|
await attempt("gate", async () => {
|
|
317
417
|
const mainGate = await deps.gate(mainRoot, mainConfig);
|
|
@@ -321,10 +421,25 @@ export async function finishWorktree(deps, options) {
|
|
|
321
421
|
// this session began would leave it reading the merge as its own change.
|
|
322
422
|
// Only this worktree leaves the session; its other roots stay covered.
|
|
323
423
|
await mainGate.afterFinish(record.sessionId, record.path, after);
|
|
424
|
+
if (options.finalProof !== undefined) {
|
|
425
|
+
for (const child of sessionChildren) {
|
|
426
|
+
await mainGate.afterFinish(record.sessionId, child.path, after);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
324
429
|
});
|
|
325
430
|
}
|
|
326
431
|
await attempt("trust", async () => await deps.trust.revoke(record.path, worktreeConfig));
|
|
327
|
-
|
|
328
|
-
|
|
432
|
+
if (options.finalProof !== undefined) {
|
|
433
|
+
for (const child of sessionChildren) {
|
|
434
|
+
await attempt(`child ${child.name}`, async () => {
|
|
435
|
+
const pinnedHead = await assertPinnedChild(child);
|
|
436
|
+
await deps.trust.revoke(child.path, await deps.loadConfig(child.path));
|
|
437
|
+
await removeCheckout(deps, child, false, pinnedHead);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
await attempt("remove", async () => await removeCheckout(deps, record, options.finalProof === undefined, options.finalProof?.head));
|
|
442
|
+
return { record, mergedHead, rebased: !fastForward, taskIds: tree.map(({ task }) => task.task.id), warnings,
|
|
443
|
+
...(receipt === undefined ? {} : { receipt: receipt.path }) };
|
|
329
444
|
});
|
|
330
445
|
}
|