@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three terminal commands — `ab done`, `ab verdict`, `ab escalate`
|
|
3
|
+
* (SPEC §8.4 D5, §8.5 D6, §8.6 D7).
|
|
4
|
+
*
|
|
5
|
+
* D5: every phase ends with exactly one terminal command; a second terminal
|
|
6
|
+
* call is rejected citing what already exists, and every terminal validates
|
|
7
|
+
* its preconditions before emitting the phase event.
|
|
8
|
+
*
|
|
9
|
+
* D6: terminal commands are atomic bundles — `appendWithArtifacts` deposits
|
|
10
|
+
* the artifact and appends the event in one operation, and validation
|
|
11
|
+
* failures return the schema and a precise error as in-session agent
|
|
12
|
+
* feedback, not build failures.
|
|
13
|
+
*
|
|
14
|
+
* D7: agents never touch the remote — push and PR creation happen here, as
|
|
15
|
+
* plumbing triggered by the terminal. Forge credentials never enter the
|
|
16
|
+
* agent's own toolbox; this CLI call IS the kernel plumbing.
|
|
17
|
+
*/
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import { loadConfig } from '../config/load'
|
|
21
|
+
import type { AbEvent, EventEnvelope } from '../events/catalog'
|
|
22
|
+
import { agentActor, KERNEL } from '../events/envelope'
|
|
23
|
+
import type { IdSource } from '../ids'
|
|
24
|
+
import {
|
|
25
|
+
autoMergeApplicationType,
|
|
26
|
+
pendingAutoMerge,
|
|
27
|
+
recordAutoMergeDeferralObservation,
|
|
28
|
+
} from '../kernel/auto-merge'
|
|
29
|
+
import { phaseSpecFor } from '../kernel/phases'
|
|
30
|
+
import { resolvePlanVerifySteps } from '../kernel/plan-verify-selection'
|
|
31
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
32
|
+
import {
|
|
33
|
+
findingDraftSchema,
|
|
34
|
+
isVerifyPhase,
|
|
35
|
+
verifyReportKind,
|
|
36
|
+
verifyStep,
|
|
37
|
+
type ArtifactRef,
|
|
38
|
+
type Finding,
|
|
39
|
+
type ReviewVerdictKind,
|
|
40
|
+
} from '../ontology'
|
|
41
|
+
import type { Forge } from '../ports/types'
|
|
42
|
+
import {
|
|
43
|
+
phaseScratchRejection,
|
|
44
|
+
reconcileScratchViolations,
|
|
45
|
+
scratchPathsTouchedInRange,
|
|
46
|
+
} from '../ports/workspace/phase-scratch'
|
|
47
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
48
|
+
import type { ArtifactMeta, BuildStore } from '../store/types'
|
|
49
|
+
import { textContent } from '../store/types'
|
|
50
|
+
import { expandIssues } from '../zod-issues'
|
|
51
|
+
import { preparePrAttachments } from './pr-attachments'
|
|
52
|
+
import { renderPrSummary } from './pr-summary'
|
|
53
|
+
import type { CliEnv } from './env'
|
|
54
|
+
|
|
55
|
+
/** Structural subset of CliDeps (src/cli/main.ts) — what terminals need. */
|
|
56
|
+
export interface TerminalDeps {
|
|
57
|
+
store: BuildStore
|
|
58
|
+
env: CliEnv
|
|
59
|
+
workspacePath: string
|
|
60
|
+
forge: Forge
|
|
61
|
+
/** All git interrogation goes through this seam — never a raw spawn. */
|
|
62
|
+
exec: Exec
|
|
63
|
+
ids: IdSource
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Shared discipline (D5) ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reject a second terminal call for this phase+round: either the phase's
|
|
70
|
+
* terminal event already recorded at this round, or an `escalation.raised`
|
|
71
|
+
* by THIS session (a session that escalated is done — §8.4). Also reject a
|
|
72
|
+
* terminal from a ZOMBIE session — one the log already ended, or one whose
|
|
73
|
+
* phase round already failed after it started (D5: a session that ends
|
|
74
|
+
* without a terminal is failed and retried; only the retry may complete the
|
|
75
|
+
* round, so a still-in-flight CLI process from the failed session must not
|
|
76
|
+
* land a terminal the runner will misattribute to the retry).
|
|
77
|
+
*/
|
|
78
|
+
function assertNoPriorTerminal(events: AbEvent[], env: CliEnv): void {
|
|
79
|
+
const spec = phaseSpecFor(env.phase)
|
|
80
|
+
// §6.3 restart boundary: verify attempts and finalize completion RESET
|
|
81
|
+
// across a spec.revised (decideNext ignores pre-restart results and
|
|
82
|
+
// re-runs the pipeline from plan), so a pre-restart verify.completed or
|
|
83
|
+
// finalize.completed must not shadow the rebuilt pipeline's terminal.
|
|
84
|
+
// Loop-phase rounds continue monotonically across restarts (engine
|
|
85
|
+
// LoopIndex.maxRoundEver) and never collide, so they match over the full
|
|
86
|
+
// log; the reconcile epilogue is restart-orthogonal (§15.7).
|
|
87
|
+
let restartSeq = 0
|
|
88
|
+
for (const event of events) {
|
|
89
|
+
if (event.type === 'spec.revised') restartSeq = event.seq
|
|
90
|
+
}
|
|
91
|
+
// reconcile.completed carries no attempt (§15.3); pair it with the latest
|
|
92
|
+
// reconcile.started seen before it.
|
|
93
|
+
let reconcileAttempt = 0
|
|
94
|
+
let sessionStartedSeq: number | undefined
|
|
95
|
+
for (const event of events) {
|
|
96
|
+
if (event.type === 'reconcile.started') reconcileAttempt = event.payload.attempt
|
|
97
|
+
if (event.type === 'session.started' && event.payload.session === env.session) {
|
|
98
|
+
sessionStartedSeq = event.seq
|
|
99
|
+
}
|
|
100
|
+
if (event.type === 'session.ended' && event.payload.session === env.session) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`terminal rejected (D5): session "${env.session}" already ended ` +
|
|
103
|
+
`(session.ended at seq ${event.seq}) — a session that ended without ` +
|
|
104
|
+
'a terminal was failed and retried; only the live retry may complete ' +
|
|
105
|
+
'this phase round (§8.4).',
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
sessionStartedSeq !== undefined &&
|
|
110
|
+
event.type === 'phase.failed' &&
|
|
111
|
+
event.payload.phase === env.phase &&
|
|
112
|
+
event.payload.round === env.round &&
|
|
113
|
+
event.seq > sessionStartedSeq
|
|
114
|
+
) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`terminal rejected (D5): ${env.phase}@${env.round} already failed after ` +
|
|
117
|
+
`this session started (phase.failed at seq ${event.seq}) — the retry ` +
|
|
118
|
+
'session owns this round now (§8.4).',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
event.type === 'escalation.raised' &&
|
|
123
|
+
event.actor.kind === 'agent' &&
|
|
124
|
+
event.actor.session === env.session
|
|
125
|
+
) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`second terminal call rejected (D5): this session already escalated — ` +
|
|
128
|
+
`escalation.raised "${event.payload.id}" at seq ${event.seq}. ` +
|
|
129
|
+
'Every phase ends with exactly one terminal command (§8.4).',
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
event.type === 'publication.requested' &&
|
|
134
|
+
event.actor.kind === 'agent' &&
|
|
135
|
+
event.actor.session === env.session &&
|
|
136
|
+
((env.phase === 'implement' &&
|
|
137
|
+
event.payload.operation === 'implement' &&
|
|
138
|
+
event.payload.round === env.round) ||
|
|
139
|
+
(env.phase === 'reconcile' && event.payload.operation === 'reconcile') ||
|
|
140
|
+
(env.phase === 'finalize' && event.payload.operation === 'finalize'))
|
|
141
|
+
) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`second terminal call rejected (D5): ${event.type} for ` +
|
|
144
|
+
`${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
|
|
145
|
+
'Remote publication is pending; do not run ab done again (§8.4).',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
if (event.type !== spec.terminalEvent) continue
|
|
149
|
+
if (
|
|
150
|
+
(event.type === 'verify.completed' || event.type === 'finalize.completed') &&
|
|
151
|
+
event.seq <= restartSeq
|
|
152
|
+
) {
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (!terminalMatchesRound(event, env, reconcileAttempt)) continue
|
|
156
|
+
throw new Error(
|
|
157
|
+
`second terminal call rejected (D5): ${event.type} for ` +
|
|
158
|
+
`${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
|
|
159
|
+
'Every phase ends with exactly one terminal command (§8.4).',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function terminalMatchesRound(event: AbEvent, env: CliEnv, reconcileAttempt: number): boolean {
|
|
165
|
+
switch (event.type) {
|
|
166
|
+
case 'plan.completed':
|
|
167
|
+
case 'plan-review.verdict':
|
|
168
|
+
case 'implement.completed':
|
|
169
|
+
case 'code-review.verdict':
|
|
170
|
+
return event.payload.round === env.round
|
|
171
|
+
case 'verify.completed':
|
|
172
|
+
return (
|
|
173
|
+
isVerifyPhase(env.phase) &&
|
|
174
|
+
event.payload.step === verifyStep(env.phase) &&
|
|
175
|
+
event.payload.attempt === env.round
|
|
176
|
+
)
|
|
177
|
+
case 'finalize.completed':
|
|
178
|
+
// finalize runs once per build (§5) — any prior completion counts.
|
|
179
|
+
return true
|
|
180
|
+
case 'reconcile.completed':
|
|
181
|
+
// No started event before it → conservative match (total over any log).
|
|
182
|
+
return reconcileAttempt === env.round || reconcileAttempt === 0
|
|
183
|
+
default:
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
async function readTextFile(path: string, flag: string): Promise<string> {
|
|
191
|
+
const file = Bun.file(path)
|
|
192
|
+
if (!(await file.exists())) {
|
|
193
|
+
throw new Error(`${flag} file not found: ${path}`)
|
|
194
|
+
}
|
|
195
|
+
return file.text()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function git(deps: TerminalDeps, args: string[]): Promise<string> {
|
|
199
|
+
const result = await deps.exec(['git', ...args], { cwd: deps.workspacePath })
|
|
200
|
+
if (result.exitCode !== 0) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`git ${args.join(' ')} exited ${result.exitCode}: ` +
|
|
203
|
+
`${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
return result.stdout
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Git object ids are 40 hex characters for SHA-1 repositories and 64 for
|
|
210
|
+
* SHA-256 repositories. The corresponding rev-parse calls also prove type. */
|
|
211
|
+
const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
212
|
+
|
|
213
|
+
function gitObjectIds(output: string, command: string): string[] {
|
|
214
|
+
const body = output.endsWith('\n') ? output.slice(0, -1) : output
|
|
215
|
+
if (body === '') return []
|
|
216
|
+
const ids = body.split('\n')
|
|
217
|
+
const malformed = ids.find((id) => !GIT_OBJECT_ID.test(id))
|
|
218
|
+
if (malformed !== undefined) {
|
|
219
|
+
throw new Error(`${command} returned malformed commit output: ${JSON.stringify(output)}`)
|
|
220
|
+
}
|
|
221
|
+
return ids
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function singleGitObjectId(output: string, command: string): string {
|
|
225
|
+
const ids = gitObjectIds(output, command)
|
|
226
|
+
if (ids.length !== 1) {
|
|
227
|
+
throw new Error(`${command} expected exactly one commit SHA, found ${ids.length}`)
|
|
228
|
+
}
|
|
229
|
+
return ids[0]!
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** No `done` on a dirty worktree (D5) — the error lists the offending files. */
|
|
233
|
+
async function assertCleanWorktree(deps: TerminalDeps): Promise<void> {
|
|
234
|
+
const status = (await git(deps, ['status', '--porcelain'])).trimEnd()
|
|
235
|
+
if (status !== '') {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`'ab done' in ${deps.env.phase} requires a clean worktree (D5) — ` +
|
|
238
|
+
`commit or discard these first:\n${status}`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function baseBranchOf(events: AbEvent[]): string {
|
|
244
|
+
for (const event of events) {
|
|
245
|
+
if (event.type === 'build.created') return event.payload.baseBranch
|
|
246
|
+
}
|
|
247
|
+
throw new Error('this build has no build.created event — cannot resolve its baseBranch (§15.3)')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function requireImplementationProvisioning(events: AbEvent[]): void {
|
|
251
|
+
if (events.some((event) => event.type === 'workspace.provisioned')) return
|
|
252
|
+
throw new Error(
|
|
253
|
+
"implement's 'ab done' requires workspace.provisioned provenance — " +
|
|
254
|
+
'this build has no workspace.provisioned event (§7.4, §15.3)',
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function remotePublication(events: readonly AbEvent[]): boolean {
|
|
259
|
+
let remote = false
|
|
260
|
+
for (const event of events) {
|
|
261
|
+
if (event.type === 'workspace.provisioned') remote = event.payload.provider === 'vercel-sandbox'
|
|
262
|
+
else if (event.type === 'workspace.released') remote = false
|
|
263
|
+
}
|
|
264
|
+
return remote
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function buildBranch(deps: TerminalDeps): Promise<string> {
|
|
268
|
+
const build = await deps.store.getBuild(deps.env.build)
|
|
269
|
+
if (build === null) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`build "${deps.env.build}" could not be resolved — cannot determine its durable branch`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
return build.branch ?? `ab/${build.slug}`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Establish the focused range that this implementation completion makes
|
|
279
|
+
* durable. The target snapshot lives only in a build-scoped internal ref:
|
|
280
|
+
* neither FETCH_HEAD nor operator/remote-tracking refs are review inputs.
|
|
281
|
+
*/
|
|
282
|
+
async function implementationReviewBoundary(
|
|
283
|
+
deps: TerminalDeps,
|
|
284
|
+
events: AbEvent[],
|
|
285
|
+
): Promise<{ branch: string; base: string; head: string }> {
|
|
286
|
+
const diagnostic = 'cannot establish implementation review boundary'
|
|
287
|
+
try {
|
|
288
|
+
requireImplementationProvisioning(events)
|
|
289
|
+
const baseBranch = baseBranchOf(events)
|
|
290
|
+
const branch = await buildBranch(deps)
|
|
291
|
+
const headCommand = 'git rev-parse --verify HEAD^{commit}'
|
|
292
|
+
const head = singleGitObjectId(
|
|
293
|
+
await git(deps, ['rev-parse', '--verify', 'HEAD^{commit}']),
|
|
294
|
+
headCommand,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const targetRef = `refs/autobuild/review/${branch}/base`
|
|
298
|
+
const target =
|
|
299
|
+
deps.forge.snapshotBase !== undefined
|
|
300
|
+
? singleGitObjectId(
|
|
301
|
+
`${await deps.forge.snapshotBase({
|
|
302
|
+
workspacePath: deps.workspacePath,
|
|
303
|
+
base: baseBranch,
|
|
304
|
+
destinationRef: targetRef,
|
|
305
|
+
})}\n`,
|
|
306
|
+
`${deps.forge.name} base snapshot`,
|
|
307
|
+
)
|
|
308
|
+
: await (async () => {
|
|
309
|
+
await git(deps, [
|
|
310
|
+
'fetch',
|
|
311
|
+
'--no-tags',
|
|
312
|
+
'--no-write-fetch-head',
|
|
313
|
+
'--refmap=',
|
|
314
|
+
'origin',
|
|
315
|
+
`+refs/heads/${baseBranch}:${targetRef}`,
|
|
316
|
+
])
|
|
317
|
+
const targetCommand = `git rev-parse --verify ${targetRef}^{commit}`
|
|
318
|
+
return singleGitObjectId(
|
|
319
|
+
await git(deps, ['rev-parse', '--verify', `${targetRef}^{commit}`]),
|
|
320
|
+
targetCommand,
|
|
321
|
+
)
|
|
322
|
+
})()
|
|
323
|
+
|
|
324
|
+
const mergeBaseArgs = ['merge-base', '--all', target, head]
|
|
325
|
+
const mergeBaseCommand = `git ${mergeBaseArgs.join(' ')}`
|
|
326
|
+
const merged = await deps.exec(['git', ...mergeBaseArgs], {
|
|
327
|
+
cwd: deps.workspacePath,
|
|
328
|
+
})
|
|
329
|
+
if (merged.exitCode === 1) {
|
|
330
|
+
throw new Error(`${mergeBaseCommand} found no common ancestor`)
|
|
331
|
+
}
|
|
332
|
+
if (merged.exitCode !== 0) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`${mergeBaseCommand} exited ${merged.exitCode}: ` +
|
|
335
|
+
`${merged.stderr.trim() || merged.stdout.trim() || '(no output)'}`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
const mergeBases = gitObjectIds(merged.stdout, mergeBaseCommand)
|
|
339
|
+
if (mergeBases.length !== 1) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`${mergeBaseCommand} expected exactly one merge-base, found ` +
|
|
342
|
+
`${mergeBases.length}${mergeBases.length > 0 ? `: ${mergeBases.join(', ')}` : ''}`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
return { branch, base: mergeBases[0]!, head }
|
|
346
|
+
} catch (error) {
|
|
347
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
348
|
+
throw new Error(`${diagnostic}: ${detail}`)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function refOf(meta: ArtifactMeta | undefined): ArtifactRef {
|
|
353
|
+
if (meta === undefined) {
|
|
354
|
+
throw new Error('deposit produced no artifact meta — this is a store bug')
|
|
355
|
+
}
|
|
356
|
+
return { kind: meta.kind, rev: meta.revision }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function requireNotes(notes: string | undefined, why: string): string {
|
|
360
|
+
if (notes === undefined) {
|
|
361
|
+
throw new Error(`--notes <file> is required: ${why} (D5, §8.2)`)
|
|
362
|
+
}
|
|
363
|
+
return notes
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── ab done (producer phases — D5/D7) ────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
export interface DoneOpts {
|
|
369
|
+
/** Path to the notes file (implement/reconcile — deposited as the phase's artifact). */
|
|
370
|
+
notes?: string
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export interface FinalizePrInput {
|
|
374
|
+
branch: string
|
|
375
|
+
baseBranch: string
|
|
376
|
+
title: string
|
|
377
|
+
body: string
|
|
378
|
+
mergeMessage: string
|
|
379
|
+
/** Remote settlement pins the PR to the already-verified published head. */
|
|
380
|
+
expectedHeadSha?: string
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Shared local/remote finalize plumbing. The caller must publish the exact
|
|
384
|
+
* requested head before entering this idempotent PR-side sequence. */
|
|
385
|
+
export async function completeFinalizePr(
|
|
386
|
+
deps: TerminalDeps,
|
|
387
|
+
events: AbEvent[],
|
|
388
|
+
input: FinalizePrInput,
|
|
389
|
+
): Promise<EventEnvelope> {
|
|
390
|
+
const { env, store } = deps
|
|
391
|
+
const pr = await deps.forge.openPr({
|
|
392
|
+
workspacePath: deps.workspacePath,
|
|
393
|
+
head: input.branch,
|
|
394
|
+
base: input.baseBranch,
|
|
395
|
+
title: input.title,
|
|
396
|
+
body: input.body,
|
|
397
|
+
mergeMessage: input.mergeMessage,
|
|
398
|
+
})
|
|
399
|
+
if (input.expectedHeadSha !== undefined && pr.headSha !== input.expectedHeadSha) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`PR head ${pr.headSha} did not match published request ${input.expectedHeadSha}`,
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
await preparePrAttachments(deps, events, pr.url)
|
|
406
|
+
|
|
407
|
+
const latest = await store.getEvents(env.build)
|
|
408
|
+
const autoMerge = pendingAutoMerge(reduceBuild(latest))
|
|
409
|
+
const autoMergeResult =
|
|
410
|
+
autoMerge === undefined
|
|
411
|
+
? undefined
|
|
412
|
+
: await deps.forge.setAutoMerge(deps.workspacePath, pr.number, autoMerge.enabled)
|
|
413
|
+
|
|
414
|
+
const event = await store.append(env.build, {
|
|
415
|
+
actor: KERNEL,
|
|
416
|
+
type: 'finalize.completed',
|
|
417
|
+
payload: { pr },
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
if (autoMerge !== undefined) {
|
|
421
|
+
try {
|
|
422
|
+
if (autoMergeResult?.kind === 'applied') {
|
|
423
|
+
await store.append(env.build, {
|
|
424
|
+
actor: KERNEL,
|
|
425
|
+
type: autoMergeApplicationType(autoMerge.enabled),
|
|
426
|
+
payload: { commandSeq: autoMerge.commandSeq },
|
|
427
|
+
})
|
|
428
|
+
} else if (autoMergeResult?.kind === 'deferred' && autoMergeResult.reason !== undefined) {
|
|
429
|
+
await recordAutoMergeDeferralObservation(
|
|
430
|
+
store,
|
|
431
|
+
env.build,
|
|
432
|
+
autoMergeResult.reason,
|
|
433
|
+
pr.number,
|
|
434
|
+
autoMerge.commandSeq,
|
|
435
|
+
deps.ids('obs'),
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
// The janitor retries unmatched commands and provider diagnostics.
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
await deps.forge.commentOnPr(
|
|
445
|
+
deps.workspacePath,
|
|
446
|
+
pr.number,
|
|
447
|
+
renderPrSummary(env, await store.getEvents(env.build)),
|
|
448
|
+
)
|
|
449
|
+
} catch {
|
|
450
|
+
// The durable audit trail remains authoritative.
|
|
451
|
+
}
|
|
452
|
+
return event
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<EventEnvelope> {
|
|
456
|
+
const { env, store } = deps
|
|
457
|
+
const spec = phaseSpecFor(env.phase)
|
|
458
|
+
if (spec.kind !== 'producer') {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`'ab done' is not ${env.phase}'s terminal — ${env.phase} is a ` +
|
|
461
|
+
`${spec.kind} phase; use 'ab verdict' (§8.2)`,
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
const events = await store.getEvents(env.build)
|
|
465
|
+
assertNoPriorTerminal(events, env)
|
|
466
|
+
const actor = agentActor(env.phase, env.session)
|
|
467
|
+
|
|
468
|
+
switch (spec.name) {
|
|
469
|
+
case 'plan': {
|
|
470
|
+
// THIS round's plan must already be deposited (D5: no done without the
|
|
471
|
+
// required artifacts): a revision newer than the one the last
|
|
472
|
+
// plan.completed cited. A bare revision count is no proxy — crash
|
|
473
|
+
// re-runs leave orphaned revs (§8.7) and a round may self-correct with
|
|
474
|
+
// several deposits, so count ≥ round can hold while the latest rev is
|
|
475
|
+
// still the already-reviewed plan, re-entering review as if revised.
|
|
476
|
+
const revs = await store.listArtifacts(env.build, 'plan')
|
|
477
|
+
const latest = revs[revs.length - 1]
|
|
478
|
+
let lastCited = -1
|
|
479
|
+
for (const event of events) {
|
|
480
|
+
if (event.type === 'plan.completed') {
|
|
481
|
+
lastCited = Math.max(lastCited, event.payload.artifact.rev)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (latest === undefined || latest.revision <= lastCited) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`plan@${env.round} 'ab done' requires this round's plan deposit: found ` +
|
|
487
|
+
(latest === undefined
|
|
488
|
+
? '0 plan revisions'
|
|
489
|
+
: `no plan revision newer than rev ${lastCited}, which an earlier ` +
|
|
490
|
+
`round's plan.completed already cited`) +
|
|
491
|
+
` — run 'ab artifact put plan <file>' first (D5)`,
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
// The event, not a later artifact read, is the engine's durable input.
|
|
495
|
+
// Validate the exact fresh revision before appending so an invalid plan
|
|
496
|
+
// can be corrected with another immutable deposit and no partial fact.
|
|
497
|
+
const plan = await store.getArtifact(env.build, 'plan', latest.revision)
|
|
498
|
+
if (plan === null) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`plan@${latest.revision} was listed but could not be fetched — this is a store bug`,
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
const config = await loadConfig(join(deps.workspacePath, 'autobuild.toml'))
|
|
504
|
+
const verifySteps = resolvePlanVerifySteps(textContent(plan), config)
|
|
505
|
+
return store.append(env.build, {
|
|
506
|
+
actor,
|
|
507
|
+
type: 'plan.completed',
|
|
508
|
+
payload: {
|
|
509
|
+
round: env.round,
|
|
510
|
+
artifact: { kind: 'plan', rev: latest.revision },
|
|
511
|
+
verifySteps,
|
|
512
|
+
},
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
case 'implement': {
|
|
517
|
+
const notesPath = requireNotes(opts.notes, "implement's 'ab done' deposits implement-notes")
|
|
518
|
+
const notes = await readTextFile(notesPath, '--notes')
|
|
519
|
+
await assertCleanWorktree(deps)
|
|
520
|
+
// Resolve the current target and focused divergence BEFORE publication
|
|
521
|
+
// or deposit. A misleading range is worse than rejecting this terminal.
|
|
522
|
+
const { branch, base, head } = await implementationReviewBoundary(deps, events)
|
|
523
|
+
const scratchPaths = await scratchPathsTouchedInRange(
|
|
524
|
+
deps.exec,
|
|
525
|
+
deps.workspacePath,
|
|
526
|
+
base,
|
|
527
|
+
head,
|
|
528
|
+
)
|
|
529
|
+
if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
|
|
530
|
+
if (remotePublication(events)) {
|
|
531
|
+
const { event } = await store.appendWithArtifacts(
|
|
532
|
+
env.build,
|
|
533
|
+
[{ kind: 'implement-notes', content: notes }],
|
|
534
|
+
(deposited) => ({
|
|
535
|
+
actor,
|
|
536
|
+
type: 'publication.requested',
|
|
537
|
+
payload: {
|
|
538
|
+
operation: 'implement',
|
|
539
|
+
branch,
|
|
540
|
+
sha: head,
|
|
541
|
+
round: env.round,
|
|
542
|
+
base,
|
|
543
|
+
artifact: refOf(deposited[0]),
|
|
544
|
+
},
|
|
545
|
+
}),
|
|
546
|
+
)
|
|
547
|
+
return event
|
|
548
|
+
}
|
|
549
|
+
// Local publication remains immediate and push-before-fact.
|
|
550
|
+
await deps.forge.pushBranch(deps.workspacePath, branch)
|
|
551
|
+
const { event } = await store.appendWithArtifacts(
|
|
552
|
+
env.build,
|
|
553
|
+
[{ kind: 'implement-notes', content: notes }],
|
|
554
|
+
(deposited) => ({
|
|
555
|
+
actor,
|
|
556
|
+
type: 'implement.completed',
|
|
557
|
+
payload: { round: env.round, commits: { base, head }, artifact: refOf(deposited[0]) },
|
|
558
|
+
}),
|
|
559
|
+
)
|
|
560
|
+
return event
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
case 'finalize': {
|
|
564
|
+
const description = await store.getArtifact(env.build, 'pr-description')
|
|
565
|
+
if (description === null) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
"finalize's 'ab done' requires a deposited pr-description artifact — " +
|
|
568
|
+
"run 'ab artifact put pr-description <file>' first (D5, §8.3). " +
|
|
569
|
+
'Its first line becomes the PR title; the rest is the body.',
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
const text = textContent(description)
|
|
573
|
+
const newline = text.indexOf('\n')
|
|
574
|
+
const firstLine = (newline === -1 ? text : text.slice(0, newline))
|
|
575
|
+
.replace(/^#+\s*/, '')
|
|
576
|
+
.trim()
|
|
577
|
+
const body = newline === -1 ? '' : text.slice(newline + 1).replace(/^\n+/, '')
|
|
578
|
+
if (firstLine === '') {
|
|
579
|
+
throw new Error(
|
|
580
|
+
'the pr-description artifact has an empty first line — it becomes the PR title',
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
const baseBranch = baseBranchOf(events)
|
|
584
|
+
const branch = await buildBranch(deps)
|
|
585
|
+
if (remotePublication(events)) {
|
|
586
|
+
await assertCleanWorktree(deps)
|
|
587
|
+
const head = await git(deps, ['rev-parse', '--verify', 'HEAD^{commit}'])
|
|
588
|
+
return await store.append(env.build, {
|
|
589
|
+
actor,
|
|
590
|
+
type: 'publication.requested',
|
|
591
|
+
payload: {
|
|
592
|
+
operation: 'finalize',
|
|
593
|
+
branch,
|
|
594
|
+
sha: head.trim(),
|
|
595
|
+
description: { kind: description.meta.kind, rev: description.meta.revision },
|
|
596
|
+
},
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
return completeFinalizePr(deps, events, {
|
|
600
|
+
branch,
|
|
601
|
+
baseBranch,
|
|
602
|
+
title: firstLine,
|
|
603
|
+
body,
|
|
604
|
+
mergeMessage: text,
|
|
605
|
+
})
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
case 'reconcile': {
|
|
609
|
+
const notesPath = requireNotes(opts.notes, "reconcile's 'ab done' deposits reconcile-notes")
|
|
610
|
+
const notes = await readTextFile(notesPath, '--notes')
|
|
611
|
+
await assertCleanWorktree(deps)
|
|
612
|
+
// The resolution lands as a merge commit (§15.7) — HEAD must have 2+
|
|
613
|
+
// parents (base merged into the branch, never rebase — D1).
|
|
614
|
+
const parentsLine = (await git(deps, ['rev-list', '--parents', '-n', '1', 'HEAD'])).trim()
|
|
615
|
+
const parts = parentsLine.split(/\s+/)
|
|
616
|
+
const head = parts[0]!
|
|
617
|
+
if (parts.length < 3) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`reconcile's 'ab done' requires HEAD to be a merge commit (2+ parents) — ` +
|
|
620
|
+
`HEAD ${head} has ${parts.length - 1} parent(s); merge the base branch ` +
|
|
621
|
+
'into this branch first (§15.7, D1: never rebase)',
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
const scratchPaths = await reconcileScratchViolations(
|
|
625
|
+
deps.exec,
|
|
626
|
+
deps.workspacePath,
|
|
627
|
+
head,
|
|
628
|
+
parts.slice(1),
|
|
629
|
+
)
|
|
630
|
+
if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
|
|
631
|
+
const branch = await buildBranch(deps)
|
|
632
|
+
if (remotePublication(events)) {
|
|
633
|
+
const { event } = await store.appendWithArtifacts(
|
|
634
|
+
env.build,
|
|
635
|
+
[{ kind: 'reconcile-notes', content: notes }],
|
|
636
|
+
(deposited) => ({
|
|
637
|
+
actor,
|
|
638
|
+
type: 'publication.requested',
|
|
639
|
+
payload: {
|
|
640
|
+
operation: 'reconcile',
|
|
641
|
+
branch,
|
|
642
|
+
sha: head,
|
|
643
|
+
artifact: refOf(deposited[0]),
|
|
644
|
+
},
|
|
645
|
+
}),
|
|
646
|
+
)
|
|
647
|
+
return event
|
|
648
|
+
}
|
|
649
|
+
// Regular push, NEVER force (D1): the merge commit extends the branch.
|
|
650
|
+
await deps.forge.pushBranch(deps.workspacePath, branch)
|
|
651
|
+
const { event } = await store.appendWithArtifacts(
|
|
652
|
+
env.build,
|
|
653
|
+
[{ kind: 'reconcile-notes', content: notes }],
|
|
654
|
+
(deposited) => ({
|
|
655
|
+
actor,
|
|
656
|
+
type: 'reconcile.completed',
|
|
657
|
+
payload: { mergeCommit: head, artifact: refOf(deposited[0]) },
|
|
658
|
+
}),
|
|
659
|
+
)
|
|
660
|
+
return event
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
default:
|
|
664
|
+
throw new Error(`'ab done' has no plumbing for phase "${env.phase}"`)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ── ab verdict (review + agent-verify phases — D5/D6) ────────────────────────
|
|
669
|
+
|
|
670
|
+
export interface VerdictOpts {
|
|
671
|
+
verdict: string
|
|
672
|
+
/** Notes file — review phases: required, deposited as the phase's artifact;
|
|
673
|
+
* agent-verify pass: optional, deposited as `verify-report:<step>`. */
|
|
674
|
+
notes?: string
|
|
675
|
+
/** FindingDraft[] JSON file (revise only). */
|
|
676
|
+
findings?: string
|
|
677
|
+
/** Escalation question, or the required explanation for a verify skip. */
|
|
678
|
+
reason?: string
|
|
679
|
+
/** Failure report file (agent-verify fail — required). */
|
|
680
|
+
report?: string
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const FINDING_DRAFT_SHAPE = [
|
|
684
|
+
'[',
|
|
685
|
+
' {',
|
|
686
|
+
' "severity": "blocking" | "important" | "minor",',
|
|
687
|
+
' "summary": "<one-line defect statement>",',
|
|
688
|
+
' "file": "src/…", // optional',
|
|
689
|
+
' "lines": [40, 62], // optional',
|
|
690
|
+
' "detail": "…", // optional',
|
|
691
|
+
' "persists": ["f_1c22"] // optional: prior-round finding ids this one continues (§15.4)',
|
|
692
|
+
' }',
|
|
693
|
+
']',
|
|
694
|
+
].join('\n')
|
|
695
|
+
|
|
696
|
+
export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<EventEnvelope[]> {
|
|
697
|
+
const { env, store } = deps
|
|
698
|
+
const spec = phaseSpecFor(env.phase)
|
|
699
|
+
if (spec.kind === 'producer') {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`'ab verdict' is not ${env.phase}'s terminal — ${env.phase} is a ` +
|
|
702
|
+
`producer phase; use 'ab done' (§8.2)`,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
// The verdict vocabulary is phase-dependent and enforced here (§8.2):
|
|
706
|
+
// review phases accept approve|revise|escalate; agent-verify steps accept
|
|
707
|
+
// pass|fail|skip.
|
|
708
|
+
const vocabulary = spec.verdictVocabulary ?? []
|
|
709
|
+
if (!vocabulary.includes(opts.verdict)) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`verdict "${opts.verdict}" is not in ${env.phase}'s vocabulary — §8.2: ` +
|
|
712
|
+
'review phases accept approve|revise|escalate; agent-verify steps ' +
|
|
713
|
+
`accept pass|fail|skip. ${env.phase} accepts: ${vocabulary.join('|')}`,
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
const events = await store.getEvents(env.build)
|
|
717
|
+
assertNoPriorTerminal(events, env)
|
|
718
|
+
const actor = agentActor(env.phase, env.session)
|
|
719
|
+
|
|
720
|
+
if (spec.kind === 'agent-verify') {
|
|
721
|
+
return agentVerifyVerdict(deps, opts, actor)
|
|
722
|
+
}
|
|
723
|
+
return reviewVerdict(deps, opts, events, actor)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function reviewVerdict(
|
|
727
|
+
deps: TerminalDeps,
|
|
728
|
+
opts: VerdictOpts,
|
|
729
|
+
events: AbEvent[],
|
|
730
|
+
actor: ReturnType<typeof agentActor>,
|
|
731
|
+
): Promise<EventEnvelope[]> {
|
|
732
|
+
const { env, store } = deps
|
|
733
|
+
const spec = phaseSpecFor(env.phase)
|
|
734
|
+
const kind = spec.requiredArtifact
|
|
735
|
+
if (kind === undefined) {
|
|
736
|
+
throw new Error(`review phase "${env.phase}" declares no artifact kind — phase table bug`)
|
|
737
|
+
}
|
|
738
|
+
if (opts.notes === undefined) {
|
|
739
|
+
throw new Error(
|
|
740
|
+
`'ab verdict ${opts.verdict}' in ${env.phase} requires --notes <file> — ` +
|
|
741
|
+
`the notes are deposited as the ${kind} artifact (D6, §8.3)`,
|
|
742
|
+
)
|
|
743
|
+
}
|
|
744
|
+
const notes = await readTextFile(opts.notes, '--notes')
|
|
745
|
+
|
|
746
|
+
let findings: Finding[] = []
|
|
747
|
+
let reason: string | undefined
|
|
748
|
+
if (opts.verdict === 'revise') {
|
|
749
|
+
if (opts.findings === undefined) {
|
|
750
|
+
throw new Error(
|
|
751
|
+
`'ab verdict revise' requires --findings <json file> with the structured ` +
|
|
752
|
+
`findings (D6). Expected shape:\n${FINDING_DRAFT_SHAPE}`,
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
findings = await parseFindings(deps, opts.findings, events)
|
|
756
|
+
}
|
|
757
|
+
if (opts.verdict === 'escalate') {
|
|
758
|
+
if (opts.reason === undefined || opts.reason.trim() === '') {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`'ab verdict escalate' requires --reason <text> — it becomes the ` +
|
|
761
|
+
'escalation question a human answers (§8.2, §11)',
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
reason = opts.reason
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const type: 'plan-review.verdict' | 'code-review.verdict' =
|
|
768
|
+
env.phase === 'plan-review' ? 'plan-review.verdict' : 'code-review.verdict'
|
|
769
|
+
// Atomic bundle (D6): notes artifact + verdict event in one operation — no
|
|
770
|
+
// state where the artifact exists without its event or vice versa.
|
|
771
|
+
const { event } = await store.appendWithArtifacts(
|
|
772
|
+
env.build,
|
|
773
|
+
[{ kind, content: notes }],
|
|
774
|
+
(deposited) => ({
|
|
775
|
+
actor,
|
|
776
|
+
type,
|
|
777
|
+
payload: {
|
|
778
|
+
round: env.round,
|
|
779
|
+
verdict: opts.verdict as ReviewVerdictKind,
|
|
780
|
+
findings,
|
|
781
|
+
artifact: refOf(deposited[0]),
|
|
782
|
+
...(reason !== undefined ? { reason } : {}),
|
|
783
|
+
},
|
|
784
|
+
}),
|
|
785
|
+
)
|
|
786
|
+
const out: EventEnvelope[] = [event]
|
|
787
|
+
|
|
788
|
+
if (opts.verdict === 'escalate') {
|
|
789
|
+
// Two appends: the verdict, then the escalation. A crash between them
|
|
790
|
+
// leaves a verdict{escalate} without its escalation.raised — the engine
|
|
791
|
+
// repairs that gap on resume (state is a reduction of events, §15.5).
|
|
792
|
+
out.push(
|
|
793
|
+
await store.append(env.build, {
|
|
794
|
+
actor,
|
|
795
|
+
type: 'escalation.raised',
|
|
796
|
+
payload: {
|
|
797
|
+
id: deps.ids('esc'),
|
|
798
|
+
phase: env.phase,
|
|
799
|
+
round: env.round,
|
|
800
|
+
source: 'agent',
|
|
801
|
+
question: reason!,
|
|
802
|
+
},
|
|
803
|
+
}),
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
return out
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Render findings-schema issues without discarding an untagged union's branch
|
|
811
|
+
* detail. Kept separate from parsing because the current accepted findings
|
|
812
|
+
* schema has no such union; this is the presentation contract for future ones.
|
|
813
|
+
*/
|
|
814
|
+
export function renderFindingsIssues(issues: readonly z.core.$ZodIssue[]): string {
|
|
815
|
+
return expandIssues(issues)
|
|
816
|
+
.map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
817
|
+
.join('\n')
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Parse, validate (D6: failures return the schema shape and the precise
|
|
822
|
+
* issue), check `persists` against prior rounds' finding ids for this loop,
|
|
823
|
+
* and stamp kernel-assigned ids (§15.4 — agents never self-assign).
|
|
824
|
+
*/
|
|
825
|
+
async function parseFindings(
|
|
826
|
+
deps: TerminalDeps,
|
|
827
|
+
path: string,
|
|
828
|
+
events: AbEvent[],
|
|
829
|
+
): Promise<Finding[]> {
|
|
830
|
+
const raw = await readTextFile(path, '--findings')
|
|
831
|
+
let json: unknown
|
|
832
|
+
try {
|
|
833
|
+
json = JSON.parse(raw)
|
|
834
|
+
} catch (error) {
|
|
835
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
836
|
+
throw new Error(
|
|
837
|
+
`--findings ${path} is not valid JSON: ${message}\n` +
|
|
838
|
+
`Expected shape (D6):\n${FINDING_DRAFT_SHAPE}`,
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
const parsed = z.array(findingDraftSchema).safeParse(json)
|
|
842
|
+
if (!parsed.success) {
|
|
843
|
+
const issues = renderFindingsIssues(parsed.error.issues)
|
|
844
|
+
throw new Error(
|
|
845
|
+
`--findings ${path} does not match the finding schema (D6):\n${issues}\n` +
|
|
846
|
+
`Expected shape:\n${FINDING_DRAFT_SHAPE}`,
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// `persists` may only reference ids from prior rounds of THIS loop (§15.4;
|
|
851
|
+
// ids are deposit-stamped, so a reviewer can only ever see rounds it was
|
|
852
|
+
// shown — §8.3).
|
|
853
|
+
const state = reduceBuild(events)
|
|
854
|
+
const loop =
|
|
855
|
+
deps.env.phase === 'plan-review'
|
|
856
|
+
? state.reviewFindings.planReview
|
|
857
|
+
: state.reviewFindings.codeReview
|
|
858
|
+
const known = new Set<string>()
|
|
859
|
+
loop.forEach((roundFindings, index) => {
|
|
860
|
+
if (index + 1 < deps.env.round) {
|
|
861
|
+
for (const finding of roundFindings) known.add(finding.id)
|
|
862
|
+
}
|
|
863
|
+
})
|
|
864
|
+
for (const draft of parsed.data) {
|
|
865
|
+
for (const id of draft.persists) {
|
|
866
|
+
if (!known.has(id)) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`persists id "${id}" does not exist in prior rounds' findings for this ` +
|
|
869
|
+
`loop — known ids: ${
|
|
870
|
+
known.size > 0
|
|
871
|
+
? [...known].join(', ')
|
|
872
|
+
: '(none — round 1 findings cannot persist anything)'
|
|
873
|
+
}`,
|
|
874
|
+
)
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return parsed.data.map((draft) => ({ ...draft, id: deps.ids('f') }))
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function agentVerifyVerdict(
|
|
883
|
+
deps: TerminalDeps,
|
|
884
|
+
opts: VerdictOpts,
|
|
885
|
+
actor: ReturnType<typeof agentActor>,
|
|
886
|
+
): Promise<EventEnvelope[]> {
|
|
887
|
+
const { env, store } = deps
|
|
888
|
+
if (!isVerifyPhase(env.phase)) {
|
|
889
|
+
throw new Error(`agent-verify verdict outside a verify phase: "${env.phase}"`)
|
|
890
|
+
}
|
|
891
|
+
const step = verifyStep(env.phase)
|
|
892
|
+
const reportKind = verifyReportKind(step)
|
|
893
|
+
|
|
894
|
+
if (opts.verdict === 'pass') {
|
|
895
|
+
// Optional --notes are deposited as the step's report kind and the event
|
|
896
|
+
// carries the ref — provenance for a pass is welcome, just not required.
|
|
897
|
+
if (opts.notes !== undefined) {
|
|
898
|
+
const notes = await readTextFile(opts.notes, '--notes')
|
|
899
|
+
const { event } = await store.appendWithArtifacts(
|
|
900
|
+
env.build,
|
|
901
|
+
[{ kind: reportKind, content: notes }],
|
|
902
|
+
(deposited) => ({
|
|
903
|
+
actor,
|
|
904
|
+
type: 'verify.completed',
|
|
905
|
+
payload: {
|
|
906
|
+
step,
|
|
907
|
+
attempt: env.round,
|
|
908
|
+
outcome: 'pass',
|
|
909
|
+
report: refOf(deposited[0]),
|
|
910
|
+
},
|
|
911
|
+
}),
|
|
912
|
+
)
|
|
913
|
+
return [event]
|
|
914
|
+
}
|
|
915
|
+
return [
|
|
916
|
+
await store.append(env.build, {
|
|
917
|
+
actor,
|
|
918
|
+
type: 'verify.completed',
|
|
919
|
+
payload: { step, attempt: env.round, outcome: 'pass' },
|
|
920
|
+
}),
|
|
921
|
+
]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (opts.verdict === 'skip') {
|
|
925
|
+
const reason = opts.reason?.trim()
|
|
926
|
+
if (reason === undefined || reason === '') {
|
|
927
|
+
throw new Error(
|
|
928
|
+
`'ab verdict skip' requires --reason <text> — a skipped verification ` +
|
|
929
|
+
'must leave a human-readable reason in the event log',
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
return [
|
|
933
|
+
await store.append(env.build, {
|
|
934
|
+
actor,
|
|
935
|
+
type: 'verify.completed',
|
|
936
|
+
payload: { step, attempt: env.round, outcome: 'skipped', reason },
|
|
937
|
+
}),
|
|
938
|
+
]
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// fail: the report is what routes back into the code loop (§5) — required.
|
|
942
|
+
if (opts.report === undefined) {
|
|
943
|
+
throw new Error(
|
|
944
|
+
`'ab verdict fail' requires --report <file> — the failure report is ` +
|
|
945
|
+
`deposited as ${reportKind} and routed back to implement (§5, D6)`,
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
const report = await readTextFile(opts.report, '--report')
|
|
949
|
+
const { event } = await store.appendWithArtifacts(
|
|
950
|
+
env.build,
|
|
951
|
+
[{ kind: reportKind, content: report }],
|
|
952
|
+
(deposited) => ({
|
|
953
|
+
actor,
|
|
954
|
+
type: 'verify.completed',
|
|
955
|
+
payload: {
|
|
956
|
+
step,
|
|
957
|
+
attempt: env.round,
|
|
958
|
+
outcome: 'fail',
|
|
959
|
+
report: refOf(deposited[0]),
|
|
960
|
+
},
|
|
961
|
+
}),
|
|
962
|
+
)
|
|
963
|
+
return [event]
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ── ab escalate (any phase — D5, §11) ────────────────────────────────────────
|
|
967
|
+
|
|
968
|
+
export interface EscalateOpts {
|
|
969
|
+
question: string
|
|
970
|
+
refs?: string[]
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export async function escalate(
|
|
974
|
+
deps: TerminalDeps,
|
|
975
|
+
opts: EscalateOpts,
|
|
976
|
+
): Promise<EventEnvelope<'escalation.raised'>> {
|
|
977
|
+
const { env, store } = deps
|
|
978
|
+
if (opts.question.trim() === '') {
|
|
979
|
+
throw new Error("'ab escalate' requires a question — it is what the human answers (§11)")
|
|
980
|
+
}
|
|
981
|
+
const events = await store.getEvents(env.build)
|
|
982
|
+
assertNoPriorTerminal(events, env)
|
|
983
|
+
return store.append(env.build, {
|
|
984
|
+
actor: agentActor(env.phase, env.session),
|
|
985
|
+
type: 'escalation.raised',
|
|
986
|
+
payload: {
|
|
987
|
+
id: deps.ids('esc'),
|
|
988
|
+
phase: env.phase,
|
|
989
|
+
round: env.round,
|
|
990
|
+
source: 'agent',
|
|
991
|
+
question: opts.question,
|
|
992
|
+
...(opts.refs !== undefined && opts.refs.length > 0 ? { refs: opts.refs } : {}),
|
|
993
|
+
},
|
|
994
|
+
})
|
|
995
|
+
}
|